From 436192a00ee87bdbcd0176efb02ae96ff2c1c63f Mon Sep 17 00:00:00 2001 From: GatewayJ <18332154+GatewayJ@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:47:06 +0800 Subject: [PATCH 1/2] fix(operator): support restricted pod security --- Cargo.lock | 11 + Cargo.toml | 1 + .../[name]/tenant-detail-client.tsx | 97 +- .../app/(dashboard)/tenants/new/page.tsx | 227 +-- console-web/i18n/locales/en-US.json | 5 +- console-web/i18n/locales/zh-CN.json | 6 +- console-web/lib/api.ts | 4 + console-web/package.json | 3 +- console-web/pnpm-lock.yaml | 10 - console-web/types/api.ts | 13 +- deploy/rustfs-operator/README.md | 58 +- deploy/rustfs-operator/crds/tenant-crd.yaml | 290 ++- deploy/rustfs-operator/crds/tenant.yaml | 290 ++- deploy/rustfs-operator/templates/NOTES.txt | 2 +- docs/operator-user-guide.md | 123 +- docs/operator-user-guide.zh-CN.md | 106 +- e2e/Cargo.lock | 11 + e2e/FAULT_TESTING.md | 5 +- e2e/README.md | 10 +- e2e/src/framework/cert_manager_tls.rs | 2 +- e2e/src/framework/config.rs | 2 +- e2e/src/framework/images.rs | 2 +- e2e/src/framework/kind.rs | 17 +- e2e/src/framework/resources.rs | 59 +- e2e/src/framework/tenant_factory.rs | 33 +- examples/README.md | 2 +- examples/cert-manager-ca-trust-tenant.yaml | 2 +- examples/cluster-expansion-tenant.yaml | 2 +- examples/custom-rbac-tenant.yaml | 6 +- examples/geographic-pools-tenant.yaml | 2 +- examples/hardware-pools-tenant.yaml | 2 +- examples/minimal-dev-tenant.yaml | 2 +- examples/multi-cert-tls-tenant.yaml | 2 +- examples/multi-pool-tenant.yaml | 2 +- examples/production-ha-tenant.yaml | 2 +- examples/provisioning-tenant.yaml | 2 +- examples/secret-credentials-tenant.yaml | 4 +- examples/simple-tenant.yaml | 15 +- examples/spot-instance-tenant.yaml | 2 +- examples/tenant-4nodes.yaml | 4 +- src/console/handlers/encryption.rs | 9 + src/console/handlers/mod.rs | 17 + src/console/handlers/pools.rs | 55 +- src/console/handlers/security_context.rs | 239 ++- src/console/handlers/tenants.rs | 904 ++++++++- src/console/models/encryption.rs | 136 +- src/console/models/tenant.rs | 6 +- src/console/openapi.rs | 46 + src/console/routes/mod.rs | 4 + src/context.rs | 2 + src/lib.rs | 17 + src/reconcile.rs | 8 + src/reconcile/phases.rs | 7 + src/reconcile/pool_lifecycle.rs | 2 + src/status.rs | 56 + src/tests.rs | 2 + src/types/error.rs | 6 + src/types/v1alpha1.rs | 91 + src/types/v1alpha1/encryption.rs | 26 +- src/types/v1alpha1/pool.rs | 13 + src/types/v1alpha1/security_context.rs | 77 + src/types/v1alpha1/status.rs | 18 + src/types/v1alpha1/tenant.rs | 14 +- src/types/v1alpha1/tenant/helper.rs | 2 +- src/types/v1alpha1/tenant/workloads.rs | 1736 ++++++++++++++++- 65 files changed, 4438 insertions(+), 493 deletions(-) create mode 100644 src/types/v1alpha1/security_context.rs diff --git a/Cargo.lock b/Cargo.lock index 2a603c8..4c580cb 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -1655,6 +1655,7 @@ dependencies = [ "rustls-webpki", "schemars", "serde", + "serde_ignored", "serde_json", "serde_yaml_ng", "sha2", @@ -2307,6 +2308,16 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.148" diff --git a/Cargo.toml b/Cargo.toml index a28e532..2cc7b66 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ futures = "0.3.31" tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } serde_json = "1.0.148" +serde_ignored = "0.1.14" serde_yaml_ng = "0.10.0" strum = { version = "0.27.2", features = ["derive"] } k8s-openapi = { version = "0.26.1", features = ["v1_30", "schemars"] } diff --git a/console-web/app/(dashboard)/tenants/[namespace]/[name]/tenant-detail-client.tsx b/console-web/app/(dashboard)/tenants/[namespace]/[name]/tenant-detail-client.tsx index f3534fe..10468f7 100644 --- a/console-web/app/(dashboard)/tenants/[namespace]/[name]/tenant-detail-client.tsx +++ b/console-web/app/(dashboard)/tenants/[namespace]/[name]/tenant-detail-client.tsx @@ -33,6 +33,7 @@ import type { AddPoolRequest, EncryptionInfoResponse, UpdateEncryptionRequest, + UpdateSecurityContextRequest, ProvisioningItemStatus, } from "@/types/api" import { ApiError } from "@/lib/api-client" @@ -115,6 +116,42 @@ function provisioningItemDetails(item: ProvisioningItemStatus): string { return details.length > 0 ? details.join(" ") : "-" } +type RunAsNonRootMode = "default" | "true" | "false" + +interface SecurityContextFormState { + runAsUser: string + runAsGroup: string + fsGroup: string + runAsNonRoot: RunAsNonRootMode +} + +type SecurityContextDirtyFields = Record + +const cleanSecurityContextDirtyFields = (): SecurityContextDirtyFields => ({ + runAsUser: false, + runAsGroup: false, + fsGroup: false, + runAsNonRoot: false, +}) + +function nullableInteger(value: string): number | null { + return value.trim() === "" ? null : Number.parseInt(value, 10) +} + +function buildSecurityContextUpdate( + form: SecurityContextFormState, + dirty: SecurityContextDirtyFields, +): UpdateSecurityContextRequest { + const update: UpdateSecurityContextRequest = {} + if (dirty.runAsUser) update.runAsUser = nullableInteger(form.runAsUser) + if (dirty.runAsGroup) update.runAsGroup = nullableInteger(form.runAsGroup) + if (dirty.fsGroup) update.fsGroup = nullableInteger(form.fsGroup) + if (dirty.runAsNonRoot) { + update.runAsNonRoot = form.runAsNonRoot === "default" ? null : form.runAsNonRoot === "true" + } + return update +} + export function TenantDetailClient({ namespace, name, initialTab, initialYamlEditable }: TenantDetailClientProps) { const router = useRouter() const { t } = useTranslation() @@ -173,12 +210,13 @@ export function TenantDetailClient({ namespace, name, initialTab, initialYamlEdi const [secCtxLoaded, setSecCtxLoaded] = useState(false) const [secCtxLoading, setSecCtxLoading] = useState(false) const [secCtxSaving, setSecCtxSaving] = useState(false) - const [secCtx, setSecCtx] = useState({ + const [secCtx, setSecCtx] = useState({ runAsUser: "", runAsGroup: "", fsGroup: "", - runAsNonRoot: true, + runAsNonRoot: "default", }) + const [secCtxDirty, setSecCtxDirty] = useState(cleanSecurityContextDirtyFields) const loadTenant = async () => { const [detailResult, poolResult, podResult] = await Promise.allSettled([ @@ -297,6 +335,11 @@ export function TenantDetailClient({ namespace, name, initialTab, initialYamlEdi loadSecurityContext() }, [tab, secCtxLoaded, secCtxLoading]) // eslint-disable-line react-hooks/exhaustive-deps -- only lazy-load once per tenant + useEffect(() => { + setSecCtxLoaded(false) + setSecCtxDirty(cleanSecurityContextDirtyFields()) + }, [namespace, name]) + const loadSecurityContext = async () => { setSecCtxLoading(true) try { @@ -305,8 +348,9 @@ export function TenantDetailClient({ namespace, name, initialTab, initialYamlEdi runAsUser: data.runAsUser?.toString() ?? "", runAsGroup: data.runAsGroup?.toString() ?? "", fsGroup: data.fsGroup?.toString() ?? "", - runAsNonRoot: data.runAsNonRoot ?? true, + runAsNonRoot: data.runAsNonRoot === null ? "default" : data.runAsNonRoot ? "true" : "false", }) + setSecCtxDirty(cleanSecurityContextDirtyFields()) } catch (e) { const err = e as ApiError toast.error(err.message || t("Failed to load security context")) @@ -320,12 +364,8 @@ export function TenantDetailClient({ namespace, name, initialTab, initialYamlEdi e.preventDefault() setSecCtxSaving(true) try { - await api.updateSecurityContext(namespace, name, { - runAsUser: secCtx.runAsUser ? parseInt(secCtx.runAsUser, 10) : undefined, - runAsGroup: secCtx.runAsGroup ? parseInt(secCtx.runAsGroup, 10) : undefined, - fsGroup: secCtx.fsGroup ? parseInt(secCtx.fsGroup, 10) : undefined, - runAsNonRoot: secCtx.runAsNonRoot, - }) + await api.updateSecurityContext(namespace, name, buildSecurityContextUpdate(secCtx, secCtxDirty)) + await loadSecurityContext() toast.success(t("SecurityContext updated")) } catch (e) { const err = e as ApiError @@ -1248,7 +1288,7 @@ export function TenantDetailClient({ namespace, name, initialTab, initialYamlEdi {t("SecurityContext")} {t( - "Override Pod SecurityContext for RustFS pods (runAsUser, runAsGroup, fsGroup). Changes apply after Pods are recreated.", + "Override Pod SecurityContext UID/GID fields. Use Raw YAML for seccomp, container, and Pool-level settings. Changes apply after Pods are recreated.", )} @@ -1267,7 +1307,10 @@ export function TenantDetailClient({ namespace, name, initialTab, initialYamlEdi type="number" placeholder="10001" value={secCtx.runAsUser} - onChange={(e) => setSecCtx((s) => ({ ...s, runAsUser: e.target.value }))} + onChange={(e) => { + setSecCtx((s) => ({ ...s, runAsUser: e.target.value })) + setSecCtxDirty((dirty) => ({ ...dirty, runAsUser: true })) + }} />
@@ -1276,7 +1319,10 @@ export function TenantDetailClient({ namespace, name, initialTab, initialYamlEdi type="number" placeholder="10001" value={secCtx.runAsGroup} - onChange={(e) => setSecCtx((s) => ({ ...s, runAsGroup: e.target.value }))} + onChange={(e) => { + setSecCtx((s) => ({ ...s, runAsGroup: e.target.value })) + setSecCtxDirty((dirty) => ({ ...dirty, runAsGroup: true })) + }} />
@@ -1285,20 +1331,29 @@ export function TenantDetailClient({ namespace, name, initialTab, initialYamlEdi type="number" placeholder="10001" value={secCtx.fsGroup} - onChange={(e) => setSecCtx((s) => ({ ...s, fsGroup: e.target.value }))} + onChange={(e) => { + setSecCtx((s) => ({ ...s, fsGroup: e.target.value })) + setSecCtxDirty((dirty) => ({ ...dirty, fsGroup: true })) + }} />
-
-
diff --git a/console-web/app/(dashboard)/tenants/new/page.tsx b/console-web/app/(dashboard)/tenants/new/page.tsx index 325bee8..cced639 100644 --- a/console-web/app/(dashboard)/tenants/new/page.tsx +++ b/console-web/app/(dashboard)/tenants/new/page.tsx @@ -3,7 +3,6 @@ import { useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { parse } from "yaml" import { useTranslation } from "react-i18next" import { toast } from "sonner" import { RiArrowLeftLine } from "@remixicon/react" @@ -16,18 +15,12 @@ import { Label } from "@/components/ui/label" import { Spinner } from "@/components/ui/spinner" import { routes } from "@/lib/routes" import * as api from "@/lib/api" -import type { - CreatePoolRequest, - CreateTenantRequest, - ProvisioningBucket, - ProvisioningPolicy, - ProvisioningUser, -} from "@/types/api" +import type { CreatePoolRequest, CreateTenantRequest, TenantListItem } from "@/types/api" import { ApiError } from "@/lib/api-client" type CreateMode = "form" | "yaml" -const DEFAULT_RUSTFS_IMAGE = "rustfs/rustfs:latest" +const DEFAULT_RUSTFS_IMAGE = "rustfs/rustfs:1.0.0-beta.10" const defaultPool: CreatePoolRequest = { name: "pool-0", @@ -59,37 +52,6 @@ spec: storage: 10Gi ` -function asRecord(value: unknown): Record | null { - if (typeof value !== "object" || value == null || Array.isArray(value)) return null - return value as Record -} - -function asString(value: unknown): string | undefined { - if (typeof value !== "string") return undefined - const trimmed = value.trim() - return trimmed ? trimmed : undefined -} - -function asPositiveInt(value: unknown): number | undefined { - if (typeof value === "number" && Number.isInteger(value) && value > 0) return value - if (typeof value === "string") { - const parsed = Number.parseInt(value, 10) - if (Number.isInteger(parsed) && parsed > 0) return parsed - } - return undefined -} - -function asBoolean(value: unknown): boolean | undefined { - if (typeof value === "boolean") return value - return undefined -} - -function asStringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined - const values = value.map(asString) - return values.every((item): item is string => !!item) ? values : undefined -} - export default function TenantCreatePage() { const { t } = useTranslation() const router = useRouter() @@ -130,179 +92,15 @@ export default function TenantCreatePage() { setPools((prev) => prev.filter((_, i) => i !== index)) } - const parseYamlToCreateRequest = (rawYaml: string): CreateTenantRequest => { - let doc: unknown - try { - doc = parse(rawYaml) - } catch { - throw new Error(t("YAML format is invalid")) - } - - const root = asRecord(doc) - if (!root) { - throw new Error(t("YAML format is invalid")) - } - - const metadata = asRecord(root.metadata) - const spec = asRecord(root.spec) - const apiVersion = asString(root.apiVersion) - const kind = asString(root.kind) - const parsedName = asString(metadata?.name) - const parsedNamespace = asString(metadata?.namespace) - - if (apiVersion !== "rustfs.com/v1alpha1" || kind !== "Tenant") { - throw new Error(t("YAML must be a rustfs.com/v1alpha1 Tenant")) - } - - if (!parsedName || !parsedNamespace) { - throw new Error(t("YAML must include metadata.name and metadata.namespace")) - } - - const poolsRaw = spec?.pools - if (!Array.isArray(poolsRaw) || poolsRaw.length === 0) { - throw new Error(t("YAML must include spec.pools with at least one item")) - } - - const parsedPools: CreatePoolRequest[] = poolsRaw.map((poolItem, index) => { - const pool = asRecord(poolItem) - const persistence = asRecord(pool?.persistence) - const volumeClaimTemplate = asRecord(persistence?.volumeClaimTemplate ?? persistence?.volume_claim_template) - const resources = asRecord(volumeClaimTemplate?.resources) - const requests = asRecord(resources?.requests) - const servers = asPositiveInt(pool?.servers) - const volumesPerServer = asPositiveInt( - pool?.volumesPerServer ?? - pool?.volumes_per_server ?? - persistence?.volumesPerServer ?? - persistence?.volumes_per_server, - ) - const storageSize = asString(pool?.storageSize ?? pool?.storage_size ?? pool?.size ?? requests?.storage) - - if (!pool || !servers || !volumesPerServer || !storageSize) { - throw new Error(t("YAML pool fields are invalid")) - } - - return { - name: asString(pool.name) ?? `pool-${index}`, - servers, - volumes_per_server: volumesPerServer, - storage_size: storageSize, - storage_class: - asString( - pool.storageClass ?? - pool.storage_class ?? - volumeClaimTemplate?.storageClassName ?? - volumeClaimTemplate?.storage_class_name, - ) || undefined, - } - }) - - const specSc = asRecord(spec?.securityContext ?? spec?.security_context) - const credsSecretRef = asRecord(spec?.credsSecret ?? spec?.creds_secret) - const security_context = specSc - ? { - runAsUser: asPositiveInt(specSc.runAsUser ?? specSc.run_as_user), - runAsGroup: asPositiveInt(specSc.runAsGroup ?? specSc.run_as_group), - fsGroup: asPositiveInt(specSc.fsGroup ?? specSc.fs_group), - runAsNonRoot: - typeof specSc.runAsNonRoot === "boolean" - ? specSc.runAsNonRoot - : typeof specSc.run_as_non_root === "boolean" - ? specSc.run_as_non_root - : true, - } - : undefined - - const policiesRaw = spec?.policies - const policies = Array.isArray(policiesRaw) - ? policiesRaw.map((item): ProvisioningPolicy => { - const policy = asRecord(item) - const document = asRecord(policy?.document) - const configMapKeyRef = asRecord(document?.configMapKeyRef ?? document?.config_map_key_ref) - const policyName = asString(policy?.name) - const configMapName = asString(configMapKeyRef?.name) - const key = asString(configMapKeyRef?.key) - if (!policy || !policyName || !configMapName || !key) { - throw new Error(t("YAML policy provisioning fields are invalid")) - } - return { - name: policyName, - document: { - configMapKeyRef: { - name: configMapName, - key, - }, - }, - } - }) - : undefined - - const usersRaw = spec?.users - const users = Array.isArray(usersRaw) - ? usersRaw.map((item): ProvisioningUser => { - const user = asRecord(item) - const userName = asString(user?.name) - const userPolicies = asStringArray(user?.policies) - const rawCredsSecret = user?.credsSecret ?? user?.creds_secret - const credsSecret = asRecord(rawCredsSecret) - const credsSecretName = asString(credsSecret?.name) - if ( - !user || - !userName || - !userPolicies || - userPolicies.length === 0 || - (rawCredsSecret != null && !credsSecretName) - ) { - throw new Error(t("YAML user provisioning fields are invalid")) - } - return { - name: userName, - credsSecret: credsSecretName ? { name: credsSecretName } : undefined, - policies: userPolicies, - } - }) - : undefined - - const bucketsRaw = spec?.buckets - const buckets = Array.isArray(bucketsRaw) - ? bucketsRaw.map((item): ProvisioningBucket => { - const bucket = asRecord(item) - const bucketName = asString(bucket?.name) - if (!bucket || !bucketName) { - throw new Error(t("YAML bucket provisioning fields are invalid")) - } - return { - name: bucketName, - region: asString(bucket.region), - objectLock: asBoolean(bucket.objectLock ?? bucket.object_lock), - } - }) - : undefined - - return { - name: parsedName, - namespace: parsedNamespace, - pools: parsedPools, - image: asString(spec?.image), - mount_path: asString(spec?.mountPath ?? spec?.mount_path), - creds_secret: asString(spec?.credsSecret ?? spec?.creds_secret) ?? asString(credsSecretRef?.name), - policies, - users, - buckets, - security_context, - } - } - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setLoading(true) try { - let requestBody: CreateTenantRequest + let createdTenant: TenantListItem if (mode === "yaml") { - // Create-by-YAML endpoint is pending; convert YAML to current JSON create payload. - requestBody = parseYamlToCreateRequest(yamlContent) + createdTenant = await api.createTenantYaml({ yaml: yamlContent }) } else { if (!name.trim()) { toast.warning(t("Tenant name is required")) @@ -317,7 +115,7 @@ export default function TenantCreatePage() { toast.warning(t("Image is required")) return } - requestBody = { + const requestBody: CreateTenantRequest = { name: name.trim(), namespace: namespace.trim(), pools: pools.map((p) => ({ @@ -333,11 +131,11 @@ export default function TenantCreatePage() { runAsNonRoot: securityContext.runAsNonRoot, }, } + createdTenant = await api.createTenant(requestBody) } - await api.createTenant(requestBody) toast.success(t("Tenant created")) - router.push(routes.tenantDetail(requestBody.namespace, requestBody.name)) + router.push(routes.tenantDetail(createdTenant.namespace, createdTenant.name)) } catch (e) { const err = e as ApiError const fallback = e instanceof Error ? e.message : t("Create failed") @@ -411,8 +209,13 @@ export default function TenantCreatePage() { required value={image} onChange={(e) => setImage(e.target.value)} - placeholder="rustfs/rustfs:latest" + placeholder="rustfs/rustfs:1.0.0-beta.10" /> +

+ {t( + "Use YAML mode for custom repositories, mutable tags, or digest-qualified images and set runtime-default-image-ack to the exact image reference.", + )} +