diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 53a3d172..81cad6de 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -16,6 +16,8 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 DEV/PROD 滚动、P 当现有 CLI 对某个 CI/CD 操作缺字段、缺动作、缺状态或缺权限时,处理顺序是先补 CLI,再执行发布或治理动作。临时低层 route 写操作只允许用于一次性止血,并且必须随后把稳定能力补进 CLI 与本参考文档;不能把手工 `kubectl apply/delete/annotate`、原生 GitHub CLI、手写 REST 请求或 registry shell 脚本沉淀成长期流程。长时观察仍遵守 60 秒短查询和 submit-and-poll 语义,不用单个 `trans`/`tran` 等待完整 PipelineRun 或 Argo rollout 结束。 +`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`。 + ## Command Model - `help` 输出命令索引,适合作为交互式入口。 diff --git a/scripts/hwlab-g14-contract-test.ts b/scripts/hwlab-g14-contract-test.ts index 6c549866..abda262d 100644 --- a/scripts/hwlab-g14-contract-test.ts +++ b/scripts/hwlab-g14-contract-test.ts @@ -1,5 +1,5 @@ -import { activeV02PipelineRuns, g14ObservabilityQueryAssertion, gitMirrorFlushJobManifest, gitMirrorStatusSummary, gitMirrorSyncJobManifest, gitMirrorV02SyncRequirement, hwlabG14Help, hwlabG14MonitorStateFileName, parseGitMirrorStatusRefs, parseK8sCpuMillicores, parseK8sMemoryMiB, parsePipelineTaskRunMetrics, parseV02TriggerSnapshot, rolloutRecordBody, runtimeLanePipelineRunManifest, semanticChangelogBullets, summarizeV02CdStatus, v02CloseoutVerdict, v02CommitAlignment, v02ControlPlaneRefreshScriptHash, v02ControlPlaneRenderScript, v02ExistingPipelineRunReuseDecision, v02FalseGreenGuard, v02GitMirrorPreSyncWaitMs, v02LatestOnlyTargetValidation, v02PipelineServiceIds, v02PrAutomationCommentBody, v02ReusableGitMirrorPreSyncMarker, v02ReusableRefreshMarker, v02StatusHistoryPolicy, v02TaskRunPerformanceSummary } from "./src/hwlab-g14"; -import { hwlabNodeHelp } from "./src/hwlab-node"; +import { activeV02PipelineRuns, cleanupPipelineRunTargetCandidateFromTextForTest, g14ObservabilityQueryAssertion, gitMirrorFlushJobManifest, gitMirrorStatusSummary, gitMirrorSyncJobManifest, gitMirrorV02SyncRequirement, hwlabG14Help, hwlabG14MonitorStateFileName, parseGitMirrorStatusRefs, parseK8sCpuMillicores, parseK8sMemoryMiB, parsePipelineTaskRunMetrics, parseV02TriggerSnapshot, rolloutRecordBody, runtimeLanePipelineRunManifest, semanticChangelogBullets, summarizeV02CdStatus, v02CloseoutVerdict, v02CommitAlignment, v02ControlPlaneRefreshScriptHash, v02ControlPlaneRenderScript, v02ExistingPipelineRunReuseDecision, v02FalseGreenGuard, v02GitMirrorPreSyncWaitMs, v02LatestOnlyTargetValidation, v02PipelineServiceIds, v02PrAutomationCommentBody, v02ReusableGitMirrorPreSyncMarker, v02ReusableRefreshMarker, v02StatusHistoryPolicy, v02TaskRunPerformanceSummary } from "./src/hwlab-g14"; +import { hwlabNodeHelp, nodeSecretStatusFromTextForTest } from "./src/hwlab-node"; import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec } from "./src/hwlab-node-lanes"; import { runCommand } from "./src/command"; @@ -124,6 +124,50 @@ assertCondition( "v0.2 secret help must expose controlled OpenFGA and master-server admin API key SecretRef bootstrap paths", hwlabHelpUsage, ); +assertCondition( + hwlabHelpUsage.some((line) => line.includes("hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-code-agent-provider")) + && hwlabHelpUsage.some((line) => line.includes("hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-code-agent-provider --confirm")) + && hwlabNodeHelpJson.includes("hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-code-agent-provider") + && hwlabNodeHelpJson.includes("hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-code-agent-provider --confirm"), + "v0.3 node-scoped secret help must expose the controlled code-agent provider SecretRef bootstrap path", + { hwlabHelpUsage, hwlabNodeHelp: hwlabNodeHelp() }, +); +const codeAgentProviderStatus = nodeSecretStatusFromTextForTest([ + "namespace\thwlab-v03", + "secret\thwlab-v03-code-agent-provider", + "preset\tcode-agent-provider", + "sourceNamespace\thwlab-v02", + "sourceSecret\thwlab-v02-code-agent-provider", + "action\tcopied-from-source", + "dryRun\tfalse", + "mutation\ttrue", + "beforeExists\tno", + "beforeOpenaiPresent\tno", + "beforeOpenaiBytes\t0", + "beforeOpencodePresent\tno", + "beforeOpencodeBytes\t0", + "sourceExists\tyes", + "sourceOpenaiPresent\tyes", + "sourceOpenaiBytes\t48", + "sourceOpencodePresent\tyes", + "sourceOpencodeBytes\t64", + "afterExists\tyes", + "afterOpenaiPresent\tyes", + "afterOpenaiBytes\t48", + "afterOpencodePresent\tyes", + "afterOpencodeBytes\t64", + "applyExitCode\t0", +].join("\n"), true, 0, ""); +assertCondition( + record(codeAgentProviderStatus).ok === true + && record(codeAgentProviderStatus).valuesRedacted === true + && record(record(codeAgentProviderStatus).after).requiredAnyProviderKeyPresent === true + && JSON.stringify(codeAgentProviderStatus).includes("hwlab-v02-code-agent-provider") + && !JSON.stringify(codeAgentProviderStatus).includes("sk-") + && !JSON.stringify(codeAgentProviderStatus).includes("base64"), + "code-agent provider Secret status must be redacted while proving at least one runtime provider key is present", + codeAgentProviderStatus, +); assertCondition( hwlabHelpUsage.some((line) => line.includes("upstream-image status --name openfga --tag v1.17.0")) && hwlabHelpUsage.some((line) => line.includes("upstream-image ensure --name openfga --tag v1.17.0 --confirm")), @@ -481,10 +525,28 @@ assertCondition( "control-plane cleanup-runs must protect the latest PipelineRun per lane by default", ); assertCondition( - sourceText.includes("selectedReason: ageMinutes === null ? \"missing-creation-timestamp\" : \"below-min-age\"") - && sourceText.includes("target-pipelinerun-not-found-or-not-terminal") - && sourceText.indexOf("const target = terminalRuns.find((item) => item.name === targetPipelineRun)") < sourceText.indexOf("target-pipelinerun-not-found-or-not-terminal"), - "targeted cleanup-runs must report below-min-age terminal PipelineRuns instead of hiding them as not found", + record(cleanupPipelineRunTargetCandidateFromTextForTest({ + targetPipelineRun: "hwlab-v02-ci-poll-d6b01b261f1b", + text: "hwlab-v02-ci-poll-d6b01b261f1b\t2026-06-08T16:15:31Z\tTrue\tCompleted", + nowMs: Date.parse("2026-06-08T16:51:31Z"), + minAgeMinutes: 60, + })).selectedReason === "below-min-age" + && record(cleanupPipelineRunTargetCandidateFromTextForTest({ + targetPipelineRun: "hwlab-v02-ci-poll-d6b01b261f1b", + text: "hwlab-v02-ci-poll-d6b01b261f1b\t2026-06-08T16:15:31Z\tUnknown\tRunning", + nowMs: Date.parse("2026-06-08T18:00:00Z"), + })).selectedReason === "target-pipelinerun-not-terminal" + && record(cleanupPipelineRunTargetCandidateFromTextForTest({ + targetPipelineRun: "hwlab-v02-ci-poll-d6b01b261f1b", + text: "", + commandOk: false, + exitCode: 1, + stderr: "Error from server (NotFound): pipelineruns.tekton.dev \"hwlab-v02-ci-poll-d6b01b261f1b\" not found", + })).reason === "target-pipelinerun-not-found" + && sourceText.includes("targetPipelineRun !== undefined") + && sourceText.includes("cleanupPipelineRunFieldsJsonPath(false)") + && !sourceText.includes("target-pipelinerun-not-found-or-not-terminal"), + "targeted cleanup-runs must query the requested PipelineRun directly and distinguish below-min-age, not-terminal, and not-found states", ); assertCondition( hwlabHelpUsage.some((line) => line.includes("hwlab nodes control-plane cleanup-runs --node G14 --lane v03 --pipeline-run")) diff --git a/scripts/src/hwlab-g14.ts b/scripts/src/hwlab-g14.ts index a1320638..7f9f4737 100644 --- a/scripts/src/hwlab-g14.ts +++ b/scripts/src/hwlab-g14.ts @@ -2568,6 +2568,14 @@ function listV02PipelineRunsCompactFromText(text: string, commandOk: boolean, co return parsePipelineRunRows(text, limit, nowMs); } +interface CleanupPipelineRunRow { + name: string; + createdAt: string; + ageMinutes: number | null; + status: string | null; + reason: string | null; +} + function pipelinePrefixesForLane(lane: G14ControlPlaneOptions["lane"]): string[] { if (isHwlabRuntimeLane(lane)) return [`${hwlabRuntimeLaneSpec(lane).pipelineRunPrefix}-`]; if (lane === "g14") return ["hwlab-g14-ci-poll-"]; @@ -2586,10 +2594,132 @@ function tailText(value: unknown, maxBytes = 2000): string { return text.length <= maxBytes ? text : text.slice(-maxBytes); } +function cleanupPipelineRunFieldsJsonPath(range: boolean): string { + const body = [ + "{.metadata.name}", + "{\"\\t\"}", + "{.metadata.creationTimestamp}", + "{\"\\t\"}", + "{.status.conditions[0].status}", + "{\"\\t\"}", + "{.status.conditions[0].reason}", + "{\"\\n\"}", + ].join(""); + return range ? `jsonpath={range .items[*]}${body}{end}` : `jsonpath=${body}`; +} + +function parseCleanupPipelineRunLine(line: string, nowMs: number): CleanupPipelineRunRow | null { + const [name = "", createdAt = "", status = "", reason = ""] = line.trim().split("\t"); + if (name.length === 0) return null; + const createdMs = Date.parse(createdAt); + const ageMinutes = Number.isFinite(createdMs) ? Math.floor((nowMs - createdMs) / 60000) : null; + return { name, createdAt, ageMinutes, status: status || null, reason: reason || null }; +} + +function cleanupPipelineRunTargetCandidate(input: { + targetPipelineRun: string; + text: string; + commandOk: boolean; + exitCode: number | null; + stderr: string; + minAgeMinutes: number; + nowMs: number; +}): Record { + if (!input.commandOk) { + const queryError = tailText(input.stderr); + const notFound = /notfound|not found/i.test(queryError); + return { + name: input.targetPipelineRun, + createdAt: null, + ageMinutes: null, + status: null, + reason: notFound ? "target-pipelinerun-not-found" : "target-pipelinerun-query-failed", + selected: false, + queryExitCode: input.exitCode, + ...(queryError.length > 0 ? { queryError } : {}), + }; + } + const target = parseCleanupPipelineRunLine(input.text, input.nowMs); + if (target === null || target.name !== input.targetPipelineRun) { + return { + name: input.targetPipelineRun, + createdAt: null, + ageMinutes: null, + status: null, + reason: "target-pipelinerun-query-empty", + selected: false, + queryOutputPreview: tailText(input.text), + }; + } + if (target.status !== "True" && target.status !== "False") { + return { + ...target, + selected: false, + selectedReason: "target-pipelinerun-not-terminal", + }; + } + const ageMinutes = typeof target.ageMinutes === "number" ? target.ageMinutes : null; + const belowMinAge = ageMinutes === null || ageMinutes < input.minAgeMinutes; + return { + ...target, + selected: !belowMinAge, + ...(belowMinAge + ? { selectedReason: ageMinutes === null ? "missing-creation-timestamp" : "below-min-age" } + : {}), + }; +} + +export function cleanupPipelineRunTargetCandidateFromTextForTest(input: { + targetPipelineRun: string; + text: string; + commandOk?: boolean; + exitCode?: number | null; + stderr?: string; + minAgeMinutes?: number; + nowMs?: number; +}): Record { + return cleanupPipelineRunTargetCandidate({ + targetPipelineRun: input.targetPipelineRun, + text: input.text, + commandOk: input.commandOk ?? true, + exitCode: input.exitCode ?? 0, + stderr: input.stderr ?? "", + minAgeMinutes: input.minAgeMinutes ?? 60, + nowMs: input.nowMs ?? Date.now(), + }); +} + function listCleanupPipelineRuns(options: G14ControlPlaneOptions): Record[] { if (options.lane !== "g14" && options.lane !== "all" && !isHwlabRuntimeLane(options.lane)) { throw new Error("control-plane cleanup-runs requires --lane v02|v03|g14|all"); } + const targetPipelineRun = options.pipelineRun ?? (options.sourceCommit === undefined + ? undefined + : isHwlabRuntimeLane(options.lane) + ? runtimeLanePipelineRunName(hwlabRuntimeLaneSpec(options.lane), options.sourceCommit) + : v02PipelineRunName(options.sourceCommit)); + const now = Date.now(); + if (targetPipelineRun !== undefined) { + const targetResult = g14K3s([ + "kubectl", + "get", + "pipelinerun", + "-n", + CI_NAMESPACE, + targetPipelineRun, + "-o", + cleanupPipelineRunFieldsJsonPath(false), + ], 60_000); + return [cleanupPipelineRunTargetCandidate({ + targetPipelineRun, + text: statusText(targetResult), + commandOk: isCommandSuccess(targetResult), + exitCode: targetResult.exitCode, + stderr: commandErrorSummary(targetResult), + minAgeMinutes: options.minAgeMinutes, + nowMs: now, + })]; + } const result = g14K3s([ "kubectl", "get", @@ -2597,28 +2727,18 @@ function listCleanupPipelineRuns(options: G14ControlPlaneOptions): Record line.trim()) .filter(Boolean) - .map((line) => { - const [name = "", createdAt = "", status = "", reason = ""] = line.split("\t"); - const createdMs = Date.parse(createdAt); - const ageMinutes = Number.isFinite(createdMs) ? Math.floor((now - createdMs) / 60000) : null; - return { name, createdAt, ageMinutes, status: status || null, reason: reason || null }; - }) + .map((line) => parseCleanupPipelineRunLine(line, now)) + .filter((item): item is CleanupPipelineRunRow => item !== null) .filter((item) => item.name.length > 0 && prefixes.some((prefix) => item.name.startsWith(prefix))) .filter((item) => item.status === "True" || item.status === "False") .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))); @@ -2629,28 +2749,6 @@ function listCleanupPipelineRuns(options: G14ControlPlaneOptions): Record String(b.createdAt).localeCompare(String(a.createdAt)))[0]; if (latest !== undefined) protectedLatestByPrefix.set(prefix, latest.name); } - if (targetPipelineRun !== undefined) { - const target = terminalRuns.find((item) => item.name === targetPipelineRun); - if (target === undefined) { - return [{ - name: targetPipelineRun, - createdAt: null, - ageMinutes: null, - status: null, - reason: "target-pipelinerun-not-found-or-not-terminal", - selected: false, - }]; - } - const ageMinutes = typeof target.ageMinutes === "number" ? target.ageMinutes : null; - const belowMinAge = ageMinutes === null || ageMinutes < options.minAgeMinutes; - return [{ - ...target, - selected: !belowMinAge, - ...(belowMinAge - ? { selectedReason: ageMinutes === null ? "missing-creation-timestamp" : "below-min-age" } - : {}), - }]; - } const candidates = terminalRuns .filter((item) => typeof item.ageMinutes === "number" && item.ageMinutes >= options.minAgeMinutes) .map((item) => { @@ -8808,6 +8906,8 @@ export function hwlabG14Help(): Record { "bun scripts/cli.ts hwlab g14 secret ensure --lane v02 --name hwlab-v02-master-server-admin-api-key --confirm", "bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-master-server-admin-api-key", "bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-master-server-admin-api-key --confirm", + "bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-code-agent-provider", + "bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-code-agent-provider --confirm", "bun scripts/cli.ts hwlab g14 secret delete --lane v02 --name --dry-run", "bun scripts/cli.ts hwlab g14 secret delete --lane v02 --name --confirm", "bun scripts/cli.ts hwlab g14 git-mirror status", diff --git a/scripts/src/hwlab-node.ts b/scripts/src/hwlab-node.ts index 7119292d..461b1b7e 100644 --- a/scripts/src/hwlab-node.ts +++ b/scripts/src/hwlab-node.ts @@ -6,7 +6,7 @@ import { runHwlabG14Command } from "./hwlab-g14"; import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane } from "./hwlab-node-lanes"; type SecretAction = "status" | "ensure"; -type SecretPreset = "openfga" | "master-server-admin-api-key"; +type SecretPreset = "openfga" | "master-server-admin-api-key" | "code-agent-provider"; type DelegatedNodeDomain = "control-plane" | "git-mirror"; interface NodeSecretOptions { @@ -33,6 +33,9 @@ interface RuntimeSecretSpec { openFgaDbUser: string; openFgaDbHost: string; masterAdminApiKeySecret: string; + codeAgentProviderSecret: string; + codeAgentProviderSourceNamespace: string; + codeAgentProviderSourceSecret: string; fieldManager: string; } @@ -41,6 +44,10 @@ const MASTER_ADMIN_API_KEY_KEY = "api-key"; const OPENFGA_AUTHN_KEY = "authn-preshared-key"; const OPENFGA_DATASTORE_URI_KEY = "datastore-uri"; const OPENFGA_POSTGRES_PASSWORD_KEY = "postgres-password"; +const CODE_AGENT_PROVIDER_OPENAI_KEY = "openai-api-key"; +const CODE_AGENT_PROVIDER_OPENCODE_KEY = "opencode-api-key"; +const CODE_AGENT_PROVIDER_SOURCE_NAMESPACE = "hwlab-v02"; +const CODE_AGENT_PROVIDER_SOURCE_SECRET = "hwlab-v02-code-agent-provider"; export async function runHwlabNodeCommand(_config: Config, args: string[]): Promise> { if (args.length === 0 || args.includes("--help") || args.includes("-h")) return hwlabNodeHelp(); @@ -70,6 +77,8 @@ export function hwlabNodeHelp(): Record { "bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-openfga", "bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-openfga --confirm", "bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-master-server-admin-api-key --confirm", + "bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-code-agent-provider", + "bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-code-agent-provider --confirm", ], }; } @@ -186,7 +195,7 @@ function rewriteDelegatedNodeString(value: string, scoped: ReturnType { const input = options.preset === "master-server-admin-api-key" && options.action === "ensure" && !options.dryRun ? readMasterAdminApiKey().key : ""; - const result = runTransScript(options.node, options.preset === "openfga" ? openFgaSecretScript(options, spec) : masterAdminApiKeySecretScript(options, spec), input, options.timeoutSeconds); + const script = options.preset === "openfga" + ? openFgaSecretScript(options, spec) + : options.preset === "master-server-admin-api-key" + ? masterAdminApiKeySecretScript(options, spec) + : codeAgentProviderSecretScript(options, spec); + const result = runTransScript(options.node, script, input, options.timeoutSeconds); const status = secretStatusFromText(statusText(result), result.exitCode === 0, result.exitCode, result.stderr, spec); const dryRunOk = options.action === "ensure" && options.dryRun && result.exitCode === 0; const ok = dryRunOk ? true : status.ok === true; @@ -492,6 +525,107 @@ function masterAdminApiKeySecretScript(options: NodeSecretOptions, spec: Runtime ].join("\n"); } +function codeAgentProviderSecretScript(options: NodeSecretOptions, spec: RuntimeSecretSpec): string { + return [ + "set +e", + `namespace=${shellQuote(spec.namespace)}`, + `name=${shellQuote(spec.codeAgentProviderSecret)}`, + `source_namespace=${shellQuote(spec.codeAgentProviderSourceNamespace)}`, + `source_name=${shellQuote(spec.codeAgentProviderSourceSecret)}`, + `openai_key=${shellQuote(CODE_AGENT_PROVIDER_OPENAI_KEY)}`, + `opencode_key=${shellQuote(CODE_AGENT_PROVIDER_OPENCODE_KEY)}`, + `selected_key=${shellQuote(options.key ?? "")}`, + `action_request=${shellQuote(options.action)}`, + `dry_run=${shellQuote(options.dryRun ? "true" : "false")}`, + `field_manager=${shellQuote(spec.fieldManager)}`, + "preset=code-agent-provider", + "secret_exists_flag() { kubectl -n \"$1\" get secret \"$2\" >/dev/null 2>&1 && printf yes || printf no; }", + "secret_b64_key() { kubectl -n \"$1\" get secret \"$2\" -o \"go-template={{ index .data \\\"$3\\\" }}\" 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 \"$namespace\" \"$name\")", + "before_openai_b64=$(secret_b64_key \"$namespace\" \"$name\" \"$openai_key\")", + "before_opencode_b64=$(secret_b64_key \"$namespace\" \"$name\" \"$opencode_key\")", + "source_exists=$(secret_exists_flag \"$source_namespace\" \"$source_name\")", + "source_openai_b64=$(secret_b64_key \"$source_namespace\" \"$source_name\" \"$openai_key\")", + "source_opencode_b64=$(secret_b64_key \"$source_namespace\" \"$source_name\" \"$opencode_key\")", + "before_openai_present=$([ -n \"$before_openai_b64\" ] && printf yes || printf no)", + "before_opencode_present=$([ -n \"$before_opencode_b64\" ] && printf yes || printf no)", + "source_openai_present=$([ -n \"$source_openai_b64\" ] && printf yes || printf no)", + "source_opencode_present=$([ -n \"$source_opencode_b64\" ] && printf yes || printf no)", + "before_openai_bytes=$(decoded_length \"$before_openai_b64\")", + "before_opencode_bytes=$(decoded_length \"$before_opencode_b64\")", + "source_openai_bytes=$(decoded_length \"$source_openai_b64\")", + "source_opencode_bytes=$(decoded_length \"$source_opencode_b64\")", + "action=observed", + "mutation=false", + "apply_exit=", + "if [ \"$action_request\" = ensure ]; then", + " missing_target=false", + " [ \"$before_exists\" = yes ] && { [ \"$before_openai_bytes\" -gt 0 ] || [ \"$before_opencode_bytes\" -gt 0 ]; } || missing_target=true", + " missing_source=false", + " [ \"$source_exists\" = yes ] && { [ \"$source_openai_bytes\" -gt 0 ] || [ \"$source_opencode_bytes\" -gt 0 ]; } || missing_source=true", + " if [ \"$missing_source\" = true ]; then", + " action=source-missing-provider-key", + " apply_exit=44", + " elif [ \"$dry_run\" = true ]; then", + " if [ \"$missing_target\" = true ]; then action=would-copy-from-source; else action=kept; fi", + " elif [ \"$missing_target\" = false ]; then", + " action=kept", + " else", + " cat < { const fields = keyValueLinesFromText(text); if (fields.preset === "master-server-admin-api-key") { @@ -513,6 +647,50 @@ function secretStatusFromText(text: string, commandOk: boolean, exitCode: number summary: healthy ? `${fields.secret || spec.masterAdminApiKeySecret}/${MASTER_ADMIN_API_KEY_KEY} exists` : `${fields.secret || spec.masterAdminApiKeySecret}/${MASTER_ADMIN_API_KEY_KEY} missing`, }; } + if (fields.preset === "code-agent-provider") { + const beforeOpenaiBytes = numericField(fields.beforeOpenaiBytes); + const beforeOpencodeBytes = numericField(fields.beforeOpencodeBytes); + const sourceOpenaiBytes = numericField(fields.sourceOpenaiBytes); + const sourceOpencodeBytes = numericField(fields.sourceOpencodeBytes); + const afterOpenaiBytes = numericField(fields.afterOpenaiBytes); + const afterOpencodeBytes = numericField(fields.afterOpencodeBytes); + const openaiReady = fields.afterOpenaiPresent === "yes" && typeof afterOpenaiBytes === "number" && afterOpenaiBytes > 0; + const opencodeReady = fields.afterOpencodePresent === "yes" && typeof afterOpencodeBytes === "number" && afterOpencodeBytes > 0; + const healthy = fields.afterExists === "yes" && (openaiReady || opencodeReady); + return { + ok: commandOk && healthy, + namespace: fields.namespace || spec.namespace, + secret: fields.secret || spec.codeAgentProviderSecret, + preset: "code-agent-provider", + source: { + namespace: fields.sourceNamespace || spec.codeAgentProviderSourceNamespace, + secret: fields.sourceSecret || spec.codeAgentProviderSourceSecret, + exists: fields.sourceExists === "yes", + openaiApiKey: { keyPresent: fields.sourceOpenaiPresent === "yes", valueBytes: sourceOpenaiBytes }, + opencodeApiKey: { keyPresent: fields.sourceOpencodePresent === "yes", valueBytes: sourceOpencodeBytes }, + }, + selectedKey: fields.selectedKey || null, + action: fields.action || null, + dryRun: fields.dryRun === "true", + mutation: fields.mutation === "true", + before: { + exists: fields.beforeExists === "yes", + openaiApiKey: { keyPresent: fields.beforeOpenaiPresent === "yes", valueBytes: beforeOpenaiBytes }, + opencodeApiKey: { keyPresent: fields.beforeOpencodePresent === "yes", valueBytes: beforeOpencodeBytes }, + }, + after: { + exists: fields.afterExists === "yes", + openaiApiKey: { keyPresent: fields.afterOpenaiPresent === "yes", valueBytes: afterOpenaiBytes }, + opencodeApiKey: { keyPresent: fields.afterOpencodePresent === "yes", valueBytes: afterOpencodeBytes }, + requiredAnyProviderKeyPresent: openaiReady || opencodeReady, + }, + applyExitCode: numericField(fields.applyExitCode), + exitCode, + stderr: commandOk ? "" : stderr.trim().slice(0, 2000), + valuesRedacted: true, + summary: healthy ? `${fields.secret || spec.codeAgentProviderSecret} has a usable provider key` : `${fields.secret || spec.codeAgentProviderSecret} missing provider keys`, + }; + } const afterAuthnBytes = numericField(fields.afterAuthnBytes); const afterUriBytes = numericField(fields.afterDatastoreUriBytes); const afterPasswordBytes = numericField(fields.afterPostgresPasswordBytes); @@ -553,6 +731,10 @@ function secretStatusFromText(text: string, commandOk: boolean, exitCode: number }; } +export function nodeSecretStatusFromTextForTest(text: string, commandOk: boolean, exitCode: number | null, stderr: string, node = "G14", lane = "v03"): Record { + return secretStatusFromText(text, commandOk, exitCode, stderr, runtimeSecretSpec({ node, lane })); +} + function readMasterAdminApiKey(): { key: string; source: string } { if (!existsSync(MASTER_ADMIN_API_KEY_ENV)) throw new Error(`HWLAB_API_KEY source missing: ${MASTER_ADMIN_API_KEY_ENV}`); const content = readFileSync(MASTER_ADMIN_API_KEY_ENV, "utf8");