From b334430c0067702f1add0e91bb1a525fce180917 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 12:54:42 +0000 Subject: [PATCH] test: cover code queue mgr prod artifact readiness --- docs/reference/artifact-registry.md | 2 + ...ue-mgr-artifact-readiness-contract-test.ts | 206 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 scripts/code-queue-mgr-artifact-readiness-contract-test.ts diff --git a/docs/reference/artifact-registry.md b/docs/reference/artifact-registry.md index f7461417..e168ee98 100644 --- a/docs/reference/artifact-registry.md +++ b/docs/reference/artifact-registry.md @@ -108,6 +108,8 @@ bun scripts/cli.ts artifact-registry deploy-service --env dev --service code-que `code-queue-mgr` 是主 server Compose sidecar,不是 D601 Code Queue scheduler/runner。`artifact-registry deploy-service --env prod --service code-queue-mgr --commit --dry-run` 必须显示 target 仅为 `composeService=code-queue-mgr` / `containerName=code-queue-mgr-backend`,并在 `excludedTargets` 中说明不会触碰 `code-queue` scheduler、runner、任务、interrupt 或 cancel 状态。真实 prod apply 仍受 supervisor-only gate 保护;未经单服务授权不得执行非 dry-run apply。 +`bun scripts/code-queue-mgr-artifact-readiness-contract-test.ts` 是这个 supervisor gate 的 focused source/contract 检查:它验证 prod `deploy.json` pin 到包含 stats endpoint 的 `code-queue-mgr` commit、`CI.json` 仍使用 `ci publish-user-service` 发布 `unidesk/code-queue-mgr:`、Rust runtime 暴露 `/api/tasks/stats` 且不返回 `skipped` 统计、`artifact-registry deploy-service --env prod --service code-queue-mgr --dry-run` 与 `deploy apply --env prod --service code-queue-mgr --dry-run` 都只计划 `code-queue-mgr-backend` 单个 Compose service,并保持 supervisor-only live apply 与 scheduler/runner/tasks/interrupt/cancel excluded target 合同。 + dry-run 输出会暴露 registry probe URL、required labels、目标 image、部署形态、目标 Deployment 列表、回滚信息,以及结构化 `registry`、`source` 和 `build` 字段;`registry.digest` 在 dry-run 中为 `null`,`digestSource` 指明真实 digest 来自 live registry manifest HEAD,`build.willCompile`、`build.willRunCargoBuild`、`build.willRunDockerBuild` 和 `build.willRunDockerComposeBuild` 必须为 false。dry-run 不得打印运行时密钥。`backend-core --env dev` 使用 `ci publish-backend-core` 产出的 `127.0.0.1:5000/unidesk/backend-core:`,导入 D601 native k3s containerd,更新 `unidesk-dev/backend-core-dev` Deployment,设置 image/env/annotations,并通过 Kubernetes API service proxy 验证 `/health.deploy.commit` 和 `deploy.requestedCommit`;CD 阶段不得运行 Rust 编译或 Docker build。`baidu-netdisk` 是 PGDATA 备份链路依赖服务;它的 Compose artifact 路径会通过 provider-gateway Host SSH 把 `unidesk/baidu-netdisk:` 流式拉到 master server,retag 为 `baidu-netdisk` 和 `baidu-netdisk:`,在 canonical UniDesk 根目录使用 `providerGateway.upgrade.composeEnvFile` 指向的受控 env 文件写入 `UNIDESK_BAIDU_NETDISK_DEPLOY_*`,只 recreate `baidu-netdisk` service,并验证容器 image label 与 `/health.deploy.commit`。live apply 在 recreate 前必须确认受控 env 文件中存在 `UNIDESK_BAIDU_NETDISK_CLIENT_ID`、`UNIDESK_BAIDU_NETDISK_CLIENT_SECRET` 和 `UNIDESK_BAIDU_NETDISK_TOKEN_KEY`,输出只能包含 present/length/boolean;dry-run 输出 `runtimeSecrets.secretSource`、`requiredSecretsPresent`、`missingSecretKeys` 和 `recommendedAction`,让 dev secret source blocker 可诊断但不打印值;recreate 后必须验收 `/health.auth.configured`、`clientIdConfigured`、`clientSecretConfigured`、`tokenKeyConfigured` 和 `loggedIn` 全部为 true,否则返回失败或 degraded,并提示先恢复 env、单服务 recreate、再验证 `microservice health baidu-netdisk`。`findjob`、`pipeline` 和 `met-nonlinear` 的 D601 direct Compose 路径在 D601 本机验证 registry manifest、pull image、retag stable image、写入 `UNIDESK_*_DEPLOY_*` labels/env,并用 `docker compose up -d --no-build --no-deps --force-recreate ` 重新拉起对应 compose service;其中 `met-nonlinear` 当前因为 registered Dockerfile 和 long-running service contract 不一致而 live deploy blocked。`k3sctl-adapter` 是基础设施控制桥,只做 plan/dry-run,真实生产部署需要 supervisor 单独确认。`frontend --env prod` 使用同一 Compose artifact consumer,流式拉取 `unidesk/frontend:`,retag 为 `unidesk-frontend` 和 `unidesk-frontend:`,写入 `UNIDESK_FRONTEND_DEPLOY_*`,只 recreate `frontend` service,并验证 image label 与 `/health.deploy.commit`。`frontend --env dev`、`decision-center`、`mdtodo`、`claudeqq` 和 dev `code-queue` 的 k3s 路径会在 D601 上验证 commit image、导入 native k3s containerd、更新 Deployment image/env/annotations,并通过 Kubernetes API service proxy 验证 `/health` 中的 live commit 与 requested commit;dev frontend 还会在 rollout 前把主 server `config.json.auth` 同步到 `unidesk-dev` Secret/ConfigMap。`decision-center --env dev` 落到 `unidesk-dev/decision-center-dev`,prod 落到 `unidesk/decision-center`;`mdtodo` 和 `claudeqq` 使用同样的 dev 后 prod k3s consumer 结构。`code-queue --env dev` 只更新 `unidesk-dev` 中的 scheduler/read/write/provider-egress-proxy dev Deployments,prod 没有 consumer target。D601 direct Compose consumer 与 k3s-managed consumer 的区别是:前者只接触 D601 Docker/Compose 项目和私有 backend health,不创建 Kubernetes 对象;后者只通过 native k3s Deployment/Service、containerd import 和 Kubernetes API service proxy 验证 live commit。回滚信息通过同一 artifact consumer 的 `rollback` 字段暴露,提示操作者重新对一个旧 commit 运行相同命令,而不是切回 legacy maintenance-channel 构建。 `status` 和 `health` 通过: diff --git a/scripts/code-queue-mgr-artifact-readiness-contract-test.ts b/scripts/code-queue-mgr-artifact-readiness-contract-test.ts new file mode 100644 index 00000000..dc4171bb --- /dev/null +++ b/scripts/code-queue-mgr-artifact-readiness-contract-test.ts @@ -0,0 +1,206 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { rootPath } from "./src/config"; +import { runArtifactRegistryCommand } from "./src/artifact-registry"; + +type JsonRecord = Record; + +const serviceId = "code-queue-mgr"; +const repo = "https://github.com/pikasTech/unidesk"; +const commit = "fee1b1b710151d827749cc4b0662b1560cbe1fd6"; +const dockerfile = "src/components/microservices/code-queue-mgr/Dockerfile"; +const repository = "unidesk/code-queue-mgr"; +const imageRef = `127.0.0.1:5000/${repository}:${commit}`; + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +function asRecord(value: unknown, label: string): JsonRecord { + assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, { value }); + return value as JsonRecord; +} + +function asArray(value: unknown, label: string): unknown[] { + assertCondition(Array.isArray(value), `${label} must be an array`, { value }); + return value as unknown[]; +} + +function strings(value: unknown, label: string): string[] { + return asArray(value, label).map(String); +} + +function manifestService(environment: "dev" | "prod", id: string): JsonRecord { + const manifest = asRecord(JSON.parse(readFileSync(rootPath("deploy.json"), "utf8")) as unknown, "deploy.json"); + const environments = asRecord(manifest.environments, "deploy.json.environments"); + const env = asRecord(environments[environment], `deploy.json.environments.${environment}`); + const services = asArray(env.services, `deploy.json.environments.${environment}.services`); + const found = services.map((item, index) => asRecord(item, `${environment}.services[${index}]`)).find((item) => item.id === id); + assertCondition(found !== undefined, `deploy.json ${environment} must include ${id}`, env); + return found as JsonRecord; +} + +function ciArtifact(id: string): JsonRecord { + const catalog = asRecord(JSON.parse(readFileSync(rootPath("CI.json"), "utf8")) as unknown, "CI.json"); + const artifacts = asArray(catalog.artifacts, "CI.json.artifacts").map((item, index) => asRecord(item, `CI.json.artifacts[${index}]`)); + const found = artifacts.find((item) => item.serviceId === id); + assertCondition(found !== undefined, `CI.json must include ${id}`, catalog); + return found as JsonRecord; +} + +function assertDesiredStateAndProducer(): void { + const prod = manifestService("prod", serviceId); + const dev = manifestService("dev", serviceId); + const artifact = ciArtifact(serviceId); + const source = asRecord(artifact.source, "CI.json code-queue-mgr.source"); + const image = asRecord(artifact.image, "CI.json code-queue-mgr.image"); + + assertCondition(prod.repo === repo, "prod code-queue-mgr repo mismatch", prod); + assertCondition(prod.commitId === commit, "prod code-queue-mgr must stay pinned to the stats endpoint commit", prod); + assertCondition(dev.repo === repo, "dev code-queue-mgr repo mismatch", dev); + assertCondition(artifact.kind === "source-build", "code-queue-mgr artifact must be source-build", artifact); + assertCondition(artifact.status === "supported", "code-queue-mgr artifact publish must be supported", artifact); + assertCondition(artifact.producer === "ci publish-user-service", "code-queue-mgr must publish through user-service artifact CI", artifact); + assertCondition(source.repo === repo, "code-queue-mgr CI source repo mismatch", source); + assertCondition(source.dockerfile === dockerfile, "code-queue-mgr CI Dockerfile mismatch", source); + assertCondition(image.repository === repository, "code-queue-mgr registry repository mismatch", image); +} + +function assertStatsEndpointSourceContract(): void { + const dockerfileText = readFileSync(rootPath(dockerfile), "utf8"); + const rustSource = readFileSync(rootPath("src/components/microservices/code-queue-mgr/src-rs/main.rs"), "utf8"); + const statsRouteIndex = rustSource.indexOf('(Method::Get, "/api/tasks/stats")'); + const statsRouteSnippet = statsRouteIndex >= 0 ? rustSource.slice(statsRouteIndex, statsRouteIndex + 900) : ""; + + assertCondition(dockerfileText.includes("COPY src/components/microservices/code-queue-mgr/src-rs ./src-rs"), "code-queue-mgr artifact must build the Rust runtime source", { dockerfile }); + assertCondition(dockerfileText.includes('CMD ["code-queue-mgr"]'), "code-queue-mgr artifact must run the Rust binary", { dockerfile }); + assertCondition(statsRouteIndex >= 0, "Rust mgr must expose /api/tasks/stats", { path: "src-rs/main.rs" }); + assertCondition(statsRouteSnippet.includes("task_statistics_summary"), "Rust mgr stats route must use task_statistics_summary", { statsRouteSnippet }); + assertCondition(rustSource.includes("stats_summary_contract_exposes_daily_buckets_and_totals"), "Rust mgr must keep the daily bucket stats unit contract", { path: "src-rs/main.rs" }); + assertCondition(!statsRouteSnippet.includes("skipped"), "Rust mgr stats route must not ship skipped statistics", { statsRouteSnippet }); +} + +function assertCommonDryRun(plan: JsonRecord, deployRef: string): void { + const source = asRecord(plan.source, "dry-run source"); + const registry = asRecord(plan.registry, "dry-run registry"); + const build = asRecord(plan.build, "dry-run build"); + const labels = asRecord(plan.requiredLabels, "dry-run labels"); + const probe = asRecord(plan.registryProbe, "dry-run registryProbe"); + const target = asRecord(plan.target, "dry-run target"); + const liveApply = asRecord(plan.liveApply, "dry-run liveApply"); + const validation = strings(plan.validation, "dry-run validation"); + const excludedTargets = asArray(plan.excludedTargets, "dry-run excludedTargets").map((item, index) => asRecord(item, `excludedTargets[${index}]`)); + const excludedText = JSON.stringify(excludedTargets); + + assertCondition(plan.ok === true && plan.supported === true, "code-queue-mgr dry-run must be supported", plan); + assertCondition(plan.dryRun === true && plan.mutation === false, "code-queue-mgr dry-run must be non-mutating", plan); + assertCondition(plan.environment === "prod", "code-queue-mgr dry-run must target prod", plan); + assertCondition(plan.providerId === "D601", "code-queue-mgr dry-run provider mismatch", plan); + assertCondition(plan.serviceId === serviceId, "code-queue-mgr dry-run service mismatch", plan); + assertCondition(plan.commit === commit, "code-queue-mgr dry-run commit mismatch", plan); + assertCondition(plan.sourceRepo === repo, "code-queue-mgr dry-run source repo mismatch", plan); + assertCondition(plan.deployRef === deployRef, "code-queue-mgr dry-run deployRef mismatch", plan); + assertCondition(plan.sourceImage === imageRef, "code-queue-mgr dry-run source image mismatch", plan); + + assertCondition(source.repo === repo && source.commit === commit && source.dockerfile === dockerfile, "code-queue-mgr source boundary mismatch", source); + assertCondition(registry.imageRef === imageRef, "code-queue-mgr registry image mismatch", registry); + assertCondition(registry.digest === null, "code-queue-mgr dry-run must not fake registry digest", registry); + assertCondition(String(registry.digestSource ?? "").includes("live apply must read this digest"), "code-queue-mgr digest source must name live registry HEAD", registry); + assertCondition(probe.method === "HEAD", "code-queue-mgr registry probe must be HEAD-only", probe); + + assertCondition(build.willCompile === false, "code-queue-mgr CD must not compile", build); + assertCondition(build.willRunCargoBuild === false, "code-queue-mgr CD must not run cargo build", build); + assertCondition(build.willRunDockerBuild === false, "code-queue-mgr CD must not run docker build", build); + assertCondition(build.willRunDockerComposeBuild === false, "code-queue-mgr CD must not run docker compose build", build); + assertCondition(build.producerBoundary === "ci publish-user-service", "code-queue-mgr producer boundary mismatch", build); + + assertCondition(labels["unidesk.ai/service-id"] === serviceId, "code-queue-mgr label service mismatch", labels); + assertCondition(labels["unidesk.ai/source-repo"] === repo, "code-queue-mgr label repo mismatch", labels); + assertCondition(labels["unidesk.ai/source-commit"] === commit, "code-queue-mgr label commit mismatch", labels); + assertCondition(labels["unidesk.ai/dockerfile"] === dockerfile, "code-queue-mgr label Dockerfile mismatch", labels); + + assertCondition(target.kind === "compose", "code-queue-mgr target must be main-server Compose", target); + assertCondition(target.runtimeHost === "main-server", "code-queue-mgr runtime host mismatch", target); + assertCondition(target.composeService === "code-queue-mgr", "code-queue-mgr Compose service mismatch", target); + assertCondition(target.containerName === "code-queue-mgr-backend", "code-queue-mgr container mismatch", target); + assertCondition(target.targetImage === "code-queue-mgr", "code-queue-mgr target image mismatch", target); + assertCondition(target.runtimeImage === `code-queue-mgr:${commit}`, "code-queue-mgr runtime image mismatch", target); + assertCondition(target.deployEnvPrefix === "UNIDESK_CODE_QUEUE_MGR_DEPLOY", "code-queue-mgr deploy env prefix mismatch", target); + assertCondition(target.deployCommandShape === "docker compose up -d --no-build --no-deps --force-recreate code-queue-mgr", "code-queue-mgr deploy command must be single-service no-build/no-deps recreate", target); + + assertCondition(liveApply.policy === "supervisor-only", "code-queue-mgr prod live apply must be supervisor-only", liveApply); + assertCondition(liveApply.allowed === false, "code-queue-mgr prod live apply must be blocked in automation", liveApply); + assertCondition(String(liveApply.reason ?? "").includes("explicit supervisor confirmation"), "code-queue-mgr live apply reason must name supervisor confirmation", liveApply); + assertCondition(validation.some((line) => line.includes("deploy.commit/deploy.requestedCommit")), "code-queue-mgr validation must require health deploy commit metadata", validation); + assertCondition(excludedText.includes("code-queue"), "code-queue-mgr excluded targets must include code-queue", excludedTargets); + assertCondition(excludedText.includes("scheduler"), "code-queue-mgr excluded targets must mention scheduler", excludedTargets); + assertCondition(excludedText.includes("runner"), "code-queue-mgr excluded targets must mention runner", excludedTargets); + assertCondition(excludedText.includes("tasks"), "code-queue-mgr excluded targets must mention tasks", excludedTargets); + assertCondition(excludedText.includes("interrupts"), "code-queue-mgr excluded targets must mention interrupts", excludedTargets); + assertCondition(excludedText.includes("cancellations"), "code-queue-mgr excluded targets must mention cancellations", excludedTargets); + assertCondition(!JSON.stringify(plan).includes("server rebuild"), "code-queue-mgr dry-run must not mention server rebuild", plan); + assertCondition(!JSON.stringify(plan).includes("docker compose build"), "code-queue-mgr dry-run must not mention compose build", plan); +} + +function deployApplyDryRun(): JsonRecord { + const result = spawnSync("bun", ["scripts/cli.ts", "deploy", "apply", "--env", "prod", "--service", serviceId, "--dry-run"], { + cwd: process.cwd(), + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }); + assertCondition(result.status === 0, "deploy apply dry-run should exit 0 for prod code-queue-mgr", { + status: result.status, + stdoutTail: result.stdout.slice(-2000), + stderrTail: result.stderr.slice(-2000), + }); + const envelope = asRecord(JSON.parse(result.stdout) as unknown, "deploy apply envelope"); + assertCondition(envelope.ok === true, "deploy apply envelope must be ok", envelope); + const data = asRecord(envelope.data, "deploy apply data"); + const results = asArray(data.results, "deploy apply results"); + assertCondition(data.action === "apply", "deploy apply dry-run action mismatch", data); + assertCondition(data.environment === "prod", "deploy apply dry-run environment mismatch", data); + assertCondition(data.executor === "d601-registry-artifact-consumer", "deploy apply dry-run executor mismatch", data); + assertCondition(data.dryRun === true, "deploy apply dry-run must report dryRun=true", data); + assertCondition(results.length === 1, "deploy apply dry-run must return exactly one service result", data); + return asRecord(results[0], "deploy apply code-queue-mgr result"); +} + +async function main(): Promise { + assertDesiredStateAndProducer(); + assertStatsEndpointSourceContract(); + + const artifactDryRun = asRecord(await runArtifactRegistryCommand([ + "deploy-service", + "--env", + "prod", + "--service", + serviceId, + "--commit", + commit, + "--dry-run", + ]), "artifact-registry dry-run"); + assertCommonDryRun(artifactDryRun, "deploy.json#environments.prod.services.code-queue-mgr"); + + const deployDryRun = deployApplyDryRun(); + assertCommonDryRun(deployDryRun, "origin/master:deploy.json#environments.prod.services.code-queue-mgr"); + + process.stdout.write(`${JSON.stringify({ + ok: true, + checks: [ + "prod deploy.json pins code-queue-mgr to the stats endpoint commit", + "CI.json publishes code-queue-mgr through ci publish-user-service", + "Dockerfile builds and runs the Rust mgr that exposes /api/tasks/stats without skipped=true", + "artifact-registry prod dry-run is non-mutating and single-service Compose only", + "deploy apply prod dry-run is non-mutating, supervisor-only, and excludes scheduler/runner/tasks/interrupt/cancel", + ], + serviceId, + commit, + artifact: imageRef, + target: asRecord(deployDryRun.target, "deploy dry-run target"), + liveApply: asRecord(deployDryRun.liveApply, "deploy dry-run liveApply"), + }, null, 2)}\n`); +} + +if (import.meta.main) { + await main(); +}