From 48c1a2c2976a0682defb3badc5384ea42ff8550e Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 13 Jun 2026 11:01:04 +0000 Subject: [PATCH] feat: make hwlab v03 admin secret yaml driven --- config/hwlab-node-lanes.yaml | 20 +++ docs/reference/cli.md | 2 +- scripts/src/hwlab-node-lanes.ts | 46 +++++ scripts/src/hwlab-node.ts | 289 ++++++++++++++++++++++++++++++-- 4 files changed, 345 insertions(+), 12 deletions(-) diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 9f22df9d..91b9aa1a 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -95,6 +95,16 @@ lanes: public: webUrl: http://74.48.78.17:20666 apiUrl: http://74.48.78.17:20667 + bootstrapAdmin: + username: admin + displayName: HWLAB v0.3 Admin + passwordSourceRef: hwlab/g14-v03-bootstrap-admin.env + passwordSourceKey: HWLAB_BOOTSTRAP_ADMIN_PASSWORD + passwordHashTransform: hwlab-sha256 + secretName: hwlab-v03-bootstrap-admin + secretKey: password-hash + rollout: + deployment: hwlab-cloud-api targets: D601: workspace: /home/ubuntu/workspace/hwlab-v03 @@ -142,6 +152,16 @@ lanes: public: webUrl: https://hwlab.pikapython.com apiUrl: https://hwlab.pikapython.com + bootstrapAdmin: + username: admin + displayName: HWLAB v0.3 Admin + passwordSourceRef: hwlab/d601-v03-bootstrap-admin.env + passwordSourceKey: HWLAB_BOOTSTRAP_ADMIN_PASSWORD + passwordHashTransform: hwlab-sha256 + secretName: hwlab-v03-bootstrap-admin + secretKey: password-hash + rollout: + deployment: hwlab-cloud-api publicExposure: mode: pk01-caddy-frp publicBaseUrl: https://hwlab.pikapython.com diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 53b128a4..fc441a8c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -20,7 +20,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动 `hwlab nodes secret status|ensure --node G14 --lane v03 --name hwlab-v03-code-agent-provider` 是 v03 Code Agent / MoonBridge provider SecretRef 的受控 bootstrap 入口;`ensure` 只从集群内既有 `hwlab-v02/hwlab-v02-code-agent-provider` 复制 `openai-api-key`、`opencode-api-key` 到 lane-local Secret,输出仅披露 source/target Secret 名、key presence、decoded byte count、mutation 和后续命令,禁止打印 base64、解码值、完整 API key 或可复用凭据。OpenFGA 和 master admin API key 继续使用同一命名空间下的 `hwlab nodes secret ... --name hwlab-v03-openfga|hwlab-v03-master-server-admin-api-key`。 -`hwlab.pikapython.com` / D601 v03 的 bootstrap admin password 是 HWLAB runtime Secret 生命周期的一部分,必须收敛到 UniDesk YAML 与受控 `hwlab nodes secret ...` CLI;明文只能存在于 Git 忽略、owner-only 的 `.state/secrets/...` 来源文件,CLI、issue、日志和 trace 只能输出 presence、byte count、fingerprint、mutation 与后续命令。当前声明式重设能力缺口由 [GitHub issue #319](https://github.com/pikasTech/unidesk/issues/319) 追踪;不要把人工生成 hash、手工写 k8s Secret 或原生 `kubectl rollout` 沉淀为长期入口。 +G14/D601 v03 的 bootstrap admin password 是 HWLAB runtime Secret 生命周期的一部分,必须收敛到 `config/hwlab-node-lanes.yaml` 的 `bootstrapAdmin` 声明与受控 `hwlab nodes secret status|ensure --node --lane v03 --name hwlab-v03-bootstrap-admin` CLI。明文只能存在于 Git 忽略、owner-only 的 `.state/secrets/...` sourceRef 文件;CLI 在本地把明文转换为 HWLAB 兼容 password hash,只向运行面同步 `password-hash`,并在输出中只披露 sourceRef、sourceKey、target Secret/key、presence、byte count、fingerprint、mutation 与后续命令。不要把人工生成 hash、手工写 k8s Secret 或原生 `kubectl rollout` 沉淀为长期入口。 `hwlab nodes control-plane infra plan|status|apply --node D601 --lane v03` 是 D601 HWLAB v03 节点本地 CI/CD 与 git-mirror 前置控制面的 YAML 驱动入口,配置真相源是 `config/hwlab-node-control-plane.yaml`。`plan` 只读展示 YAML target 和将渲染的 control-plane 对象;`status` 只读观察 D601 Tekton、CI namespace、git-mirror、Argo、node-local registry 和 tools image readiness;`apply --dry-run` 只输出 manifest 摘要;`apply --confirm` 只收敛 D601 control-plane bootstrap 对象,不触发 HWLAB runtime rollout,不创建 PK01 DB,也不修改 Caddy/FRP。tools image 的 node-local registry 地址只能作为输出 artifact;输入 base image 必须由 YAML 声明为公开 registry 来源,缺少 output image 时应在 `status.next.blockers` 中体现,而不是把现有 node-local image 当成输入基础镜像。 diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index 3fcdb5a9..6bcdbf1d 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -120,6 +120,17 @@ export interface HwlabRuntimePublicExposureSpec { readonly apiProxy: HwlabRuntimePublicExposureFrpcProxySpec; } +export interface HwlabRuntimeBootstrapAdminSpec { + readonly username: string; + readonly displayName: string; + readonly passwordSourceRef: string; + readonly passwordSourceKey: string; + readonly passwordHashTransform: "hwlab-sha256"; + readonly secretName: string; + readonly secretKey: string; + readonly rolloutDeployment: string; +} + export interface HwlabRuntimeLaneSpec { readonly lane: HwlabRuntimeLane; readonly nodeId: string; @@ -156,6 +167,7 @@ export interface HwlabRuntimeLaneSpec { readonly publicApiUrl: string; readonly stepEnv: Record; readonly buildkit?: HwlabRuntimeBuildkitSpec; + readonly bootstrapAdmin?: HwlabRuntimeBootstrapAdminSpec; readonly externalPostgres?: HwlabRuntimeExternalPostgresSpec; readonly publicExposure: HwlabRuntimePublicExposureSpec | null; readonly observability: HwlabRuntimeObservabilitySpec; @@ -196,6 +208,7 @@ interface HwlabLaneConfig { readonly public: { readonly webUrl: string; readonly apiUrl: string }; readonly stepEnv: Record; readonly buildkit?: HwlabRuntimeBuildkitSpec; + readonly bootstrapAdmin?: HwlabRuntimeBootstrapAdminSpec; readonly externalPostgres?: HwlabRuntimeExternalPostgresSpec; readonly publicExposure: HwlabRuntimePublicExposureSpec | null; readonly observability: HwlabRuntimeObservabilitySpec; @@ -396,6 +409,7 @@ function laneConfig(id: HwlabRuntimeLane, raw: Record): HwlabLa }, stepEnv: optionalStringRecord(raw.stepEnv, `lanes.${id}.stepEnv`), buildkit: buildkitConfig(raw.buildkit, `lanes.${id}.buildkit`), + bootstrapAdmin: bootstrapAdminConfig(raw.bootstrapAdmin, `lanes.${id}.bootstrapAdmin`), externalPostgres: externalPostgresConfig(raw.externalPostgres, `lanes.${id}.externalPostgres`), publicExposure: publicExposureConfig(raw.publicExposure, `lanes.${id}.publicExposure`), observability: observabilityConfig(raw.observability, `lanes.${id}.observability`), @@ -414,6 +428,7 @@ function laneTargetConfig(id: HwlabRuntimeLane, nodeId: string, baseRaw: Record< public: mergeOptionalRecord(baseRaw.public, targetRaw.public), stepEnv: mergeOptionalRecord(baseRaw.stepEnv, targetRaw.stepEnv) ?? {}, buildkit: mergeOptionalRecord(baseRaw.buildkit, targetRaw.buildkit), + bootstrapAdmin: mergeOptionalRecord(baseRaw.bootstrapAdmin, targetRaw.bootstrapAdmin), externalPostgres: mergeOptionalRecord(baseRaw.externalPostgres, targetRaw.externalPostgres), publicExposure: mergeOptionalRecord(baseRaw.publicExposure, targetRaw.publicExposure), observability: mergeOptionalRecord(baseRaw.observability, targetRaw.observability), @@ -430,6 +445,36 @@ function buildkitConfig(value: unknown, path: string): HwlabRuntimeBuildkitSpec }; } +function sourceRefField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (!/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${path}.${key} has an unsupported format`); + return value; +} + +function secretKeyField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (!/^[A-Za-z0-9_.-]+$/u.test(value)) throw new Error(`${path}.${key} has an unsupported format`); + return value; +} + +function bootstrapAdminConfig(value: unknown, path: string): HwlabRuntimeBootstrapAdminSpec | undefined { + if (value === undefined) return undefined; + const raw = asRecord(value, path); + const transform = stringField(raw, "passwordHashTransform", path); + if (transform !== "hwlab-sha256") throw new Error(`${path}.passwordHashTransform must be hwlab-sha256`); + const rollout = asRecord(raw.rollout, `${path}.rollout`); + return { + username: stringField(raw, "username", path), + displayName: stringField(raw, "displayName", path), + passwordSourceRef: sourceRefField(raw, "passwordSourceRef", path), + passwordSourceKey: secretKeyField(raw, "passwordSourceKey", path), + passwordHashTransform: transform, + secretName: stringField(raw, "secretName", path), + secretKey: secretKeyField(raw, "secretKey", path), + rolloutDeployment: stringField(rollout, "deployment", `${path}.rollout`), + }; +} + function externalPostgresComponentConfig(value: unknown, path: string): HwlabRuntimeExternalPostgresComponentSpec { const raw = asRecord(value, path); return { @@ -603,6 +648,7 @@ function buildRuntimeLaneSpec(config: HwlabLaneConfig): HwlabRuntimeLaneSpec { publicApiUrl: config.public.apiUrl, stepEnv: config.stepEnv, ...(config.buildkit === undefined ? {} : { buildkit: config.buildkit }), + ...(config.bootstrapAdmin === undefined ? {} : { bootstrapAdmin: config.bootstrapAdmin }), ...(config.externalPostgres === undefined ? {} : { externalPostgres: config.externalPostgres }), publicExposure: config.publicExposure, observability: config.observability, diff --git a/scripts/src/hwlab-node.ts b/scripts/src/hwlab-node.ts index 8a41f166..c641a5bf 100644 --- a/scripts/src/hwlab-node.ts +++ b/scripts/src/hwlab-node.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { repoRoot, rootPath, type Config } from "./config"; @@ -32,6 +32,17 @@ interface NodeSecretOptions { timeoutSeconds: number; } +interface BootstrapAdminSecretMaterial { + ok: boolean; + sourceRef: string | null; + sourceKey: string | null; + sourcePath: string | null; + sourcePresent: boolean; + sourceFingerprint: string | null; + passwordHash: string | null; + error: string | null; +} + interface NodePublicExposureOptions { action: "public-exposure"; node: string; @@ -62,6 +73,11 @@ interface RuntimeSecretSpec { masterAdminApiKeySecret: string; bootstrapAdminSecret: string; bootstrapAdminPasswordHashKey: string; + bootstrapAdminUsername: string; + bootstrapAdminDisplayName: string; + bootstrapAdminPasswordSourceRef?: string; + bootstrapAdminPasswordSourceKey?: string; + bootstrapAdminPasswordHashTransform?: "hwlab-sha256"; bootstrapAdminSourceNamespace: string; bootstrapAdminSourceSecret: string; cloudApiDbSecret: string; @@ -304,6 +320,17 @@ function nodeRuntimeExpected(spec: HwlabRuntimeLaneSpec): Record= 0) paths.push(join(repoRoot.slice(0, index), ".state", "secrets", sourceRef)); + return [...new Set(paths)]; +} + +function displayRepoPath(path: string): string { + const normalizedRoot = repoRoot.replace(/\/+$/u, ""); + if (path === normalizedRoot) return "."; + if (path.startsWith(`${normalizedRoot}/`)) return path.slice(normalizedRoot.length + 1); + const marker = "/.worktree/"; + const index = normalizedRoot.indexOf(marker); + if (index >= 0) { + const mainRoot = normalizedRoot.slice(0, index); + if (path === mainRoot) return "."; + if (path.startsWith(`${mainRoot}/`)) return path.slice(mainRoot.length + 1); + } + return path; +} + +function hwlabPasswordHash(password: string): string { + const salt = randomBytes(16).toString("hex"); + return `sha256:${salt}:${createHash("sha256").update(`${salt}:${password}`).digest("hex")}`; +} + +function readBootstrapAdminSecretMaterial(spec: RuntimeSecretSpec): BootstrapAdminSecretMaterial { + const sourceRef = spec.bootstrapAdminPasswordSourceRef; + const sourceKey = spec.bootstrapAdminPasswordSourceKey; + if (sourceRef === undefined || sourceKey === undefined || spec.bootstrapAdminPasswordHashTransform === undefined) { + return { ok: false, sourceRef: sourceRef ?? null, sourceKey: sourceKey ?? null, sourcePath: null, sourcePresent: false, sourceFingerprint: null, passwordHash: null, error: "bootstrap-admin-yaml-source-missing" }; + } + const paths = secretSourcePaths(sourceRef); + const sourcePath = paths.find((candidate) => existsSync(candidate)) ?? paths[0] ?? join(repoRoot, ".state", "secrets", sourceRef); + if (!existsSync(sourcePath)) { + return { ok: false, sourceRef, sourceKey, sourcePath, sourcePresent: false, sourceFingerprint: null, passwordHash: null, error: "secret-source-missing" }; + } + const values = parseEnvFile(readFileSync(sourcePath, "utf8")); + const password = values[sourceKey]; + if (password === undefined || password.length === 0) { + return { ok: false, sourceRef, sourceKey, sourcePath, sourcePresent: true, sourceFingerprint: null, passwordHash: null, error: "secret-key-missing" }; + } + return { + ok: true, + sourceRef, + sourceKey, + sourcePath, + sourcePresent: true, + sourceFingerprint: `sha256:${createHash("sha256").update(password).digest("hex").slice(0, 16)}`, + passwordHash: hwlabPasswordHash(password), + error: null, + }; +} + function parseEnvFile(text: string): Record { const values: Record = {}; for (const rawLine of text.split(/\r?\n/u)) { @@ -3280,6 +3362,7 @@ function runtimeSecretSpec(input: { node: string; lane: string }): RuntimeSecret const namespace = `hwlab-${input.lane}`; const runtimeLaneSpec = isHwlabRuntimeLane(input.lane) ? hwlabRuntimeLaneSpecForNode(input.lane, input.node) : undefined; const externalPostgres = runtimeLaneSpec?.externalPostgres; + const bootstrapAdmin = runtimeLaneSpec?.bootstrapAdmin; const platformDb = externalPostgres !== undefined || /^v0*[3-9]\d*$/.test(input.lane); const platformPostgresService = externalPostgres?.serviceName ?? "g14-platform-postgres"; const legacyPostgresHost = `${namespace}-postgres.${namespace}.svc.cluster.local`; @@ -3307,8 +3390,13 @@ function runtimeSecretSpec(input: { node: string; lane: string }): RuntimeSecret openFgaDbUser, openFgaDbHost: platformDb ? platformPostgresHost : legacyPostgresHost, masterAdminApiKeySecret: `${namespace}-master-server-admin-api-key`, - bootstrapAdminSecret: `${namespace}-bootstrap-admin`, - bootstrapAdminPasswordHashKey: BOOTSTRAP_ADMIN_PASSWORD_HASH_KEY, + bootstrapAdminSecret: bootstrapAdmin?.secretName ?? `${namespace}-bootstrap-admin`, + bootstrapAdminPasswordHashKey: bootstrapAdmin?.secretKey ?? BOOTSTRAP_ADMIN_PASSWORD_HASH_KEY, + bootstrapAdminUsername: bootstrapAdmin?.username ?? "admin", + bootstrapAdminDisplayName: bootstrapAdmin?.displayName ?? `HWLAB ${input.lane} Admin`, + ...(bootstrapAdmin?.passwordSourceRef === undefined ? {} : { bootstrapAdminPasswordSourceRef: bootstrapAdmin.passwordSourceRef }), + ...(bootstrapAdmin?.passwordSourceKey === undefined ? {} : { bootstrapAdminPasswordSourceKey: bootstrapAdmin.passwordSourceKey }), + ...(bootstrapAdmin?.passwordHashTransform === undefined ? {} : { bootstrapAdminPasswordHashTransform: bootstrapAdmin.passwordHashTransform }), bootstrapAdminSourceNamespace: BOOTSTRAP_ADMIN_SOURCE_NAMESPACE, bootstrapAdminSourceSecret: BOOTSTRAP_ADMIN_SOURCE_SECRET, cloudApiDbSecret: externalPostgres?.cloudApi.secretName ?? `hwlab-cloud-api-${input.lane}-db`, @@ -3333,15 +3421,18 @@ function runNodeSecret(options: NodeSecretOptions): Record { if (spec.externalPostgres !== undefined && options.action === "ensure" && (options.preset === "cloud-api-db" || options.preset === "openfga")) { return runExternalPostgresSecretEnsure(options, spec); } + const bootstrapAdminMaterial = options.preset === "bootstrap-admin" ? readBootstrapAdminSecretMaterial(spec) : null; const input = options.preset === "master-server-admin-api-key" && options.action === "ensure" && !options.dryRun ? readMasterAdminApiKey().key + : options.preset === "bootstrap-admin" && options.action === "ensure" && !options.dryRun && bootstrapAdminMaterial?.ok === true + ? bootstrapAdminMaterial.passwordHash ?? "" : ""; const script = options.preset === "openfga" ? spec.platformDb ? platformDbSecretStatusScript(options, spec) : openFgaSecretScript(options, spec) : options.preset === "master-server-admin-api-key" ? masterAdminApiKeySecretScript(options, spec) : options.preset === "bootstrap-admin" - ? bootstrapAdminSecretScript(options, spec) + ? bootstrapAdminSecretScript(options, spec, bootstrapAdminMaterial) : options.preset === "cloud-api-db" ? spec.platformDb ? platformDbSecretStatusScript(options, spec) : cloudApiDbSecretScript(options, spec) : options.preset === "owned-postgres-cleanup" @@ -4823,7 +4914,146 @@ function masterAdminApiKeySecretScript(options: NodeSecretOptions, spec: Runtime ].join("\n"); } -function bootstrapAdminSecretScript(options: NodeSecretOptions, spec: RuntimeSecretSpec): string { +function bootstrapAdminSecretScript(options: NodeSecretOptions, spec: RuntimeSecretSpec, material: BootstrapAdminSecretMaterial | null): string { + const yamlSourceEnabled = spec.bootstrapAdminPasswordSourceRef !== undefined && spec.bootstrapAdminPasswordSourceKey !== undefined && spec.bootstrapAdminPasswordHashTransform !== undefined; + if (!yamlSourceEnabled) return legacyBootstrapAdminSecretScript(options, spec); + const materialOk = material?.ok === true; + return [ + "set +e", + `namespace=${shellQuote(spec.namespace)}`, + `name=${shellQuote(spec.bootstrapAdminSecret)}`, + `password_hash_key=${shellQuote(spec.bootstrapAdminPasswordHashKey)}`, + `username=${shellQuote(spec.bootstrapAdminUsername)}`, + `display_name=${shellQuote(spec.bootstrapAdminDisplayName)}`, + `source_ref=${shellQuote(spec.bootstrapAdminPasswordSourceRef ?? "")}`, + `source_key=${shellQuote(spec.bootstrapAdminPasswordSourceKey ?? "")}`, + `source_path=${shellQuote(material?.sourcePath === null || material?.sourcePath === undefined ? "" : displayRepoPath(material.sourcePath))}`, + `source_present=${shellQuote(material?.sourcePresent === true ? "yes" : "no")}`, + `source_fingerprint=${shellQuote(material?.sourceFingerprint ?? "")}`, + `source_error=${shellQuote(material?.error ?? "")}`, + `transform=${shellQuote(spec.bootstrapAdminPasswordHashTransform ?? "")}`, + `material_ok=${shellQuote(materialOk ? "true" : "false")}`, + `cloud_api_deployment=${shellQuote(spec.cloudApiDeployment)}`, + `action_request=${shellQuote(options.action)}`, + `dry_run=${shellQuote(options.dryRun ? "true" : "false")}`, + `field_manager=${shellQuote(spec.fieldManager)}`, + "preset=bootstrap-admin", + "secret_exists_flag() { kubectl -n \"$namespace\" get secret \"$name\" >/dev/null 2>&1 && printf yes || printf no; }", + "secret_b64_key() { kubectl -n \"$namespace\" get secret \"$name\" -o \"go-template={{ index .data \\\"$1\\\" }}\" 2>/dev/null || true; }", + "secret_annotation() { kubectl -n \"$namespace\" get secret \"$name\" -o \"go-template={{ with .metadata.annotations }}{{ index . \\\"$1\\\" }}{{ end }}\" 2>/dev/null || true; }", + "decoded_length() { if [ -n \"$1\" ]; then printf '%s' \"$1\" | base64 -d 2>/dev/null | wc -c | tr -d ' '; else printf '0'; fi; }", + "before_exists=$(secret_exists_flag)", + "before_hash_b64=$(secret_b64_key \"$password_hash_key\")", + "before_source_ref=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-source-ref)", + "before_source_key=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-source-key)", + "before_source_fingerprint=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-source-fingerprint)", + "before_username=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-username)", + "before_hash_present=$([ -n \"$before_hash_b64\" ] && printf yes || printf no)", + "before_hash_bytes=$(decoded_length \"$before_hash_b64\")", + "action=observed", + "mutation=false", + "apply_exit=", + "rollout_restart_exit=", + "rollout_status_exit=", + "if [ \"$action_request\" = ensure ]; then", + " needs_sync=false", + " [ \"$before_exists\" = yes ] && [ \"$before_hash_bytes\" -gt 0 ] || needs_sync=true", + " [ \"$before_source_ref\" = \"$source_ref\" ] && [ \"$before_source_key\" = \"$source_key\" ] && [ \"$before_source_fingerprint\" = \"$source_fingerprint\" ] && [ \"$before_username\" = \"$username\" ] || needs_sync=true", + " if [ \"$material_ok\" != true ]; then", + " action=${source_error:-secret-source-invalid}", + " apply_exit=44", + " elif [ \"$dry_run\" = true ]; then", + " if [ \"$needs_sync\" = true ]; then action=would-sync-from-yaml-source; else action=kept; fi", + " elif [ \"$needs_sync\" = false ]; then", + " action=kept", + " else", + " password_hash=$(cat)", + " case \"$password_hash\" in sha256:*:*) ;; *) action=password-hash-invalid; apply_exit=45 ;; esac", + " if [ -z \"$apply_exit\" ]; then", + " cat </tmp/hwlab-bootstrap-admin-rollout-restart.out 2>/tmp/hwlab-bootstrap-admin-rollout-restart.err", + " rollout_restart_exit=$?", + " if [ \"$rollout_restart_exit\" -eq 0 ]; then", + " kubectl -n \"$namespace\" rollout status \"deployment/$cloud_api_deployment\" --timeout=180s >/tmp/hwlab-bootstrap-admin-rollout-status.out 2>/tmp/hwlab-bootstrap-admin-rollout-status.err", + " rollout_status_exit=$?", + " fi", + " if [ -n \"$rollout_restart_exit\" ] && [ \"$rollout_restart_exit\" != 0 ]; then action=rollout-restart-failed", + " elif [ -n \"$rollout_status_exit\" ] && [ \"$rollout_status_exit\" != 0 ]; then action=rollout-status-failed", + " else action=synced-from-yaml-source; mutation=true; fi", + " else action=apply-failed; fi", + " password_hash=", + " fi", + "fi", + "after_exists=$(secret_exists_flag)", + "after_hash_b64=$(secret_b64_key \"$password_hash_key\")", + "after_source_ref=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-source-ref)", + "after_source_key=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-source-key)", + "after_source_fingerprint=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-source-fingerprint)", + "after_username=$(secret_annotation hwlab.pikastech.local/bootstrap-admin-username)", + "after_hash_present=$([ -n \"$after_hash_b64\" ] && printf yes || printf no)", + "after_hash_bytes=$(decoded_length \"$after_hash_b64\")", + "printf 'namespace\\t%s\\n' \"$namespace\"", + "printf 'secret\\t%s\\n' \"$name\"", + "printf 'key\\t%s\\n' \"$password_hash_key\"", + "printf 'preset\\t%s\\n' \"$preset\"", + "printf 'username\\t%s\\n' \"$username\"", + "printf 'displayName\\t%s\\n' \"$display_name\"", + "printf 'sourceRef\\t%s\\n' \"$source_ref\"", + "printf 'sourceKey\\t%s\\n' \"$source_key\"", + "printf 'sourcePath\\t%s\\n' \"$source_path\"", + "printf 'sourceExists\\t%s\\n' \"$source_present\"", + "printf 'sourceFingerprint\\t%s\\n' \"$source_fingerprint\"", + "printf 'passwordHashTransform\\t%s\\n' \"$transform\"", + "printf 'action\\t%s\\n' \"$action\"", + "printf 'dryRun\\t%s\\n' \"$dry_run\"", + "printf 'mutation\\t%s\\n' \"$mutation\"", + "printf 'beforeExists\\t%s\\n' \"$before_exists\"", + "printf 'beforePasswordHashPresent\\t%s\\n' \"$before_hash_present\"", + "printf 'beforePasswordHashBytes\\t%s\\n' \"$before_hash_bytes\"", + "printf 'beforeSourceRef\\t%s\\n' \"$before_source_ref\"", + "printf 'beforeSourceKey\\t%s\\n' \"$before_source_key\"", + "printf 'beforeSourceFingerprint\\t%s\\n' \"$before_source_fingerprint\"", + "printf 'beforeUsername\\t%s\\n' \"$before_username\"", + "printf 'afterExists\\t%s\\n' \"$after_exists\"", + "printf 'afterPasswordHashPresent\\t%s\\n' \"$after_hash_present\"", + "printf 'afterPasswordHashBytes\\t%s\\n' \"$after_hash_bytes\"", + "printf 'afterSourceRef\\t%s\\n' \"$after_source_ref\"", + "printf 'afterSourceKey\\t%s\\n' \"$after_source_key\"", + "printf 'afterSourceFingerprint\\t%s\\n' \"$after_source_fingerprint\"", + "printf 'afterUsername\\t%s\\n' \"$after_username\"", + "printf 'cloudApiDeployment\\t%s\\n' \"$cloud_api_deployment\"", + "printf 'applyExitCode\\t%s\\n' \"$apply_exit\"", + "printf 'rolloutRestartExitCode\\t%s\\n' \"$rollout_restart_exit\"", + "printf 'rolloutStatusExitCode\\t%s\\n' \"$rollout_status_exit\"", + "if [ -n \"$apply_exit\" ] && [ \"$apply_exit\" != 0 ]; then exit \"$apply_exit\"; fi", + "if [ -n \"$rollout_restart_exit\" ] && [ \"$rollout_restart_exit\" != 0 ]; then exit \"$rollout_restart_exit\"; fi", + "if [ -n \"$rollout_status_exit\" ] && [ \"$rollout_status_exit\" != 0 ]; then exit \"$rollout_status_exit\"; fi", + ].join("\n"); +} + +function legacyBootstrapAdminSecretScript(options: NodeSecretOptions, spec: RuntimeSecretSpec): string { return [ "set +e", `namespace=${shellQuote(spec.namespace)}`, @@ -5279,31 +5509,68 @@ function secretStatusFromText(text: string, commandOk: boolean, exitCode: number const beforeHashBytes = numericField(fields.beforePasswordHashBytes); const sourceHashBytes = numericField(fields.sourcePasswordHashBytes); const afterHashBytes = numericField(fields.afterPasswordHashBytes); - const healthy = fields.afterExists === "yes" && + const yamlSourceMode = typeof fields.sourceRef === "string" && fields.sourceRef.length > 0; + const targetHashReady = fields.afterExists === "yes" && fields.afterPasswordHashPresent === "yes" && typeof afterHashBytes === "number" && afterHashBytes > 0; + const yamlSourceReady = !yamlSourceMode || ( + fields.sourceExists === "yes" && + typeof fields.sourceFingerprint === "string" && + fields.sourceFingerprint.length > 0 && + fields.afterSourceRef === fields.sourceRef && + fields.afterSourceKey === fields.sourceKey && + fields.afterSourceFingerprint === fields.sourceFingerprint && + fields.afterUsername === fields.username + ); + const healthy = targetHashReady && yamlSourceReady; return { ok: commandOk && healthy, namespace: fields.namespace || spec.namespace, secret: fields.secret || spec.bootstrapAdminSecret, key: fields.key || spec.bootstrapAdminPasswordHashKey, preset: "bootstrap-admin", - source: { - namespace: fields.sourceNamespace || spec.bootstrapAdminSourceNamespace, - secret: fields.sourceSecret || spec.bootstrapAdminSourceSecret, - exists: fields.sourceExists === "yes", - passwordHash: { keyPresent: fields.sourcePasswordHashPresent === "yes", valueBytes: sourceHashBytes }, + account: { + username: fields.username || spec.bootstrapAdminUsername, + displayName: fields.displayName || spec.bootstrapAdminDisplayName, }, + source: yamlSourceMode + ? { + sourceRef: fields.sourceRef, + sourceKey: fields.sourceKey || null, + sourcePath: fields.sourcePath || null, + exists: fields.sourceExists === "yes", + fingerprint: fields.sourceFingerprint || null, + passwordHashTransform: fields.passwordHashTransform || spec.bootstrapAdminPasswordHashTransform || null, + valuesRedacted: true, + } + : { + namespace: fields.sourceNamespace || spec.bootstrapAdminSourceNamespace, + secret: fields.sourceSecret || spec.bootstrapAdminSourceSecret, + exists: fields.sourceExists === "yes", + passwordHash: { keyPresent: fields.sourcePasswordHashPresent === "yes", valueBytes: sourceHashBytes }, + }, action: fields.action || null, dryRun: fields.dryRun === "true", mutation: fields.mutation === "true", before: { exists: fields.beforeExists === "yes", passwordHash: { keyPresent: fields.beforePasswordHashPresent === "yes", valueBytes: beforeHashBytes }, + ...(yamlSourceMode ? { + sourceRef: fields.beforeSourceRef || null, + sourceKey: fields.beforeSourceKey || null, + sourceFingerprint: fields.beforeSourceFingerprint || null, + username: fields.beforeUsername || null, + } : {}), }, after: { exists: fields.afterExists === "yes", passwordHash: { keyPresent: fields.afterPasswordHashPresent === "yes", valueBytes: afterHashBytes }, + ...(yamlSourceMode ? { + sourceRef: fields.afterSourceRef || null, + sourceKey: fields.afterSourceKey || null, + sourceFingerprint: fields.afterSourceFingerprint || null, + username: fields.afterUsername || null, + } : {}), }, cloudApiDeployment: fields.cloudApiDeployment || spec.cloudApiDeployment, applyExitCode: numericField(fields.applyExitCode),