From f4c0f8831228b635f5a05a426d689d9516cb7c14 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 20 May 2026 21:04:57 +0000 Subject: [PATCH] fix: expose deploy artifact matrix plan fields --- docs/reference/deploy.md | 2 + .../deploy-artifact-matrix-contract-test.ts | 74 ++++++ scripts/src/check.ts | 3 + scripts/src/deploy.ts | 225 +++++++++++++++--- 4 files changed, 265 insertions(+), 39 deletions(-) create mode 100644 scripts/deploy-artifact-matrix-contract-test.ts diff --git a/docs/reference/deploy.md b/docs/reference/deploy.md index 3e4594bc..5464d627 100644 --- a/docs/reference/deploy.md +++ b/docs/reference/deploy.md @@ -112,6 +112,8 @@ Production `code-queue-mgr` is a separate main-server Compose sidecar artifact c `bun scripts/cli.ts deploy plan --env dev [--service ]` reads `origin/master:deploy.json#environments.dev` and prints a dry-run environment plan without checking or mutating live runtime resources. `deploy check --env dev` uses the same dry-run environment plan. `--env prod` is available for parity as a dry-run planning path; it reads `origin/master:deploy.json#environments.prod` and must not use a dirty local `deploy.json`. +Environment plan output must be sufficient to review the artifact matrix without running a live apply. Each service item includes `deploymentPath`, `artifactConsumer.consumerKind`, `artifactConsumer.registryImage`, `artifactConsumer.noRuntimeSourceBuild`, `artifactConsumer.dryRunOnly`, `target`, `validation` and `liveApply` where relevant. `consumerKind=d601-direct-compose` means the reviewed consumer touches only the D601 Docker/Compose service and private health path; `consumerKind=d601-k3s-managed` means the reviewed consumer imports the artifact into native k3s/containerd and verifies through the Kubernetes API service proxy; `consumerKind=main-server-compose` means the reviewed consumer streams or loads the D601 artifact into the main-server Compose service; `consumerKind=d601-dev-target-side-build` is reserved for the controlled dev backend-core source-build exception. Artifact consumer plan items must explicitly report `noRuntimeSourceBuild=true` and list forbidden build/public exposure actions. Blocked or gated services must keep structured `dryRunOnly` / `blockedReason` output, for example `met-nonlinear` `runtime-verification-blocked` and `k3sctl-adapter` supervisor-only production apply. + `bun scripts/cli.ts deploy apply [--file deploy.json | --env dev|prod] [--service ] [--commit ] [--dry-run] [--force]` starts an asynchronous job only for supported targets. Use `bun scripts/cli.ts job status --tail-bytes 30000` to observe progress. `--dry-run` resolves the same plan but does not build or replace runtime objects. `--force` rebuilds even when the live commit matches. Environment apply is not the dev e2e trigger; use `bun scripts/cli.ts ci run-dev-e2e` for the Git-controlled temporary namespace smoke flow. `--env dev` apply is enabled for persistent D601 `backend-core` target-side rollout and for `frontend`/`baidu-netdisk`/`decision-center`/`mdtodo`/`claudeqq`/dev-only `code-queue`/`project-manager`/`oa-event-flow`/`code-queue-mgr`/`todo-note`/`findjob`/`pipeline`/`met-nonlinear` artifact consumers. `--env prod` apply exposes the D601 registry artifact consumer for `backend-core`, `frontend`, `baidu-netdisk`, `decision-center`, `mdtodo`, `claudeqq`, `project-manager`, `oa-event-flow`, `todo-note`, `findjob`, `pipeline` and `met-nonlinear`; `code-queue-mgr` prod live apply is supervisor-gated and `k3sctl-adapter` is plan/dry-run only. `--commit` may override one selected reviewed artifact consumer in either dev or prod, for example `deploy apply --env dev --service frontend --commit `, and the image must already exist as `127.0.0.1:5000/unidesk/:`. Unsupported prod services, especially `code-queue`, return a structured `unsupported` payload instead of silently falling back to a maintenance-channel source build. All deploy commands output JSON. Long operations must use `.state/jobs/` and bounded log tails; no deploy path may succeed with missing progress output. diff --git a/scripts/deploy-artifact-matrix-contract-test.ts b/scripts/deploy-artifact-matrix-contract-test.ts new file mode 100644 index 00000000..2dcedbe4 --- /dev/null +++ b/scripts/deploy-artifact-matrix-contract-test.ts @@ -0,0 +1,74 @@ +import { spawnSync } from "node:child_process"; + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +function asRecord(value: unknown, label: string): Record { + assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); + return value as Record; +} + +function runDeployPlan(environment: "dev" | "prod", serviceId: string): Record { + const result = spawnSync("bun", ["scripts/cli.ts", "deploy", "plan", "--env", environment, "--service", serviceId], { + cwd: process.cwd(), + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }); + assertCondition(result.status === 0, `deploy plan should exit 0 for ${environment}/${serviceId}`, { + status: result.status, + stdout: result.stdout.slice(-2000), + stderr: result.stderr.slice(-2000), + }); + const envelope = asRecord(JSON.parse(result.stdout) as unknown, "cli envelope"); + assertCondition(envelope.ok === true, `deploy plan envelope should be ok for ${environment}/${serviceId}`, envelope); + const data = asRecord(envelope.data, "deploy plan data"); + const services = Array.isArray(data.services) ? data.services : []; + assertCondition(services.length === 1, `deploy plan should return one service for ${environment}/${serviceId}`, data); + return asRecord(services[0], "service plan"); +} + +function listIncludes(value: unknown, expected: string): boolean { + return Array.isArray(value) && value.some((item) => item === expected); +} + +const findjob = runDeployPlan("dev", "findjob"); +const findjobArtifact = asRecord(findjob.artifactConsumer, "findjob artifactConsumer"); +const findjobTarget = asRecord(findjob.target, "findjob target"); +assertCondition(findjobArtifact.consumerKind === "d601-direct-compose", "findjob dev must be a D601 direct Compose artifact consumer", findjobArtifact); +assertCondition(findjobArtifact.noRuntimeSourceBuild === true, "findjob dry-run must declare no runtime source build", findjobArtifact); +assertCondition(findjobTarget.runtimeHost === "D601", "findjob target should be D601", findjobTarget); +assertCondition(findjobTarget.composeService === "server", "findjob compose service should be server", findjobTarget); +assertCondition(listIncludes(findjobTarget.forbiddenActions, "docker compose build"), "findjob plan should forbid Compose build", findjobTarget); + +const mdtodo = runDeployPlan("prod", "mdtodo"); +const mdtodoArtifact = asRecord(mdtodo.artifactConsumer, "mdtodo artifactConsumer"); +const mdtodoTarget = asRecord(mdtodo.target, "mdtodo target"); +assertCondition(mdtodoArtifact.consumerKind === "d601-k3s-managed", "mdtodo prod must be a D601 k3s-managed artifact consumer", mdtodoArtifact); +assertCondition(mdtodoArtifact.noRuntimeSourceBuild === true, "mdtodo dry-run must declare no runtime source build", mdtodoArtifact); +assertCondition(mdtodoTarget.namespace === "unidesk", "mdtodo prod target namespace should be unidesk", mdtodoTarget); +assertCondition(listIncludes(mdtodoTarget.forbiddenActions, "NodePort"), "mdtodo plan should forbid NodePort", mdtodoTarget); + +const metNonlinear = runDeployPlan("prod", "met-nonlinear"); +const metArtifact = asRecord(metNonlinear.artifactConsumer, "met-nonlinear artifactConsumer"); +assertCondition(metArtifact.consumerKind === "d601-direct-compose", "met-nonlinear should remain D601 direct Compose", metArtifact); +assertCondition(metArtifact.dryRunOnly === true, "met-nonlinear must remain dry-run only", metArtifact); +assertCondition(String(metArtifact.blockedReason ?? "").includes("runtime-verification-blocked"), "met-nonlinear blocked reason should mention runtime verification", metArtifact); + +const k3sctl = runDeployPlan("prod", "k3sctl-adapter"); +const k3sctlArtifact = asRecord(k3sctl.artifactConsumer, "k3sctl-adapter artifactConsumer"); +const k3sctlTarget = asRecord(k3sctl.target, "k3sctl-adapter target"); +assertCondition(k3sctlArtifact.consumerKind === "d601-direct-compose", "k3sctl-adapter should be D601 direct Compose", k3sctlArtifact); +assertCondition(k3sctlArtifact.dryRunOnly === true, "k3sctl-adapter must be dry-run only for worker automation", k3sctlArtifact); +assertCondition(String(k3sctlArtifact.blockedReason ?? "").includes("supervisor"), "k3sctl-adapter blocked reason should mention supervisor confirmation", k3sctlArtifact); +assertCondition(k3sctlTarget.composeService === "k3sctl-adapter", "k3sctl target service should be k3sctl-adapter", k3sctlTarget); + +process.stdout.write(`${JSON.stringify({ + ok: true, + checks: [ + "deploy plan distinguishes D601 direct Compose from D601 k3s-managed artifact consumers", + "deploy plan exposes no-runtime-source-build and forbidden build/public-port actions", + "met-nonlinear remains runtime-verification-blocked", + "k3sctl-adapter remains supervisor-only dry-run", + ], +}, null, 2)}\n`); diff --git a/scripts/src/check.ts b/scripts/src/check.ts index e82e6a65..a2327200 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -284,6 +284,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("scripts/code-queue-pr-preflight-contract-test.ts"), fileItem("scripts/src/ci.ts"), fileItem("scripts/src/e2e.ts"), + fileItem("scripts/deploy-artifact-matrix-contract-test.ts"), fileItem("scripts/code-queue-prompt-observation-test.ts"), fileItem("scripts/gh-cli-issue-guard-contract-test.ts"), fileItem("scripts/gh-cli-pr-contract-test.ts"), @@ -302,6 +303,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(commandItem("code-queue:issue3-diagnostics-and-image-preflight", ["bun", "scripts/code-queue-issue3-regression-test.ts"], 30_000)); items.push(commandItem("code-queue:trace-summary-contract", ["bun", "scripts/code-queue-trace-summary-contract-test.ts"], 30_000)); items.push(commandItem("code-queue:pr-preflight-contract", ["bun", "scripts/code-queue-pr-preflight-contract-test.ts"], 30_000)); + items.push(commandItem("deploy:artifact-matrix-contract", ["bun", "scripts/deploy-artifact-matrix-contract-test.ts"], 30_000)); items.push(commandItem("code-queue:active-run-heartbeat-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:active-run-heartbeat-visible"], 30_000)); items.push(commandItem("code-queue:trace-gap-not-stale", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:trace-gap-not-stale"], 30_000)); items.push(commandItem("code-queue:stale-active-owner-expired", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:stale-active-owner-expired"], 30_000)); @@ -318,6 +320,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(skippedItem("code-queue:issue3-diagnostics-and-image-preflight", "Code Queue issue #3 regression fixtures are opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:trace-summary-contract", "Code Queue trace summary contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:pr-preflight-contract", "Code Queue PR preflight contract is opt-in with script checks", "--scripts-typecheck or --full")); + items.push(skippedItem("deploy:artifact-matrix-contract", "deploy artifact matrix contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:liveness-diagnostics-fixtures", "Code Queue liveness diagnostics fixtures are opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("baidu-netdisk:artifact-guard-contract", "Baidu Netdisk artifact guard contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("artifact-registry:direct-compose-dry-run-matrix", "main-server direct artifact consumer dry-run matrix is opt-in with script checks", "--scripts-typecheck or --full")); diff --git a/scripts/src/deploy.ts b/scripts/src/deploy.ts index 6e299e49..dc4355b4 100644 --- a/scripts/src/deploy.ts +++ b/scripts/src/deploy.ts @@ -119,6 +119,8 @@ interface DeployEnvironmentManifestSource { fetchedAt: string; } +type ArtifactConsumerPlanKind = "main-server-compose" | "d601-direct-compose" | "d601-k3s-managed" | "d601-dev-target-side-build" | "unsupported"; + const defaultDeployFile = "deploy.json"; const defaultTimeoutMs = 900_000; const resolveFetchTimeout = "120s"; @@ -148,6 +150,9 @@ const prodArtifactLiveApplyBlockedServiceIds = new Map([ ["met-nonlinear", "met-nonlinear is blocked for live artifact deploy because config.json points at docker/unidesk/Dockerfile.ml while the compose service is met-nonlinear-ts."], ["k3sctl-adapter", "k3sctl-adapter is an infrastructure control bridge; this executor exposes artifact consumer plan/dry-run only. Real production deployment requires supervisor confirmation outside this task."], ]); +const artifactConsumerDryRunBlockedServiceIds = new Map([ + ["met-nonlinear", "runtime-verification-blocked: CI publishes the ML image Dockerfile contract, but the long-running Compose service is met-nonlinear-ts; CD cannot yet prove the running container image label matches the requested commit."], +]); const deployEnvironmentTargets: Record = { dev: { environment: "dev", @@ -881,6 +886,133 @@ function environmentTargetSummary(target: DeployEnvironmentTarget): Record { + const kind = artifactConsumerPlanKind(service, environment); + const liveBlockReason = environment === "prod" ? prodArtifactLiveApplyBlockedServiceIds.get(service.id) ?? null : null; + const dryRunBlockedReason = artifactConsumerDryRunBlockedServiceIds.get(service.id) ?? null; + const targetImage = artifactConsumerTargetImageHint(service, environment); + const common = { + consumerKind: kind, + runtimeHost: kind === "main-server-compose" ? "main-server" : "D601", + deploymentMode: service.deployment.mode, + noRuntimeSourceBuild: kind !== "d601-dev-target-side-build", + dryRunOnly: dryRunBlockedReason !== null || (environment === "prod" && prodArtifactLiveApplyBlockedServiceIds.has(service.id)), + blockedReason: dryRunBlockedReason ?? liveBlockReason, + }; + if (kind === "d601-k3s-managed") { + return { + ...common, + namespace: service.deployment.namespace ?? (environment === "dev" ? "unidesk-dev" : k8sNamespace), + deployment: service.repository.composeService, + service: service.deployment.k3sServiceId ?? service.repository.composeService, + containerName: service.repository.containerName, + manifest: service.repository.composeFile, + targetImage, + deployCommandShape: "registry manifest check + native k3s containerd import + Kubernetes Deployment image/env/annotation update + API service proxy health", + forbiddenActions: ["docker build", "docker compose build", "NodePort", "hostPort", "provider-gateway direct business backend"], + }; + } + if (kind === "d601-direct-compose" || kind === "main-server-compose") { + return { + ...common, + workDir: targetWorkDir(service), + composeFile: service.repository.composeFile, + composeService: service.repository.composeService, + containerName: service.repository.containerName, + targetImage, + deployCommandShape: `docker compose up -d --no-build --no-deps --force-recreate ${service.repository.composeService}`, + forbiddenActions: ["docker build", "docker compose build", "docker compose up --build", "dirty worktree source deploy"], + }; + } + if (kind === "d601-dev-target-side-build") { + return { + ...common, + namespace: service.deployment.namespace ?? "unidesk-dev", + deployment: service.repository.composeService, + manifest: service.repository.composeFile, + workDir: targetWorkDir(service), + deployCommandShape: "D601 dev target-side source materialization/build/import/rollout", + forbiddenActions: ["production namespace mutation", "production database mutation"], + }; + } + return common; +} + +function artifactConsumerTargetImageHint(service: UniDeskMicroserviceConfig, environment: DeployEnvironment): string { + if (service.id === "frontend" && environment === "prod") return "unidesk-frontend"; + if (service.id === "frontend" && environment === "dev") return "unidesk-frontend:dev"; + if (service.id === "k3sctl-adapter") return "unidesk-k3sctl-adapter:d601"; + if (service.id === "met-nonlinear") return "met-nonlinear-ml:tf26"; + if (service.deployment.mode === "k3sctl-managed") return `unidesk-${service.id}:${environment === "dev" ? "dev" : "d601"}`; + const container = service.repository.containerName; + if (container.endsWith("-backend")) return container.slice(0, -"-backend".length); + return container; +} + +function artifactConsumerPlanValidation(service: UniDeskMicroserviceConfig, environment: DeployEnvironment): string[] { + const kind = artifactConsumerPlanKind(service, environment); + if (kind === "d601-dev-target-side-build") { + return [ + "reads origin/master:deploy.json for dev desired state", + "materializes and builds only the selected dev service on D601", + "rolls out only unidesk-dev resources", + ]; + } + const base = [ + "reads origin/master:deploy.json for commit intent", + "requires a commit-pinned artifact in 127.0.0.1:5000", + "verifies image labels for service id, source commit, and Dockerfile", + "does not build source on the runtime target", + ]; + if (kind === "d601-k3s-managed") { + return [ + ...base, + "imports the artifact into native k3s containerd", + "verifies Deployment metadata and health through the Kubernetes API service proxy", + ]; + } + if (kind === "d601-direct-compose" || kind === "main-server-compose") { + return [ + ...base, + "recreates only the selected Compose service with --no-build --no-deps --force-recreate", + "verifies running image labels and private service health", + ]; + } + return ["unsupported service remains blocked before source materialization or runtime mutation"]; +} + +function environmentPlanServiceConfig(config: UniDeskConfig | null, service: DeployManifestService, environment: DeployEnvironment): UniDeskMicroserviceConfig | null { + if (environment === "dev") { + const devService = devK3sDeployService(service.id); + if (devService !== undefined) return devService; + } + const configured = config?.microservices.find((candidate) => candidate.id === service.id); + if (configured !== undefined) return configured; + if (config !== null) return coreDeployService(config, service.id, environment) ?? null; + return null; +} + function targetIsMain(service: UniDeskMicroserviceConfig): boolean { return service.providerId === "main-server"; } @@ -2657,6 +2789,7 @@ async function checkOrPlan(config: UniDeskConfig, manifest: DeployManifest, opti } function environmentDryRunPlan( + config: UniDeskConfig | null, manifest: DeployManifest, source: DeployEnvironmentManifestSource, options: DeployOptions, @@ -2707,49 +2840,63 @@ function environmentDryRunPlan( deployJsonFromWorktree: false, dirtyWorktreeUsed: false, }, - services: services.map((service) => ({ - id: service.id, - repo: service.repo, - commitId: service.commitId, - environment, - targetNamespace: target.namespace, - providerId: target.provider.providerId, - databaseFingerprint: fingerprint, - deploymentPath: environment === "prod" - ? prodArtifactConsumerServiceIds.has(service.id) - ? "d601-registry-artifact-consumer" - : "unsupported" - : devArtifactConsumerServiceIds.has(service.id) - ? "d601-registry-artifact-consumer" - : devApplySupportedServiceIds.has(service.id) - ? "d601-dev-target-side-build" - : "unsupported", - unsupported: (environment === "prod" && !prodArtifactConsumerServiceIds.has(service.id)) - || (environment === "dev" && !devApplySupportedServiceIds.has(service.id) && !devArtifactConsumerServiceIds.has(service.id)) - ? { - ok: false, - supported: false, - reason: environment === "prod" - ? "No standardized prod D601 registry artifact consumer is implemented for this service; legacy maintenance-channel deployment is not allowed." - : "No standardized dev deployment or artifact validation path is implemented for this service.", - } - : environment === "dev" && !devApplySupportedServiceIds.has(service.id) && !devArtifactConsumerServiceIds.has(service.id) + services: services.map((service) => { + const serviceConfig = environmentPlanServiceConfig(config, service, environment); + const unsupported = (environment === "prod" && !prodArtifactConsumerServiceIds.has(service.id)) + || (environment === "dev" && !devApplySupportedServiceIds.has(service.id) && !devArtifactConsumerServiceIds.has(service.id)); + const dryRunBlockedReason = artifactConsumerDryRunBlockedServiceIds.get(service.id) ?? null; + const planTarget = serviceConfig === null ? null : artifactConsumerPlanTarget(serviceConfig, environment); + return { + id: service.id, + repo: service.repo, + commitId: service.commitId, + environment, + targetNamespace: target.namespace, + providerId: target.provider.providerId, + databaseFingerprint: fingerprint, + deploymentPath: serviceConfig === null + ? deploymentPathForEnvironmentServiceId(service.id, environment) + : deploymentPathForEnvironmentService(serviceConfig, environment), + artifactConsumer: serviceConfig === null ? { + consumerKind: "unsupported", + noRuntimeSourceBuild: true, + dryRunOnly: true, + blockedReason: "service is missing from config.json and no synthetic deploy service is available for this environment", + } : { + consumerKind: artifactConsumerPlanKind(serviceConfig, environment), + registryImage: `127.0.0.1:5000/unidesk/${service.id}:${service.commitId}`, + noRuntimeSourceBuild: artifactConsumerPlanKind(serviceConfig, environment) !== "d601-dev-target-side-build", + dryRunOnly: (environment === "prod" && prodArtifactLiveApplyBlockedServiceIds.has(service.id)) || dryRunBlockedReason !== null, + blockedReason: dryRunBlockedReason ?? (environment === "prod" ? prodArtifactLiveApplyBlockedServiceIds.get(service.id) ?? null : null), + }, + target: planTarget, + validation: serviceConfig === null ? undefined : artifactConsumerPlanValidation(serviceConfig, environment), + unsupported: unsupported ? { ok: false, supported: false, - reason: "No standardized dev D601 registry artifact consumer is implemented for this service; legacy maintenance-channel deployment is not allowed.", + reason: environment === "prod" + ? "No standardized prod D601 registry artifact consumer is implemented for this service; legacy maintenance-channel deployment is not allowed." + : "No standardized dev deployment or artifact validation path is implemented for this service.", } - : undefined, - liveApply: environment === "prod" && prodArtifactLiveApplyBlockedServiceIds.has(service.id) - ? { - allowed: false, - reason: prodArtifactLiveApplyBlockedServiceIds.get(service.id), - dryRunOnly: true, - } - : environment === "prod" - ? { allowed: prodArtifactConsumerServiceIds.has(service.id) } : undefined, - })), + liveApply: dryRunBlockedReason !== null + ? { + allowed: false, + reason: dryRunBlockedReason, + dryRunOnly: true, + } + : environment === "prod" && prodArtifactLiveApplyBlockedServiceIds.has(service.id) + ? { + allowed: false, + reason: prodArtifactLiveApplyBlockedServiceIds.get(service.id), + dryRunOnly: true, + } + : environment === "prod" + ? { allowed: prodArtifactConsumerServiceIds.has(service.id) } + : undefined, + }; + }), unsupported: environment === "prod" ? prodArtifactUnsupported : devUnsupported, }; } @@ -3031,7 +3178,7 @@ export async function runDeployCommand(config: UniDeskConfig | null, args: strin const commitOverrideError = commitOverrideArtifactConsumerError(options); if (commitOverrideError !== null) throw new Error(commitOverrideError); const { manifest, source } = readEnvironmentDeployManifest(options.environment); - if (action === "check" || action === "plan") return environmentDryRunPlan(manifest, source, options, action); + if (action === "check" || action === "plan") return environmentDryRunPlan(config, manifest, source, options, action); if (options.environment === "prod") { const unsupported = prodArtifactUnsupportedServices(manifest, options.serviceId); if (unsupported.length > 0) return prodArtifactUnsupportedResult(unsupported);