test: cover code queue mgr prod artifact readiness

This commit is contained in:
Codex
2026-05-21 12:54:42 +00:00
parent afe50b9e8f
commit b334430c00
2 changed files with 208 additions and 0 deletions
+2
View File
@@ -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 <full-sha> --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:<commit>`、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:<commit>`,导入 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:<commit>` 流式拉到 master serverretag 为 `baidu-netdisk``baidu-netdisk:<commit>`,在 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/booleandry-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 <service>` 重新拉起对应 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:<commit>`retag 为 `unidesk-frontend``unidesk-frontend:<commit>`,写入 `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 commitdev 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 Deploymentsprod 没有 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` 通过:
@@ -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<string, unknown>;
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<void> {
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();
}