diff --git a/components/buckets/replication-tab.tsx b/components/buckets/replication-tab.tsx index 5b1f87cf..b381d9f6 100644 --- a/components/buckets/replication-tab.tsx +++ b/components/buckets/replication-tab.tsx @@ -10,6 +10,7 @@ import { DataTable } from "@/components/data-table/data-table" import { useDataTable } from "@/hooks/use-data-table" import { useBucket } from "@/hooks/use-bucket" import { usePermissions } from "@/hooks/use-permissions" +import { useRuntimeCapabilities } from "@/hooks/use-runtime-capabilities" import { ReplicationNewForm } from "@/components/replication/new-form" import { useDialog } from "@/lib/feedback/dialog" import { useMessage } from "@/lib/feedback/message" @@ -35,10 +36,19 @@ export function BucketReplicationTab({ bucketName, hideTitle = false, renderHead const message = useMessage() const dialog = useDialog() const { canCapability } = usePermissions() + const { capabilities, error: capabilitiesError } = useRuntimeCapabilities() const { getBucketReplication, putBucketReplication, deleteBucketReplication, deleteRemoteReplicationTarget } = useBucket() const replicationContext = React.useMemo(() => ({ bucket: bucketName }), [bucketName]) - const canEditReplication = canCapability("bucket.replication.edit", replicationContext) + const replicationSupported = capabilities?.replication.bucketReplication.status.state === "supported" + const remoteTargetsSupported = capabilities?.replication.remoteTargets.status.state === "supported" + const replicationSupportMessage = + capabilities?.replication.bucketReplication.status.reason ?? + capabilities?.replication.remoteTargets.status.reason ?? + capabilitiesError ?? + t("Unable to load replication rules. Refresh before making changes.") + const canEditReplication = canCapability("bucket.replication.edit", replicationContext) && replicationSupported + const canAddReplication = canEditReplication && remoteTargetsSupported const [data, setData] = React.useState([]) const [loading, setLoading] = React.useState(false) @@ -195,18 +205,16 @@ export function BucketReplicationTab({ bucketName, hideTitle = false, renderHead enableSorting: false, cell: ({ row }) => (
- {canEditReplication ? ( - - ) : null} +
), }, @@ -222,15 +230,13 @@ export function BucketReplicationTab({ bucketName, hideTitle = false, renderHead const actions = ( <> - {canEditReplication ? ( - - ) : null} + @@ -539,6 +650,7 @@ export function ReplicationNewForm({ open, onOpenChange, bucketName, onSuccess } autoComplete="off" placeholder={t("Tag Name")} spellCheck={false} + disabled={controlsLocked || !canEditCurrentTagFilter} />
@@ -695,6 +809,7 @@ export function ReplicationNewForm({ open, onOpenChange, bucketName, onSuccess } name="replication-expired-delete-marker" checked={expiredDeleteMark} onCheckedChange={setExpiredDeleteMark} + disabled={controlsLocked || !canEditBucketField("Rule.DeleteMarkerReplication.Status")} /> @@ -710,6 +825,7 @@ export function ReplicationNewForm({ open, onOpenChange, bucketName, onSuccess } name="replication-delete" checked={replicateDelete} onCheckedChange={setReplicateDelete} + disabled={controlsLocked || !canEditBucketField("Rule.DeleteReplication.Status")} /> @@ -735,6 +851,7 @@ export function ReplicationNewForm({ open, onOpenChange, bucketName, onSuccess } aria-invalid={Boolean(fieldErrors.timecheck)} aria-describedby={fieldErrors.timecheck ? "replication-health-check-interval-error" : undefined} className="w-32" + disabled={controlsLocked || !canEditTargetField("healthCheckDuration")} /> {fieldErrors.timecheck} @@ -753,8 +870,13 @@ export function ReplicationNewForm({ open, onOpenChange, bucketName, onSuccess } value={bandwidth} onChange={(e) => setBandwidth(Number(e.target.value))} className="w-32" + disabled={controlsLocked || !canEditTargetField("bandwidth")} /> - setUnit(value ?? "")} + disabled={controlsLocked || !canEditTargetField("bandwidth")} + > @@ -771,6 +893,34 @@ export function ReplicationNewForm({ open, onOpenChange, bucketName, onSuccess } )} + + {bucketHistoricalFields.length || remoteTargetUnsupportedFields.length ? ( +
+ {t("Advanced Settings")} +
+ {bucketHistoricalFields.map((field) => ( +
+

{field.name}

+

+ + {t("Read-only settings")} + +

+
+ ))} + {remoteTargetUnsupportedFields.map((field) => ( +
+

{field.name}

+

+ + {t("Unsupported")} + +

+
+ ))} +
+
+ ) : null} @@ -784,8 +934,8 @@ export function ReplicationNewForm({ open, onOpenChange, bucketName, onSuccess } > {t("Cancel")} - diff --git a/hooks/use-bucket.ts b/hooks/use-bucket.ts index 68461c90..567d5533 100644 --- a/hooks/use-bucket.ts +++ b/hooks/use-bucket.ts @@ -355,6 +355,13 @@ export function useBucket() { [api], ) + const listRemoteReplicationTargets = useCallback( + async (bucket: string) => { + return api.get(`/list-remote-targets?bucket=${encodeURIComponent(bucket)}`) + }, + [api], + ) + const setRemoteReplicationTarget = useCallback( async (bucket: string, data: unknown) => { return api.put(`/set-remote-target?bucket=${encodeURIComponent(bucket)}`, data) @@ -421,6 +428,7 @@ export function useBucket() { putBucketReplication, deleteBucketReplication, deleteRemoteReplicationTarget, + listRemoteReplicationTargets, setRemoteReplicationTarget, listBucketNotifications, putBucketNotifications, diff --git a/hooks/use-runtime-capabilities.ts b/hooks/use-runtime-capabilities.ts new file mode 100644 index 00000000..305a8d6a --- /dev/null +++ b/hooks/use-runtime-capabilities.ts @@ -0,0 +1,46 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { useApi } from "@/contexts/api-context" +import { normalizeRuntimeCapabilities, type RuntimeCapabilitiesSnapshot } from "@/lib/runtime-capabilities" + +export function useRuntimeCapabilities() { + const api = useApi() + const [capabilities, setCapabilities] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const loadCapabilities = useCallback(async () => { + setIsLoading(true) + setError(null) + + try { + const response = await api.get(api.resolveUrl("/rustfs/admin/v4/runtime/capabilities"), { + suppress403Redirect: true, + }) + const normalized = normalizeRuntimeCapabilities(response) + setCapabilities(normalized.capabilities) + setError(normalized.error ?? null) + } catch (loadError) { + setCapabilities(null) + setError( + loadError instanceof Error && loadError.message + ? loadError.message + : "Replication capabilities are unavailable.", + ) + } finally { + setIsLoading(false) + } + }, [api]) + + useEffect(() => { + void loadCapabilities() + }, [loadCapabilities]) + + return { + capabilities, + isLoading, + error, + reload: loadCapabilities, + } +} diff --git a/lib/runtime-capabilities.ts b/lib/runtime-capabilities.ts new file mode 100644 index 00000000..568cc053 --- /dev/null +++ b/lib/runtime-capabilities.ts @@ -0,0 +1,173 @@ +"use client" + +type JsonRecord = Record + +export type RuntimeCapabilityFieldState = "supported" | "read_only_historical" | "unsupported" + +export interface RuntimeCapabilityField { + name: string + state: RuntimeCapabilityFieldState +} + +export interface RuntimeCapabilityStatus { + state: string + reason?: string +} + +export interface RuntimeCapabilityFeature { + contractVersion: number + status: RuntimeCapabilityStatus + fields: RuntimeCapabilityField[] +} + +export interface RuntimeCapabilitiesSnapshot { + replication: { + contractVersion: number + bucketReplication: RuntimeCapabilityFeature + remoteTargets: RuntimeCapabilityFeature + mixedVersionPolicy: string + } + storageClasses: { + contractVersion: number + supportedWriteClasses: string[] + unsupportedWriteError: string + legacyLabelBehavior: string + } +} + +export interface RuntimeCapabilitiesNormalization { + capabilities: RuntimeCapabilitiesSnapshot | null + error?: string +} + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" ? (value as JsonRecord) : {} +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +function asNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0 +} + +function normalizeStatus(value: unknown): RuntimeCapabilityStatus { + const record = asRecord(value) + + return { + state: asString(record.state).toLowerCase(), + ...(asString(record.reason) ? { reason: asString(record.reason) } : {}), + } +} + +function normalizeFieldState(value: unknown): RuntimeCapabilityFieldState { + const normalized = asString(value).toLowerCase() + + if (normalized === "supported" || normalized === "read_only_historical" || normalized === "unsupported") { + return normalized + } + + return "unsupported" +} + +function normalizeFields(value: unknown): RuntimeCapabilityField[] { + if (!Array.isArray(value)) { + return [] + } + + return value.flatMap((item) => { + const record = asRecord(item) + const name = asString(record.name) + if (!name) return [] + + return [{ name, state: normalizeFieldState(record.state) }] + }) +} + +function normalizeFeature(value: unknown): RuntimeCapabilityFeature | null { + const record = asRecord(value) + const contractVersion = asNumber(record.contract_version) + + if (contractVersion !== 1) { + return null + } + + return { + contractVersion, + status: normalizeStatus(record.status), + fields: normalizeFields(record.fields), + } +} + +export function normalizeRuntimeCapabilities(value: unknown): RuntimeCapabilitiesNormalization { + const record = asRecord(value) + const replication = normalizeFeature(record.replication) + const storageClasses = asRecord(record.storage_classes) + + if (!replication) { + return { + capabilities: null, + error: "Versioned replication capabilities are unavailable on this server.", + } + } + + const storageClassesVersion = asNumber(storageClasses.contract_version) + if (storageClassesVersion !== 1) { + return { + capabilities: null, + error: "Storage-class capabilities are unavailable on this server.", + } + } + + const bucketReplication = normalizeFeature(replication ? asRecord(record.replication).bucket_replication : null) + const remoteTargets = normalizeFeature(replication ? asRecord(record.replication).remote_targets : null) + + if (!bucketReplication || !remoteTargets) { + return { + capabilities: null, + error: "Replication capabilities are unavailable on this server.", + } + } + + return { + capabilities: { + replication: { + contractVersion: asNumber(asRecord(record.replication).contract_version), + bucketReplication, + remoteTargets, + mixedVersionPolicy: asString(asRecord(record.replication).mixed_version_policy), + }, + storageClasses: { + contractVersion: storageClassesVersion, + supportedWriteClasses: Array.isArray(storageClasses.supported_write_classes) + ? storageClasses.supported_write_classes.flatMap((item) => (typeof item === "string" && item ? [item] : [])) + : [], + unsupportedWriteError: asString(storageClasses.unsupported_write_error), + legacyLabelBehavior: asString(storageClasses.legacy_label_behavior), + }, + }, + } +} + +export function getRuntimeCapabilityFieldState( + capabilities: RuntimeCapabilitiesSnapshot | null, + feature: "bucketReplication" | "remoteTargets", + fieldName: string, +): RuntimeCapabilityFieldState { + if (!capabilities) { + return "unsupported" + } + + const capability = capabilities.replication[feature] + if (capability.status.state !== "supported") { + return "unsupported" + } + + const fields = capability.fields + return fields.find((field) => field.name === fieldName)?.state ?? "unsupported" +} + +export function isRuntimeCapabilitiesSupported(capabilities: RuntimeCapabilitiesSnapshot | null): boolean { + return Boolean(capabilities) +} diff --git a/tests/lib/bucket-settings-safety.test.js b/tests/lib/bucket-settings-safety.test.js index fc5d8755..81d48686 100644 --- a/tests/lib/bucket-settings-safety.test.js +++ b/tests/lib/bucket-settings-safety.test.js @@ -33,6 +33,10 @@ const replicationFormSource = await readFile( new URL("../../components/replication/new-form.tsx", import.meta.url), "utf8", ) +const runtimeCapabilitiesHookSource = await readFile( + new URL("../../hooks/use-runtime-capabilities.ts", import.meta.url), + "utf8", +) const lifecycleFormSource = await readFile(new URL("../../components/lifecycle/new-form.tsx", import.meta.url), "utf8") const eventsFormSource = await readFile(new URL("../../components/events/new-form.tsx", import.meta.url), "utf8") const bucketHookSource = await readFile(new URL("../../hooks/use-bucket.ts", import.meta.url), "utf8") @@ -220,6 +224,26 @@ test("replication creation rereads rules and never removes an uncertain remote t assert.doesNotMatch(replicationTabSource, /Replication configuration Role is missing/) }) +test("replication controls fail closed on missing capabilities and preserve target identity", () => { + assert.match(runtimeCapabilitiesHookSource, /api\.resolveUrl\("\/rustfs\/admin\/v4\/runtime\/capabilities"\)/) + assert.match(replicationFormSource, /useRuntimeCapabilities/) + assert.match(replicationFormSource, /credentials: \{[\s\S]{0,100}accessKey,[\s\S]{0,100}secretKey,[\s\S]{0,100}\}/) + assert.match(replicationFormSource, /listRemoteReplicationTargets/) + assert.match(replicationFormSource, /controlsLocked/) + assert.match(replicationFormSource, /requiredBucketFieldsSupported/) + assert.match(replicationFormSource, /requiredTargetFieldsSupported/) + assert.match(replicationFormSource, /canEditTargetField\("healthCheckDuration"\)/) + assert.match(replicationFormSource, /canEditBucketField\("Rule\.DeleteReplication\.Status"\)/) + assert.match(replicationFormSource, /canEditBucketField\("Rule\.Filter\.Prefix"\)/) + assert.match(replicationFormSource, /Advanced Settings/) + assert.match(replicationFormSource, /currentTarget\.arn/) + assert.doesNotMatch(replicationFormSource, /expiration: "0001-01-01T00:00:00Z"/) + assert.doesNotMatch(replicationFormSource, /SseKmsEncryptedObjects/) + assert.match(replicationTabSource, /useRuntimeCapabilities/) + assert.match(replicationTabSource, /!canEditReplication/) + assert.match(replicationTabSource, /Replication rules unavailable/) +}) + test("bucket setting controls expose names, field errors, and unresolved dependency states", () => { assert.match(bucketInfoSource, /aria-label=\{t\("Versioning"\)\}/) assert.match(bucketInfoSource, /bucket-policy-error/) diff --git a/tests/lib/runtime-capabilities.test.ts b/tests/lib/runtime-capabilities.test.ts new file mode 100644 index 00000000..b513e4d4 --- /dev/null +++ b/tests/lib/runtime-capabilities.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + getRuntimeCapabilityFieldState, + isRuntimeCapabilitiesSupported, + normalizeRuntimeCapabilities, +} from "../../lib/runtime-capabilities" + +test("runtime capabilities normalize supported and historical replication fields", () => { + const normalized = normalizeRuntimeCapabilities({ + replication: { + contract_version: 1, + bucket_replication: { + contract_version: 1, + status: { state: "supported", reason: "bucket replication is available" }, + fields: [ + { name: "Rule.DeleteReplication.Status", state: "supported" }, + { name: "Destination.EncryptionConfiguration", state: "read_only_historical" }, + ], + }, + remote_targets: { + contract_version: 1, + status: { state: "supported", reason: "remote targets are available" }, + fields: [ + { name: "bandwidth", state: "supported" }, + { name: "disableProxy", state: "unsupported" }, + ], + }, + mixed_version_policy: "fail_closed_when_capability_unknown_or_unsupported", + }, + storage_classes: { + contract_version: 1, + supported_write_classes: ["STANDARD", "REDUCED_REDUNDANCY"], + unsupported_write_error: "InvalidStorageClass", + legacy_label_behavior: "normalized_to_effective_class", + }, + }) + + assert.equal(isRuntimeCapabilitiesSupported(normalized.capabilities), true) + assert.equal( + getRuntimeCapabilityFieldState(normalized.capabilities, "bucketReplication", "Rule.DeleteReplication.Status"), + "supported", + ) + assert.equal( + getRuntimeCapabilityFieldState(normalized.capabilities, "bucketReplication", "Destination.EncryptionConfiguration"), + "read_only_historical", + ) + assert.equal(getRuntimeCapabilityFieldState(normalized.capabilities, "remoteTargets", "disableProxy"), "unsupported") + assert.deepEqual(normalized.capabilities?.storageClasses.supportedWriteClasses, ["STANDARD", "REDUCED_REDUNDANCY"]) +}) + +test("runtime capabilities fail closed on unknown contract versions", () => { + const normalized = normalizeRuntimeCapabilities({ + replication: { + contract_version: 2, + bucket_replication: { + contract_version: 1, + status: { state: "supported" }, + fields: [], + }, + remote_targets: { + contract_version: 1, + status: { state: "supported" }, + fields: [], + }, + mixed_version_policy: "fail_closed_when_capability_unknown_or_unsupported", + }, + storage_classes: { + contract_version: 1, + supported_write_classes: ["STANDARD"], + unsupported_write_error: "InvalidStorageClass", + legacy_label_behavior: "normalized_to_effective_class", + }, + }) + + assert.equal(normalized.capabilities, null) + assert.match(normalized.error ?? "", /unavailable/) + assert.equal(getRuntimeCapabilityFieldState(normalized.capabilities, "remoteTargets", "disableProxy"), "unsupported") +}) + +test("runtime capability fields fail closed when a feature is unavailable", () => { + const normalized = normalizeRuntimeCapabilities({ + replication: { + contract_version: 1, + bucket_replication: { + contract_version: 1, + status: { state: "supported" }, + fields: [{ name: "Rule.ID", state: "supported" }], + }, + remote_targets: { + contract_version: 1, + status: { state: "unsupported", reason: "remote target routes are unavailable" }, + fields: [{ name: "endpoint", state: "supported" }], + }, + mixed_version_policy: "fail_closed_when_capability_unknown_or_unsupported", + }, + storage_classes: { + contract_version: 1, + supported_write_classes: ["STANDARD"], + unsupported_write_error: "InvalidStorageClass", + legacy_label_behavior: "normalized_to_effective_class", + }, + }) + + assert.equal(getRuntimeCapabilityFieldState(normalized.capabilities, "remoteTargets", "endpoint"), "unsupported") +})