From 3fd24c17ac39928dabe76aed1eb71c89ea605721 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 26 Jun 2026 13:34:05 +0000 Subject: [PATCH] fix(web-probe): sync sentinel runtime secrets --- .../secrets.auth-session-switch.d601-v03.yaml | 20 ++- scripts/src/hwlab-node-web-sentinel-cicd.ts | 133 +++++++++++++++++- 2 files changed, 140 insertions(+), 13 deletions(-) diff --git a/config/hwlab-web-probe-sentinel/secrets.auth-session-switch.d601-v03.yaml b/config/hwlab-web-probe-sentinel/secrets.auth-session-switch.d601-v03.yaml index 55463618..d8b9aa39 100644 --- a/config/hwlab-web-probe-sentinel/secrets.auth-session-switch.d601-v03.yaml +++ b/config/hwlab-web-probe-sentinel/secrets.auth-session-switch.d601-v03.yaml @@ -11,14 +11,15 @@ sentinel: sourceRef: hwlab/d601-v03-bootstrap-admin.env sourceKey: HWLAB_BOOTSTRAP_ADMIN_PASSWORD - purpose: account-a - sourceRef: hwlab/web-probe-sentinel-auth-switch-account-a.env - sourceKey: ACCOUNT_A_JSON + sourceRef: hwlab/d601-v03-bootstrap-admin.env + sourceKey: HWLAB_BOOTSTRAP_ADMIN_PASSWORD + format: web-account-json + username: admin - purpose: account-b - sourceRef: hwlab/web-probe-sentinel-auth-switch-account-b.env - sourceKey: ACCOUNT_B_JSON - - purpose: prompt-set - sourceRef: hwlab/web-probe-sentinel-auth-switch.env - sourceKey: AUTH_SWITCH_UNUSED_PROMPTS_JSON + sourceRef: hwlab/d601-v03-preset-users.env + sourceKey: HUIGGAO_PASSWORD + format: web-account-json + username: huiggao - purpose: frp-token sourceRef: platform-infra/pk01-frp.env sourceKey: FRP_TOKEN @@ -35,11 +36,6 @@ sentinel: targetKey: account-a.json - sourcePurpose: account-b targetKey: account-b.json - - name: hwlab-web-probe-sentinel-auth-switch-prompt-set - namespace: hwlab-v03 - data: - - sourcePurpose: prompt-set - targetKey: prompts.json - name: hwlab-web-probe-sentinel-auth-switch-frpc namespace: hwlab-v03 data: diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts index eb6d0a30..e5431f15 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts @@ -99,6 +99,7 @@ interface SentinelCicdState { readonly runtime: Record; readonly cicd: Record; readonly publicExposure: Record; + readonly secrets: Record; readonly controlPlaneTarget: Record; readonly controlPlaneNode: Record; readonly sourceHead: SourceHead; @@ -301,6 +302,7 @@ function loadSentinelCicdState(spec: HwlabRuntimeLaneSpec, sentinelId: string | runtime, cicd, publicExposure, + secrets, controlPlaneTarget, controlPlaneNode, sourceHead, @@ -634,6 +636,7 @@ function runSentinelControlPlaneConfirmed(state: SentinelCicdState, options: Ext const flush = !applyOnly && record(publish).ok === true ? runChildCli(["hwlab", "nodes", "git-mirror", "flush", "--node", state.spec.nodeId, "--lane", state.spec.lane, "--confirm", "--wait"], remainingCicdWaitSeconds()) : null; + const runtimeSecretsApply = applySentinelRuntimeSecrets(state, remainingCicdWaitSeconds()); const publicExposureApply = applySentinelPublicExposure(state, remainingCicdWaitSeconds()); const argoApply = applySentinelArgoApplication(state, remainingCicdWaitSeconds()); const observed = waitForSentinelObservedStatus(state, remainingCicdWaitSeconds()); @@ -645,14 +648,17 @@ function runSentinelControlPlaneConfirmed(state: SentinelCicdState, options: Ext && (applyOnly || record(sourceMirrorSync).ok === true) && (applyOnly || record(publish).ok === true) && (applyOnly || record(flush).ok === true) + && record(runtimeSecretsApply).ok === true && record(publicExposureApply).ok === true && record(argoApply).ok === true && observedReady; const elapsedMs = Date.now() - startedAt; const blocker = ok ? null : { - code: record(sourceMirrorSync).ok === false ? "sentinel-source-mirror-sync-failed" : "sentinel-control-plane-not-ready", + code: record(sourceMirrorSync).ok === false ? "sentinel-source-mirror-sync-failed" : record(runtimeSecretsApply).ok === false ? "sentinel-runtime-secret-sync-failed" : "sentinel-control-plane-not-ready", reason: record(sourceMirrorSync).ok === false ? "source mirror sync did not complete; investigate git mirror/proxy before control-plane publish" + : record(runtimeSecretsApply).ok === false + ? "one or more YAML-declared runtime Secrets were not synced from sourceRef" : "one or more publish, publicExposure, Argo or runtime observation checks did not pass", }; const result = { @@ -691,6 +697,7 @@ function runSentinelControlPlaneConfirmed(state: SentinelCicdState, options: Ext sourceMirrorSync, publish, flush, + runtimeSecretsApply, publicExposureApply, argoApply, observed, @@ -2162,6 +2169,127 @@ function probeSentinelPublicDashboard(state: SentinelCicdState, timeoutSeconds: return { ok: result.exitCode === 0 && parsed?.ok === true, ...record(parsed), result: compactCommand(result), valuesRedacted: true }; } +function applySentinelRuntimeSecrets(state: SentinelCicdState, timeoutSeconds: number): Record { + const sourcesByPurpose = new Map>(); + for (const source of arrayAt(state.secrets, "sources").map(record)) { + const purpose = stringAtNullable(source, "purpose"); + if (purpose !== null) sourcesByPurpose.set(purpose, source); + } + const desired: Array<{ namespace: string; name: string; data: Array<{ key: string; value: string; sourcePurpose: string; sourceRef: string; sourceKey: string; fingerprint: string }> }> = []; + const missing: Record[] = []; + const skipped: Record[] = []; + for (const runtimeSecret of arrayAt(state.secrets, "runtimeSecrets").map(record)) { + const name = stringAt(runtimeSecret, "name"); + const namespace = stringAt(runtimeSecret, "namespace"); + const data: Array<{ key: string; value: string; sourcePurpose: string; sourceRef: string; sourceKey: string; fingerprint: string }> = []; + for (const item of arrayAt(runtimeSecret, "data").map(record)) { + const sourcePurpose = stringAt(item, "sourcePurpose"); + const targetKey = stringAt(item, "targetKey"); + if (sourcePurpose === "frp-token") { + skipped.push({ name, namespace, targetKey, sourcePurpose, reason: "managed-by-publicExposure-frpc", valuesRedacted: true }); + continue; + } + const source = sourcesByPurpose.get(sourcePurpose); + if (source === undefined) { + missing.push({ name, namespace, targetKey, sourcePurpose, reason: "source-purpose-missing", valuesRedacted: true }); + continue; + } + const sourceRef = stringAt(source, "sourceRef"); + const sourceKey = stringAt(source, "sourceKey"); + const material = readSentinelSecretSourceValue(source); + if (!material.ok) { + missing.push({ name, namespace, targetKey, sourcePurpose, sourceRef, sourceKey, reason: material.error, sourcePath: material.sourcePath, valuesRedacted: true }); + continue; + } + const value = stringAt(material, "value"); + data.push({ + key: targetKey, + value, + sourcePurpose, + sourceRef, + sourceKey, + fingerprint: `sha256:${createHash("sha256").update(value).digest("hex").slice(0, 16)}`, + }); + } + if (data.length > 0) desired.push({ namespace, name, data }); + } + if (missing.length > 0) return { ok: false, phase: "local-source", missing, skipped, valuesRedacted: true }; + if (desired.length === 0) return { ok: true, phase: "skipped-no-runtime-secrets", secretCount: 0, keyCount: 0, skippedKeyCount: skipped.length, skipped, valuesRedacted: true }; + const manifests = desired.map((secret) => ({ + apiVersion: "v1", + kind: "Secret", + metadata: { + name: secret.name, + namespace: secret.namespace, + labels: { + "app.kubernetes.io/managed-by": "unidesk", + "app.kubernetes.io/part-of": "hwlab-web-probe-sentinel", + "unidesk.ai/node": state.spec.nodeId, + "unidesk.ai/lane": state.spec.lane, + "unidesk.ai/web-probe-sentinel-id": state.sentinelId, + }, + }, + type: "Opaque", + data: Object.fromEntries(secret.data.map((item) => [item.key, Buffer.from(item.value, "utf8").toString("base64")])), + })); + const manifestYaml = `${manifests.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`; + const summary = desired.map((secret) => ({ + name: secret.name, + namespace: secret.namespace, + keys: secret.data.map((item) => item.key).sort(), + sources: secret.data.map((item) => ({ key: item.key, sourcePurpose: item.sourcePurpose, sourceRef: item.sourceRef, sourceKey: item.sourceKey, fingerprint: item.fingerprint, valuesRedacted: true })), + valuesRedacted: true, + })); + const summaryB64 = Buffer.from(JSON.stringify(summary), "utf8").toString("base64"); + const script = [ + "set +e", + `summary_b64=${shellQuote(summaryB64)}`, + "tmp=$(mktemp -d)", + "trap 'rm -rf \"$tmp\"' EXIT", + "manifest=\"$tmp/runtime-secrets.yaml\"", + "cat >\"$manifest\"", + "kubectl apply --server-side --force-conflicts --field-manager=unidesk-web-probe-sentinel-runtime-secrets -f \"$manifest\" >/tmp/web-probe-sentinel-runtime-secrets.out 2>/tmp/web-probe-sentinel-runtime-secrets.err", + "apply_rc=$?", + "python3 - \"$summary_b64\" \"$apply_rc\" <<'PY'", + "import base64, json, subprocess, sys", + "summary = json.loads(base64.b64decode(sys.argv[1]).decode('utf-8'))", + "apply_rc = int(sys.argv[2])", + "items = []", + "ok = apply_rc == 0", + "for secret in summary:", + " proc = subprocess.run(['kubectl', '-n', secret['namespace'], 'get', 'secret', secret['name'], '-o', 'json'], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)", + " data = json.loads(proc.stdout).get('data', {}) if proc.returncode == 0 and proc.stdout else {}", + " keys = {key: key in data for key in secret['keys']}", + " present = proc.returncode == 0 and all(keys.values())", + " ok = ok and present", + " items.append({'name': secret['name'], 'namespace': secret['namespace'], 'present': present, 'keys': keys, 'sources': secret['sources'], 'valuesRedacted': True})", + "print(json.dumps({'ok': ok, 'applyExitCode': apply_rc, 'secretCount': len(summary), 'keyCount': sum(len(item['keys']) for item in summary), 'items': items, 'valuesRedacted': True}, ensure_ascii=False))", + "PY", + ].join("\n"); + const result = runCommand(["trans", stringAt(state.controlPlaneNode, "kubeRoute"), "sh", "--", script], repoRoot, { input: manifestYaml, timeoutMs: Math.min(timeoutSeconds, 60) * 1000 }); + const parsed = parseJsonObject(result.stdout); + return { ok: result.exitCode === 0 && parsed?.ok === true, ...record(parsed), skippedKeyCount: skipped.length, skipped, result: compactCommand(result), valuesRedacted: true }; +} + +function readSentinelSecretSourceValue(source: Record): Record { + const sourceRef = stringAt(source, "sourceRef"); + const sourceKey = stringAt(source, "sourceKey"); + const paths = secretSourcePaths(sourceRef); + const sourcePath = paths.find((item) => existsSync(item)) ?? paths[0] ?? join(repoRoot, ".state", "secrets", sourceRef); + if (!existsSync(sourcePath)) return { ok: false, error: "secret-source-missing", sourceRef, sourceKey, sourcePath: displayPath(sourcePath), valuesRedacted: true }; + const values = parseEnvFile(readFileSync(sourcePath, "utf8")); + const value = values[sourceKey]; + if (value === undefined || value.length === 0) return { ok: false, error: "secret-source-key-missing", sourceRef, sourceKey, sourcePath: displayPath(sourcePath), valuesRedacted: true }; + const format = stringAtNullable(source, "format"); + if (format === null) return { ok: true, value, sourceRef, sourceKey, sourcePath: displayPath(sourcePath), valuesRedacted: true }; + if (format === "web-account-json") { + const username = stringAtNullable(source, "username"); + if (username === null) return { ok: false, error: "web-account-json-username-missing", sourceRef, sourceKey, sourcePath: displayPath(sourcePath), valuesRedacted: true }; + return { ok: true, value: JSON.stringify({ username, password: value }), sourceRef, sourceKey, format, sourcePath: displayPath(sourcePath), valuesRedacted: true }; + } + return { ok: false, error: "unsupported-secret-source-format", sourceRef, sourceKey, format, sourcePath: displayPath(sourcePath), valuesRedacted: true }; +} + function applySentinelPublicExposure(state: SentinelCicdState, timeoutSeconds: number): Record { const material = readSentinelFrpcMaterial(state); if (!material.ok) return { ok: false, hostname: stringAt(state.publicExposure, "hostname"), material, valuesRedacted: true }; @@ -3126,6 +3254,7 @@ function renderControlPlaneResult(result: Record): string { const sourceMirrorSync = record(result.sourceMirrorSync); const publish = record(result.publish); const flush = record(result.flush); + const runtimeSecretsApply = record(result.runtimeSecretsApply); const publicExposureApply = record(result.publicExposureApply); const publicExposureCaddy = record(publicExposureApply.caddy); const argoApply = record(result.argoApply); @@ -3163,6 +3292,8 @@ function renderControlPlaneResult(result: Record): string { "", Object.keys(flush).length === 0 ? "FLUSH\n-" : table(["OK", "EXIT", "TIMED_OUT", "PREVIEW"], [[flush.ok, record(flush.result).exitCode, record(flush.result).timedOut, record(flush.result).stdoutPreview]]), "", + Object.keys(runtimeSecretsApply).length === 0 ? "RUNTIME_SECRETS\n-" : table(["OK", "SECRETS", "KEYS", "SKIPPED"], [[runtimeSecretsApply.ok, runtimeSecretsApply.secretCount ?? "-", runtimeSecretsApply.keyCount ?? "-", runtimeSecretsApply.skippedKeyCount ?? "-"]]), + "", Object.keys(publicExposureApply).length === 0 ? "PUBLIC_EXPOSURE_APPLY\n-" : table(["OK", "SECRET", "CADDY", "HOST"], [[publicExposureApply.ok, record(publicExposureApply.secret).ok, record(publicExposureApply.caddy).ok, publicExposureApply.hostname]]), "", Object.keys(publicExposureCaddy).length === 0 || publicExposureCaddy.ok === true