diff --git a/deploy/README.md b/deploy/README.md index 9dcb23a0..dea6e725 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -31,7 +31,10 @@ for future compatibility, but PROD deployment is not part of MVP acceptance. `hwlab-code-agent-provider/openai-api-key` and `HWLAB_CODE_AGENT_OPENAI_BASE_URL` through the DEV egress/proxy path. Source checks verify the Secret ref and base-url contract only; they do not prove - provider connectivity and must not print API key material. + provider connectivity and must not print API key material. Controlled apply + reports also check that the live `hwlab-cloud-api` Deployment preserves the + same env names and `secretKeyRef` name/key metadata after apply without + reading Kubernetes Secret data. - `hwlab-cloud-api` also declares `HWLAB_CLOUD_RUNTIME_ADAPTER=postgres` and `HWLAB_CLOUD_RUNTIME_DURABLE=true` so DEV can use the Postgres-backed runtime adapter once the schema is applied. `/health/live` must still remain degraded @@ -66,7 +69,10 @@ The desired-state plan reads only `deploy/deploy.json`, `deploy/artifact-catalog.dev.json`, `deploy/k8s/base/workloads.yaml`, and the optional artifact report snapshot. It checks that commit IDs, service image tags, workload images, and present env mirrors such as `HWLAB_COMMIT_ID`, -`HWLAB_IMAGE`, `HWLAB_IMAGE_TAG`, and `HWLAB_SKILLS_COMMIT_ID` converge. With +`HWLAB_IMAGE`, `HWLAB_IMAGE_TAG`, and `HWLAB_SKILLS_COMMIT_ID` converge. It +also blocks if `hwlab-cloud-api` loses the Code Agent provider Secret ref +`hwlab-code-agent-provider/openai-api-key`, the `OPENAI_API_KEY` env name, or +the DEV egress proxy base-url env. With `--target-ref` or `--target-tag`, a uniform older desired-state is reported as a read-only promotion plan. Use `--promotion-commit ` when reviewing or automating a concrete promotion; then `--check` also fails if any authoritative diff --git a/docs/dev-deploy-apply.md b/docs/dev-deploy-apply.md index b9836c8b..816589de 100644 --- a/docs/dev-deploy-apply.md +++ b/docs/dev-deploy-apply.md @@ -15,6 +15,10 @@ not be used for PROD. any apply review. - Validate artifact identity, image commit tags, Kubernetes workload/service shape, DEV health, and cloud-api DB connectivity. +- Validate that `hwlab-cloud-api` desired state declares the Code Agent + provider env contract: `OPENAI_API_KEY` from + `hwlab-code-agent-provider/openai-api-key` and + `HWLAB_CODE_AGENT_OPENAI_BASE_URL` through the DEV egress/proxy path. - Emit an operator-readable plan in `devDeployApply`: plan/apply boundary, expected artifact commit, target namespace, workloads, services, remaining blockers, minimal manual commands, and rollback hints. @@ -22,7 +26,8 @@ not be used for PROD. template Jobs whose image changed but whose Kubernetes `spec.template` is immutable. - Stop before mutation when artifact publish evidence, registry digests, - `kubectl`, DEV health, or cloud-api DB connectivity are missing. + `kubectl`, DEV health, cloud-api DB connectivity, or Code Agent provider + env preservation are missing. ## Commands @@ -115,6 +120,11 @@ operator fields: reference, digest, live image, and Kubernetes rollout revision when read-only Deployment access is available. If access is missing, this field records the read/rollout commands and the blocker instead of claiming a live rollout. +- `devDeployApply.codeAgentProvider`: no-secret provider validation gate. It + records desired-state env/ref readiness plus live `hwlab-cloud-api` + Deployment env preservation before apply and, in apply mode, after apply. + This gate reads Deployment env metadata only; it must not read Kubernetes + Secret data or attempt a provider call. - `devDeployApply.manualCommands`: dry-run/report commands before human approval; the apply command is present only when the plan is ready. - `devDeployApply.rollbackHint`: pre-apply capture commands, deployment diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index f89807bf..b3b5f8d4 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -20,12 +20,19 @@ Secret 或 token。 当前 DEV 部署合同中 `hwlab-cloud-api` 的 Code Agent provider 使用 OpenAI provider,运行时通过 `OPENAI_API_KEY` 读取 provider 凭证。该环境变量应由授权路径把 -`hwlab-code-agent-provider/openai-api-key` 注入到 DEV runtime。 +`hwlab-code-agent-provider/openai-api-key` 注入到 DEV runtime。DEV Pod 的 OpenAI +Responses 请求必须使用 `HWLAB_CODE_AGENT_OPENAI_BASE_URL` 指向受控 DEV egress/proxy +路径 `http://172.26.26.227:17680/v1/responses`,不能直接指向 public +`api.openai.com`。 Runner 不得尝试修补、读取、回显或替换该 Secret。若 DEV runtime 缺少该授权凭证注入, `provider_unavailable` 且 `error.missingEnv` 包含 `OPENAI_API_KEY` 必须判为 `BLOCKED/credential`。 +部署前和部署后的自动化只允许证明 env 名称、`secretKeyRef` 的 Secret 名和 key 名、以及 +DEV egress/base-url 合同是否声明和保留;不得读取 Secret data,也不得把 Secret 值写入 +report、issue、PR 或截图。 + ## 判定标准 | 观测结果 | readiness | diff --git a/internal/cloud/code-agent-contract.mjs b/internal/cloud/code-agent-contract.mjs index c8089d02..8250cd55 100644 --- a/internal/cloud/code-agent-contract.mjs +++ b/internal/cloud/code-agent-contract.mjs @@ -37,6 +37,172 @@ export function codeAgentSecretRefPlaceholder() { return `secretRef:${ref.secretName}/${ref.secretKey}`; } +export function inspectCodeAgentProviderManifestRefs({ deployEnv = {}, workloadEnv = {} } = {}) { + const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT; + const secretRef = contract.secretRefs[0]; + const expectedSecretRef = codeAgentSecretRefPlaceholder(); + const requiredRuntimeEnv = codeAgentRequiredRuntimeEnv(); + const expectedValues = { + HWLAB_CODE_AGENT_PROVIDER: contract.provider, + HWLAB_CODE_AGENT_MODEL: contract.model, + [contract.egress.env]: contract.egress.defaultBaseUrl, + [secretRef.env]: expectedSecretRef + }; + const workloadInspection = inspectCodeAgentProviderWorkloadEnv(workloadEnv); + const missingDeployEnv = requiredRuntimeEnv.filter((name) => !Object.hasOwn(deployEnv ?? {}, name)); + const deployMismatches = requiredRuntimeEnv + .filter((name) => Object.hasOwn(deployEnv ?? {}, name) && !Object.is(deployEnv[name], expectedValues[name])) + .map((name) => ({ + name, + expected: name === secretRef.env ? expectedSecretRef : expectedValues[name], + redacted: name === secretRef.env || name === contract.egress.env + })); + const missingSecretRefs = []; + if (deployEnv?.[secretRef.env] !== expectedSecretRef || workloadInspection.secretRef.present !== true) { + missingSecretRefs.push(`${secretRef.secretName}/${secretRef.secretKey}`); + } + const missingEgressContract = []; + if (deployEnv?.[contract.egress.env] !== contract.egress.defaultBaseUrl || workloadInspection.egress.matchesDevProxy !== true) { + missingEgressContract.push(`${contract.egress.env} must use ${contract.egress.target}`); + } + if ( + isForbiddenDirectOpenAiBaseUrl(deployEnv?.[contract.egress.env]) || + workloadInspection.egress.directPublicOpenAi === true + ) { + missingEgressContract.push(`${contract.egress.env} must not point directly at public api.openai.com`); + } + + const ready = + missingDeployEnv.length === 0 && + deployMismatches.length === 0 && + workloadInspection.ready === true && + missingSecretRefs.length === 0 && + missingEgressContract.length === 0; + + return { + contractVersion: contract.contractVersion, + environment: contract.environment, + provider: contract.runtimeProvider, + model: contract.model, + status: ready ? "pass" : "blocked", + ready, + requiredEnv: requiredRuntimeEnv.map((name) => ({ + name, + deployPresent: Object.hasOwn(deployEnv ?? {}, name), + workloadPresent: workloadInspection.requiredEnv.find((entry) => entry.name === name)?.present === true, + redacted: name === secretRef.env || name === contract.egress.env, + source: name === secretRef.env ? "k8s-secret-ref" : "runtime-env" + })), + secretRef: { + env: secretRef.env, + secretName: secretRef.secretName, + secretKey: secretRef.secretKey, + deployPlaceholderPresent: deployEnv?.[secretRef.env] === expectedSecretRef, + workloadSecretRefPresent: workloadInspection.secretRef.present === true, + present: deployEnv?.[secretRef.env] === expectedSecretRef && workloadInspection.secretRef.present === true, + redacted: true + }, + egress: { + env: contract.egress.env, + target: contract.egress.target, + deployPresent: Boolean(deployEnv?.[contract.egress.env]), + workloadPresent: workloadInspection.egress.present, + deployMatchesDevProxy: deployEnv?.[contract.egress.env] === contract.egress.defaultBaseUrl, + workloadMatchesDevProxy: workloadInspection.egress.matchesDevProxy, + directPublicOpenAi: + isForbiddenDirectOpenAiBaseUrl(deployEnv?.[contract.egress.env]) || + workloadInspection.egress.directPublicOpenAi === true, + valueRedacted: true + }, + missingDeployEnv, + missingWorkloadEnv: workloadInspection.missingEnv, + deployMismatches, + workloadMismatches: workloadInspection.mismatchedEnv, + missingSecretRefs, + missingEgressContract, + secretMaterialRead: false, + valuesRedacted: true, + providerConnected: false, + fixtureEvidence: false + }; +} + +export function inspectCodeAgentProviderWorkloadEnv(workloadEnv = {}) { + const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT; + const secretRef = contract.secretRefs[0]; + const requiredRuntimeEnv = codeAgentRequiredRuntimeEnv(); + const expectedValues = { + HWLAB_CODE_AGENT_PROVIDER: contract.provider, + HWLAB_CODE_AGENT_MODEL: contract.model, + [contract.egress.env]: contract.egress.defaultBaseUrl + }; + const requiredEnv = requiredRuntimeEnv.map((name) => { + const entry = getEnvEntry(workloadEnv, name); + return { + name, + present: Boolean(entry), + redacted: name === secretRef.env || name === contract.egress.env, + source: name === secretRef.env ? "k8s-secret-ref" : "runtime-env" + }; + }); + const missingEnv = requiredEnv.filter((entry) => !entry.present).map((entry) => entry.name); + const mismatchedEnv = []; + for (const [name, expected] of Object.entries(expectedValues)) { + const entry = getEnvEntry(workloadEnv, name); + if (entry && !Object.is(envEntryValue(entry), expected)) { + mismatchedEnv.push({ + name, + expected, + redacted: name === contract.egress.env + }); + } + } + + const secretKeyRef = envEntrySecretKeyRef(getEnvEntry(workloadEnv, secretRef.env)); + const secretPresent = + secretKeyRef?.name === secretRef.secretName && + secretKeyRef?.key === secretRef.secretKey; + const baseUrl = envEntryValue(getEnvEntry(workloadEnv, contract.egress.env)); + const directPublicOpenAi = isForbiddenDirectOpenAiBaseUrl(baseUrl); + const matchesDevProxy = baseUrl === contract.egress.defaultBaseUrl; + const ready = + missingEnv.length === 0 && + mismatchedEnv.length === 0 && + secretPresent && + matchesDevProxy && + !directPublicOpenAi; + + return { + contractVersion: contract.contractVersion, + environment: contract.environment, + provider: contract.runtimeProvider, + model: contract.model, + status: ready ? "pass" : "blocked", + ready, + requiredEnv, + missingEnv, + mismatchedEnv, + secretRef: { + env: secretRef.env, + secretName: secretRef.secretName, + secretKey: secretRef.secretKey, + present: secretPresent, + redacted: true + }, + egress: { + env: contract.egress.env, + target: contract.egress.target, + present: Boolean(baseUrl), + matchesDevProxy, + directPublicOpenAi, + valueRedacted: true + }, + secretMaterialRead: false, + valuesRedacted: true, + providerConnected: false + }; +} + export function buildCodeAgentProviderManifestPlaceholder() { const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT; return { @@ -135,3 +301,37 @@ function firstNonEmpty(...values) { } return ""; } + +function codeAgentRequiredRuntimeEnv() { + return [ + "HWLAB_CODE_AGENT_PROVIDER", + "HWLAB_CODE_AGENT_MODEL", + ...DEV_CODE_AGENT_PROVIDER_CONTRACT.requiredEnv + ]; +} + +function getEnvEntry(env, name) { + if (!env) return undefined; + if (env instanceof Map) return env.get(name); + if (Array.isArray(env)) return env.find((entry) => entry?.name === name); + return env[name]; +} + +function envEntryValue(entry) { + if (typeof entry === "string") return entry; + if (entry && typeof entry === "object" && Object.hasOwn(entry, "value")) return entry.value; + return undefined; +} + +function envEntrySecretKeyRef(entry) { + if (!entry || typeof entry !== "object") return null; + return entry.valueFrom?.secretKeyRef ?? null; +} + +function isForbiddenDirectOpenAiBaseUrl(value) { + const baseUrl = firstNonEmpty(value); + return ( + baseUrl === DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.forbiddenDirectBaseUrl || + /^https:\/\/api\.openai\.com\/v1\/responses\/?$/u.test(baseUrl) + ); +} diff --git a/scripts/deploy-desired-state-plan.test.mjs b/scripts/deploy-desired-state-plan.test.mjs index 92880d2a..4e680a61 100644 --- a/scripts/deploy-desired-state-plan.test.mjs +++ b/scripts/deploy-desired-state-plan.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -17,12 +17,14 @@ async function makeFixture({ deployEnvImage = null, deployEnvMirrors = true, deploySkillsCommitId = null, + deployCodeAgentProviderEnv = true, workloadTag = commitId, workloadEnvCommitId = commitId, workloadEnvImageTag = commitId, workloadEnvImage = null, workloadEnvMirrors = true, - workloadSkillsCommitId = null + workloadSkillsCommitId = null, + workloadCodeAgentProviderEnv = true } = {}) { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-desired-state-")); await mkdir(path.join(root, "deploy/k8s/base"), { recursive: true }); @@ -30,6 +32,33 @@ async function makeFixture({ const image = `127.0.0.1:5000/hwlab/${serviceId}:${commitId}`; const catalogImage = `127.0.0.1:5000/hwlab/${serviceId}:${catalogImageTag}`; const workloadImage = `127.0.0.1:5000/hwlab/${serviceId}:${workloadTag}`; + const providerBaseUrl = "http://172.26.26.227:17680/v1/responses"; + const providerEnv = + serviceId === "hwlab-cloud-api" && deployCodeAgentProviderEnv + ? { + HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_MODEL: "gpt-5.5", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: providerBaseUrl, + OPENAI_API_KEY: "secretRef:hwlab-code-agent-provider/openai-api-key" + } + : {}; + const workloadProviderEnv = + serviceId === "hwlab-cloud-api" && workloadCodeAgentProviderEnv + ? [ + { name: "HWLAB_CODE_AGENT_PROVIDER", value: "openai" }, + { name: "HWLAB_CODE_AGENT_MODEL", value: "gpt-5.5" }, + { name: "HWLAB_CODE_AGENT_OPENAI_BASE_URL", value: providerBaseUrl }, + { + name: "OPENAI_API_KEY", + valueFrom: { + secretKeyRef: { + name: "hwlab-code-agent-provider", + key: "openai-api-key" + } + } + } + ] + : []; const deploy = { manifestVersion: "v1", environment: "dev", @@ -47,7 +76,8 @@ async function makeFixture({ HWLAB_COMMIT_ID: deployEnvCommitId, HWLAB_IMAGE: deployEnvImage ?? image, HWLAB_IMAGE_TAG: deployEnvImageTag, - ...(deploySkillsCommitId ? { HWLAB_SKILLS_COMMIT_ID: deploySkillsCommitId } : {}) + ...(deploySkillsCommitId ? { HWLAB_SKILLS_COMMIT_ID: deploySkillsCommitId } : {}), + ...providerEnv } : {} } @@ -105,7 +135,8 @@ async function makeFixture({ { name: "HWLAB_COMMIT_ID", value: workloadEnvCommitId }, { name: "HWLAB_IMAGE", value: workloadEnvImage ?? image }, { name: "HWLAB_IMAGE_TAG", value: workloadEnvImageTag }, - ...(workloadSkillsCommitId ? [{ name: "HWLAB_SKILLS_COMMIT_ID", value: workloadSkillsCommitId }] : []) + ...(workloadSkillsCommitId ? [{ name: "HWLAB_SKILLS_COMMIT_ID", value: workloadSkillsCommitId }] : []), + ...workloadProviderEnv ] : [] } @@ -139,6 +170,16 @@ test("passes when cloud-web owns runtime identity mirrors", async () => { assert.deepEqual(plan.diagnostics, []); }); +test("passes when cloud-api preserves provider env and DEV egress contract", async () => { + const repoRoot = await makeFixture(); + const plan = await buildDesiredStatePlan({ repoRoot }); + assert.equal(plan.status, "pass"); + assert.equal(plan.codeAgentProvider.ready, true); + assert.equal(plan.codeAgentProvider.secretRef.present, true); + assert.equal(plan.codeAgentProvider.egress.deployMatchesDevProxy, true); + assert.deepEqual(plan.diagnostics, []); +}); + test("blocks when cloud-web runtime identity mirrors are missing", async () => { const repoRoot = await makeFixture({ serviceId: "hwlab-cloud-web", @@ -158,6 +199,29 @@ test("blocks when cloud-web runtime identity mirrors are missing", async () => { )); }); +test("blocks when cloud-api provider env is missing from desired state", async () => { + const repoRoot = await makeFixture({ deployCodeAgentProviderEnv: false, workloadCodeAgentProviderEnv: false }); + const plan = await buildDesiredStatePlan({ repoRoot }); + assert.equal(plan.status, "blocked"); + assert.equal(plan.codeAgentProvider.ready, false); + assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "code_agent_provider_contract_mismatch")); + assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.path === "deploy.codeAgentProvider")); +}); + +test("blocks when cloud-api workload points Code Agent directly at public OpenAI", async () => { + const repoRoot = await makeFixture(); + const workloadsPath = path.join(repoRoot, "deploy/k8s/base/workloads.yaml"); + const workloads = JSON.parse(await readFile(workloadsPath, "utf8")); + const env = workloads.items[0].spec.template.spec.containers[0].env; + env.find((entry) => entry.name === "HWLAB_CODE_AGENT_OPENAI_BASE_URL").value = "https://api.openai.com/v1/responses"; + await writeFile(workloadsPath, `${JSON.stringify(workloads, null, 2)}\n`); + + const plan = await buildDesiredStatePlan({ repoRoot }); + assert.equal(plan.status, "blocked"); + assert.equal(plan.codeAgentProvider.egress.directPublicOpenAi, true); + assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "code_agent_provider_contract_mismatch")); +}); + test("target tag review is read-only promotion_pending when current state is uniformly older", async () => { const repoRoot = await makeFixture(); const plan = await buildDesiredStatePlan({ repoRoot, targetTag: "def5678" }); diff --git a/scripts/src/deploy-desired-state-plan.mjs b/scripts/src/deploy-desired-state-plan.mjs index 5ae3d64c..4e19e95f 100644 --- a/scripts/src/deploy-desired-state-plan.mjs +++ b/scripts/src/deploy-desired-state-plan.mjs @@ -4,6 +4,11 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { + DEV_CODE_AGENT_PROVIDER_CONTRACT, + inspectCodeAgentProviderManifestRefs +} from "../../internal/cloud/code-agent-contract.mjs"; + const execFileAsync = promisify(execFile); const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -275,6 +280,55 @@ function inspectEnvMirrors(ctx, { source, basePath, serviceId, env, service, des return { present, missing, diagnostics }; } +function inspectCodeAgentProviderDesiredState(ctx, deployService, workloadRecords) { + const workloadRecord = workloadRecords.find((workload) => workload.containerName === "hwlab-cloud-api") ?? workloadRecords[0] ?? null; + const inspection = inspectCodeAgentProviderManifestRefs({ + deployEnv: deployService?.env ?? {}, + workloadEnv: workloadRecord?.env ?? {} + }); + if (!inspection.ready) { + addDiagnostic(ctx, { + code: "code_agent_provider_contract_mismatch", + path: "deploy.codeAgentProvider", + message: "hwlab-cloud-api desired state must preserve Code Agent provider Secret ref and DEV egress proxy env", + expected: { + provider: DEV_CODE_AGENT_PROVIDER_CONTRACT.provider, + secretRef: `${DEV_CODE_AGENT_PROVIDER_CONTRACT.secretRefs[0].secretName}/${DEV_CODE_AGENT_PROVIDER_CONTRACT.secretRefs[0].secretKey}`, + egressTarget: DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.target + }, + actual: { + missingDeployEnv: inspection.missingDeployEnv, + missingWorkloadEnv: inspection.missingWorkloadEnv, + deployMismatches: inspection.deployMismatches.map((entry) => entry.name), + workloadMismatches: inspection.workloadMismatches.map((entry) => entry.name), + missingSecretRefs: inspection.missingSecretRefs, + missingEgressContract: inspection.missingEgressContract + }, + secretMaterialRead: false, + valuesRedacted: true + }); + } + recordObservation(ctx, { + source: "deploy", + serviceId: "hwlab-cloud-api", + path: `${deployPath}.services.hwlab-cloud-api.env.OPENAI_API_KEY`, + field: "OPENAI_API_KEY", + kind: "secret-ref", + value: inspection.secretRef.present ? "secretRef:declared" : "missing_or_mismatched", + expected: "secretRef:hwlab-code-agent-provider/openai-api-key" + }); + recordObservation(ctx, { + source: "workload", + serviceId: "hwlab-cloud-api", + path: `${workloadsPath}.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL`, + field: "HWLAB_CODE_AGENT_OPENAI_BASE_URL", + kind: "egress-proxy", + value: inspection.egress.workloadMatchesDevProxy ? "dev-egress-proxy" : "missing_or_mismatched", + expected: DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.target + }); + return inspection; +} + function indexWorkloads(workloads) { const byService = new Map(); for (const item of listItems(workloads)) { @@ -561,6 +615,7 @@ export async function buildDesiredStatePlan(options = {}) { ); const services = []; + let codeAgentProvider = null; for (const serviceId of serviceIds) { const deployService = deployByService.get(serviceId); const catalogService = catalogByService.get(serviceId); @@ -717,6 +772,10 @@ export async function buildDesiredStatePlan(options = {}) { service.promotion = servicePromotion(service, target); services.push(service); + + if (serviceId === "hwlab-cloud-api") { + codeAgentProvider = inspectCodeAgentProviderDesiredState(ctx, deployService, workloadsForService); + } } const targetConvergence = buildTargetConvergence(ctx, target); @@ -779,6 +838,14 @@ export async function buildDesiredStatePlan(options = {}) { nonAuthoritativeEvidence: [artifactReportPath], note: "This planner reviews source desired-state only. It does not prove image existence, registry reachability, a real DEV apply, or M3 DEV-LIVE hardware-loop evidence." }, + codeAgentProvider: codeAgentProvider ?? { + status: "blocked", + ready: false, + missingDeployEnv: ["hwlab-cloud-api"], + missingWorkloadEnv: ["hwlab-cloud-api"], + secretMaterialRead: false, + valuesRedacted: true + }, reportHints: reportHints(artifactReport, desiredCommitId), services, diagnostics: ctx.diagnostics diff --git a/scripts/src/dev-deploy-apply.mjs b/scripts/src/dev-deploy-apply.mjs index 6ac3172a..2806bfc6 100755 --- a/scripts/src/dev-deploy-apply.mjs +++ b/scripts/src/dev-deploy-apply.mjs @@ -10,6 +10,11 @@ import { DEV_DB_ENV_CONTRACT, summarizeDbContract } from "../../internal/cloud/db-contract.mjs"; +import { + DEV_CODE_AGENT_PROVIDER_CONTRACT, + inspectCodeAgentProviderManifestRefs, + inspectCodeAgentProviderWorkloadEnv +} from "../../internal/cloud/code-agent-contract.mjs"; import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs"; @@ -582,6 +587,12 @@ function blockerHint(blocker) { if (blocker.scope.startsWith("rollout-read-")) { return "Restore read-only Deployment visibility in hwlab-dev so rollout revision, image tag, digest, and source commit can be reported after apply."; } + if (blocker.scope.startsWith("code-agent-provider-env-read-")) { + return "Restore read-only Deployment visibility for hwlab-cloud-api so provider env names and secretKeyRef metadata can be verified without reading Secret values."; + } + if (blocker.scope.startsWith("code-agent-provider-env-preservation-") || blocker.scope === "code-agent-provider-desired-state") { + return "Repair deploy/deploy.json and deploy/k8s/base/workloads.yaml so hwlab-cloud-api preserves OPENAI_API_KEY from hwlab-code-agent-provider/openai-api-key and the DEV egress proxy base URL."; + } if (blocker.scope.startsWith("deploy-runtime-env-") || blocker.scope.startsWith("k8s-runtime-env-")) { return "Update deploy/deploy.json and deploy/k8s/base/workloads.yaml so the service owns HWLAB_COMMIT_ID, HWLAB_IMAGE, and HWLAB_IMAGE_TAG."; } @@ -631,6 +642,132 @@ function buildRollbackHint(workloads) { }; } +function workloadEnvByServiceId(workloads, serviceId) { + for (const item of listItems(workloads)) { + for (const container of containersFor(item)) { + if (serviceIdFor(item, container) !== serviceId) continue; + return new Map((container.env ?? []).filter((entry) => entry?.name).map((entry) => [entry.name, entry])); + } + } + return new Map(); +} + +export function inspectCodeAgentProviderDesiredState(deploy, workloads) { + const deployEnv = deploy?.services?.find((service) => service.serviceId === "hwlab-cloud-api")?.env ?? {}; + return inspectCodeAgentProviderManifestRefs({ + deployEnv, + workloadEnv: workloadEnvByServiceId(workloads, "hwlab-cloud-api") + }); +} + +export function inspectCodeAgentProviderLiveDeployment(deployment) { + const container = containerForDeployment(deployment, "hwlab-cloud-api"); + const workloadEnv = new Map((container?.env ?? []).filter((entry) => entry?.name).map((entry) => [entry.name, entry])); + const inspection = inspectCodeAgentProviderWorkloadEnv(workloadEnv); + return { + ...inspection, + namespace: deployment?.metadata?.namespace ?? namespace, + deployment: deployment?.metadata?.name ?? "hwlab-cloud-api", + container: container?.name ?? "hwlab-cloud-api", + validationScope: "deployment-env-and-secretKeyRef-metadata-only", + secretValuesRead: false, + kubernetesSecretDataRead: false + }; +} + +function containerForDeployment(deployment, containerName) { + return (deployment?.spec?.template?.spec?.containers ?? []).find((container) => container.name === containerName) ?? null; +} + +function codeAgentProviderBlockerSummary(inspection) { + const missing = [ + ...(inspection.missingDeployEnv ?? []), + ...(inspection.missingWorkloadEnv ?? inspection.missingEnv ?? []), + ...(inspection.deployMismatches ?? []).map((entry) => entry.name), + ...(inspection.workloadMismatches ?? inspection.mismatchedEnv ?? []).map((entry) => entry.name), + ...(inspection.missingSecretRefs ?? []), + ...(inspection.missingEgressContract ?? []) + ]; + const detail = uniqueStrings(missing).join(", "); + return detail + ? `Code Agent provider env preservation is incomplete; missing or mismatched ${detail}` + : "Code Agent provider env preservation is incomplete"; +} + +function addCodeAgentProviderDesiredStateBlocker(blockers, desiredState) { + if (desiredState.ready) return; + addBlocker( + blockers, + "agent_blocker", + "code-agent-provider-desired-state", + codeAgentProviderBlockerSummary(desiredState) + ); +} + +async function observeCodeAgentProviderLiveDeployment(kubectl, blockers, { phase, blockOnMismatch }) { + const commandArgs = ["-n", namespace, "get", "deployment", "hwlab-cloud-api", "-o", "json"]; + const base = { + phase, + namespace, + serviceId: "hwlab-cloud-api", + status: "not_evaluated", + readCommand: kubectlCommand(kubectl, commandArgs), + kubeconfig: kubectl.kubeconfig, + expectedSecretRef: `${DEV_CODE_AGENT_PROVIDER_CONTRACT.secretRefs[0].secretName}/${DEV_CODE_AGENT_PROVIDER_CONTRACT.secretRefs[0].secretKey}`, + expectedEgressTarget: DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.target, + secretValuesRead: false, + kubernetesSecretDataRead: false, + valuesRedacted: true + }; + + if (kubectl.status !== "ready") { + if (blockOnMismatch) { + addBlocker(blockers, "environment_blocker", `code-agent-provider-env-read-${phase}`, kubectl.reason); + } + return { + ...base, + status: "blocked", + blocker: kubectl.reason + }; + } + + const result = await kubectlResult(kubectl, commandArgs, 15000); + if (!result.ok) { + const summary = oneLine(result.stderr || result.stdout); + if (blockOnMismatch) { + addBlocker(blockers, "environment_blocker", `code-agent-provider-env-read-${phase}`, `Cannot read DEV Deployment hwlab-cloud-api provider env: ${summary}`); + } + return { + ...base, + status: "blocked", + blocker: summary + }; + } + + try { + const inspection = inspectCodeAgentProviderLiveDeployment(JSON.parse(result.stdout)); + if (!inspection.ready && blockOnMismatch) { + addBlocker(blockers, "runtime_blocker", `code-agent-provider-env-preservation-${phase}`, codeAgentProviderBlockerSummary(inspection)); + } + return { + ...base, + ...inspection, + phase, + status: inspection.ready ? "pass" : "blocked" + }; + } catch (error) { + const summary = oneLine(error.message); + if (blockOnMismatch) { + addBlocker(blockers, "contract_blocker", `code-agent-provider-env-read-${phase}`, `Cannot parse DEV Deployment hwlab-cloud-api provider env: ${summary}`); + } + return { + ...base, + status: "blocked", + blocker: summary + }; + } +} + function buildManualCommands(status) { if (status === "blocked") { return { @@ -1297,6 +1434,8 @@ export async function runDevDeployApply(argv, io = {}) { const artifactEvidence = validateDeployAndCatalog(deploy, catalog, commitId, blockers); const k8sManifest = validateK8s(devKustomization, namespaceDoc, workloads, services, healthContract, deploy, catalog, blockers); const cloudApiDb = await checkCloudApiDb(deploy, workloads, services, blockers); + const codeAgentProviderDesiredState = inspectCodeAgentProviderDesiredState(deploy, workloads); + addCodeAgentProviderDesiredStateBlocker(blockers, codeAgentProviderDesiredState); if (!(await readText("internal/cloud/server.mjs")).includes('url.pathname === "/health/live"')) { addBlocker(blockers, "contract_blocker", "cloud-api-health-path", "cloud-api does not implement the k8s /health/live path"); } @@ -1307,6 +1446,10 @@ export async function runDevDeployApply(argv, io = {}) { observationPhase: "before_apply", blockRuntimeEnvDrift: false }); + const codeAgentProviderLiveBeforeApply = await observeCodeAgentProviderLiveDeployment(kubectl, blockers, { + phase: "before_apply", + blockOnMismatch: !args.apply + }); const liveProbe = args.skipLiveProbe ? { status: "not_run", reason: "--skip-live-probe" } : await probeDevEndpoint(blockers); const templateJobReplacements = await buildDevTemplateJobReplacementPlan(kubectl, workloads, blockers); const applyStep = await runApplyStep( @@ -1329,6 +1472,19 @@ export async function runDevDeployApply(argv, io = {}) { args.apply ? "apply did not complete successfully; post-apply rollout env verification was not run" : "dry-run mode; no post-apply rollout env verification" ); const cloudWebRollout = args.apply ? cloudWebRolloutAfterApply : cloudWebRolloutBeforeApply; + const codeAgentProviderLiveAfterApply = args.apply && applyStep.status === "pass" + ? await observeCodeAgentProviderLiveDeployment(kubectl, blockers, { + phase: "after_apply", + blockOnMismatch: true + }) + : { + phase: "after_apply", + status: "not_run", + reason: args.apply ? "apply did not complete" : "dry-run mode", + secretValuesRead: false, + kubernetesSecretDataRead: false, + valuesRedacted: true + }; const status = blockers.length > 0 ? "blocked" : "pass"; const artifactPlan = buildArtifactPlan(deploy, catalog, commitId, artifactEvidence); const workloadPlan = buildWorkloadPlan(workloads); @@ -1342,7 +1498,7 @@ export async function runDevDeployApply(argv, io = {}) { $id: "https://hwlab.pikastech.local/reports/dev-gate/dev-deploy-report.json", reportVersion: "v1", issue: "pikasTech/HWLAB#33", - supports: ["pikasTech/HWLAB#63", "pikasTech/HWLAB#50", "pikasTech/HWLAB#7", "pikasTech/HWLAB#33", "pikasTech/HWLAB#17", "pikasTech/HWLAB#32", "pikasTech/HWLAB#30"], + supports: ["pikasTech/HWLAB#164", "pikasTech/HWLAB#143", "pikasTech/HWLAB#99", "pikasTech/HWLAB#63", "pikasTech/HWLAB#50", "pikasTech/HWLAB#7", "pikasTech/HWLAB#33", "pikasTech/HWLAB#17", "pikasTech/HWLAB#32", "pikasTech/HWLAB#30"], taskId: "dev-deploy-apply", commitId, acceptanceLevel: "dev_deploy_apply", @@ -1385,6 +1541,8 @@ export async function runDevDeployApply(argv, io = {}) { `kubectl executor: ${kubectl.executor ?? "missing"}`, `kubectl kubeconfig: ${kubectl.kubeconfig}`, `live health: ${liveProbe.status}`, + `code agent provider desired-state: ${codeAgentProviderDesiredState.status}`, + `code agent provider live env before apply: ${codeAgentProviderLiveBeforeApply.status}`, `template job replacements: ${templateJobReplacements.filter((replacement) => replacement.replace).length}` ], summary: status === "blocked" ? "DEV apply is blocked before mutation." : "DEV dry-run preflight passed." @@ -1395,6 +1553,7 @@ export async function runDevDeployApply(argv, io = {}) { "DEV artifact catalog must contain CI publish evidence and registry digests", "kubectl must be available for D601 hwlab-dev and must not target PROD", "hwlab-cloud-api /health/live must report serviceId, dev environment, and DB env readiness without exposing secret values", + "hwlab-cloud-api must declare and preserve OPENAI_API_KEY from hwlab-code-agent-provider/openai-api-key plus the DEV Code Agent egress proxy env without reading Secret values", "pods, services, configmaps, and image commit tags must be observable in hwlab-dev" ], summary: status === "blocked" ? "One or more required DEV deploy preconditions are blocked." : "DEV deploy preconditions are satisfied." @@ -1418,6 +1577,16 @@ export async function runDevDeployApply(argv, io = {}) { cloudWebRollout, cloudWebRolloutBeforeApply, cloudWebRolloutAfterApply, + codeAgentProvider: { + desiredState: codeAgentProviderDesiredState, + liveBeforeApply: codeAgentProviderLiveBeforeApply, + liveAfterApply: codeAgentProviderLiveAfterApply, + validationGate: "no-secret provider env/secretKeyRef preservation", + providerCallAttempted: false, + secretValuesRead: false, + kubernetesSecretDataRead: false, + valuesRedacted: true + }, applyStep, manualCommands, rollbackHint: buildRollbackHint(workloads), diff --git a/scripts/src/dev-deploy-apply.test.mjs b/scripts/src/dev-deploy-apply.test.mjs index 016f208b..34efad73 100644 --- a/scripts/src/dev-deploy-apply.test.mjs +++ b/scripts/src/dev-deploy-apply.test.mjs @@ -1,7 +1,76 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { compareRuntimeIdentityEnv, decideDevTemplateJobReplacement, resolveApplySourceCommit } from "./dev-deploy-apply.mjs"; +import { + compareRuntimeIdentityEnv, + decideDevTemplateJobReplacement, + inspectCodeAgentProviderDesiredState, + inspectCodeAgentProviderLiveDeployment, + resolveApplySourceCommit +} from "./dev-deploy-apply.mjs"; + +const codeAgentBaseUrl = "http://172.26.26.227:17680/v1/responses"; + +function codeAgentDeploy(env = {}) { + return { + services: [ + { + serviceId: "hwlab-cloud-api", + env: { + HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_MODEL: "gpt-5.5", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: codeAgentBaseUrl, + OPENAI_API_KEY: "secretRef:hwlab-code-agent-provider/openai-api-key", + ...env + } + } + ] + }; +} + +function codeAgentWorkloads(envOverrides = []) { + return { + kind: "List", + items: [ + { + kind: "Deployment", + metadata: { + name: "hwlab-cloud-api", + namespace: "hwlab-dev", + labels: { + "hwlab.pikastech.local/service-id": "hwlab-cloud-api" + } + }, + spec: { + template: { + spec: { + containers: [ + { + name: "hwlab-cloud-api", + env: [ + { name: "HWLAB_CODE_AGENT_PROVIDER", value: "openai" }, + { name: "HWLAB_CODE_AGENT_MODEL", value: "gpt-5.5" }, + { name: "HWLAB_CODE_AGENT_OPENAI_BASE_URL", value: codeAgentBaseUrl }, + { + name: "OPENAI_API_KEY", + valueFrom: { + secretKeyRef: { + name: "hwlab-code-agent-provider", + key: "openai-api-key" + } + } + }, + ...envOverrides + ] + } + ] + } + } + } + } + ] + }; +} test("allowlisted suspended template Job image change plans replacement", () => { const decision = decideDevTemplateJobReplacement({ @@ -127,3 +196,39 @@ test("runtime identity env comparator detects stale cloud-web env drift", () => ["HWLAB_COMMIT_ID", "HWLAB_IMAGE", "HWLAB_IMAGE_TAG"] ); }); + +test("Code Agent provider desired state preserves Secret ref and egress base URL", () => { + const inspection = inspectCodeAgentProviderDesiredState(codeAgentDeploy(), codeAgentWorkloads()); + + assert.equal(inspection.ready, true); + assert.equal(inspection.status, "pass"); + assert.equal(inspection.secretRef.present, true); + assert.equal(inspection.secretMaterialRead, false); + assert.equal(inspection.egress.deployMatchesDevProxy, true); + assert.equal(inspection.egress.workloadMatchesDevProxy, true); +}); + +test("Code Agent provider desired state blocks direct public OpenAI base URL", () => { + const inspection = inspectCodeAgentProviderDesiredState( + codeAgentDeploy({ HWLAB_CODE_AGENT_OPENAI_BASE_URL: "https://api.openai.com/v1/responses" }), + codeAgentWorkloads([ + { name: "HWLAB_CODE_AGENT_OPENAI_BASE_URL", value: "https://api.openai.com/v1/responses" } + ]) + ); + + assert.equal(inspection.ready, false); + assert.equal(inspection.status, "blocked"); + assert.equal(inspection.egress.directPublicOpenAi, true); + assert.ok(inspection.missingEgressContract.some((entry) => entry.includes("api.openai.com"))); +}); + +test("Code Agent live Deployment inspection reads only env metadata", () => { + const deployment = codeAgentWorkloads().items[0]; + const inspection = inspectCodeAgentProviderLiveDeployment(deployment); + + assert.equal(inspection.ready, true); + assert.equal(inspection.secretRef.present, true); + assert.equal(inspection.secretValuesRead, false); + assert.equal(inspection.kubernetesSecretDataRead, false); + assert.equal(inspection.validationScope, "deployment-env-and-secretKeyRef-metadata-only"); +});