From 9f166d0580f6fe89b14bfc0f3369fb3255f2d4d1 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 12:22:48 +0000 Subject: [PATCH] test: cover issue 9 deploy apply dry-run gaps --- .../issue-9-k3s-user-service-cicd-state.md | 9 +- ...vice-deploy-apply-dry-run-contract-test.ts | 245 ++++++++++++++++++ 2 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 scripts/issue-9-user-service-deploy-apply-dry-run-contract-test.ts diff --git a/docs/issue/issue-9-k3s-user-service-cicd-state.md b/docs/issue/issue-9-k3s-user-service-cicd-state.md index 8a850434..50d4ce8d 100644 --- a/docs/issue/issue-9-k3s-user-service-cicd-state.md +++ b/docs/issue/issue-9-k3s-user-service-cicd-state.md @@ -9,12 +9,12 @@ This matrix closes the current review pass for the `decision-center`, `mdtodo`, | Area | State | Impact | Next step | | --- | --- | --- | --- | | Artifact producer catalog | `CI.json` has source-build producer entries for the reviewed services, including `frontend`. | The standard producer route is modeled for the lane. | Keep producer commands as the only source-build entrypoint: `ci publish-user-service --service --commit `. | -| CD consumer plan | `deploy plan --env dev|prod --service ` resolves standard consumers: k3s-managed for `decision-center`, `mdtodo`, `claudeqq` and dev `frontend`; main-server Compose artifact consumer for `todo-note` and prod `frontend`. | The dry-run contract exists, but live apply still depends on artifact and verification readiness. | Use dev -> focused smoke -> prod only after the desired artifact exists and the preflight channel is healthy. | +| CD consumer plan | `deploy plan --env dev|prod --service ` and `deploy apply --env dev|prod --service --dry-run` resolve standard consumers: k3s-managed for `decision-center`, `mdtodo`, `claudeqq` and dev `frontend`; main-server Compose artifact consumer for `todo-note` and prod `frontend`. | The dry-run contract exists on both entrypoints, but live apply still depends on artifact and verification readiness. | Use dev -> focused smoke -> prod only after the desired artifact exists and the preflight channel is healthy. | | Artifact-registry probe | Remote frontend Host SSH probing is no longer blocked by command length. Registry runtime API is reachable and frontend publish dry-run can report ready, while `artifact-registry health` still reports service drift in rendered config / registry image scopes. | Runtime artifact reads are usable for contract evidence; registry service drift remains an infra hygiene item and should not be confused with a frontend publish blocker. | Repair registry rendered-config/image drift separately before treating registry health as fully clean. | | Project-manager registry probe | `deploy.json` wants `0c3cdb4ee06a23361ed511a2da033d67b53d16f4`; `config.json` still records runtime commit `a278de032d5cdb91010466ac1e2183c79026550d`; remote registry HEAD for `127.0.0.1:5000/unidesk/project-manager:0c3cdb4ee06a23361ed511a2da033d67b53d16f4` returned 404, so no digest is available yet. | The producer and consumer contract is wired, but the desired artifact is still missing. | Publish the desired artifact before any live dev or prod apply. | | Dev service catalog | `decision-center-dev` exists in k3s, while `mdtodo-dev` and `claudeqq-dev` do not. The adapter public catalog did not expose the dev aliases during this pass. | Dev promotion cannot be claimed for `mdtodo` or `claudeqq`; `decision-center-dev` is reachable through direct k3s service proxy only. | Publish artifacts first, then create/verify dev consumers and register dev aliases where intended. | | Frontend closure contract | `bun scripts/frontend-artifact-lane-contract-test.ts` is the standard lightweight contract for the frontend artifact sample. | Frontend lane evidence is now repeatable without live deploy, publish, service restart, or Playwright. | Keep the contract green whenever frontend deploy.json, health metadata, artifact digest, producer preflight, or CD dry-run shape changes. | -| User-service gap contract | `bun scripts/issue-9-user-service-artifact-gap-contract-test.ts` normalizes `mdtodo`, `claudeqq` and `todo-note` with `desiredCommit`, `runtimeCommit`, `artifactExists`, `devStatus`, `prodStatus`, `blockedScopes` and `recommendedAction`. | The remaining gap surface is repeatable from `deploy.json`, `CI.json` and local artifact-registry dry-runs without publish, deploy, service restart, or full check/e2e. | Keep the contract green when desired commits, producer metadata, consumer targets or next-hop classifications change. | +| User-service gap contract | `bun scripts/issue-9-user-service-artifact-gap-contract-test.ts` normalizes `mdtodo`, `claudeqq` and `todo-note` with `desiredCommit`, `runtimeCommit`, `artifactExists`, `devStatus`, `prodStatus`, `blockedScopes` and `recommendedAction`; `bun scripts/issue-9-user-service-deploy-apply-dry-run-contract-test.ts` covers the top-level `deploy apply --env dev|prod --service --dry-run` envelope for the same three services. | The remaining gap surface is repeatable from `deploy.json`, `CI.json`, `deploy apply --dry-run` and local artifact-registry dry-runs without publish, deploy, service restart, or full check/e2e. | Keep both contracts green when desired commits, producer metadata, consumer targets or next-hop classifications change. | ## Service Matrix @@ -33,7 +33,7 @@ Focused read-only evidence for this refresh: - remote `microservice status` and `microservice health` for `mdtodo`, `claudeqq` and `todo-note`; - D601 registry v2 manifest `HEAD` against each desired tag, all returning 404; -- `deploy plan --env dev|prod --service ` and `artifact-registry deploy-service --env dev|prod --service --commit --dry-run`, all resolving no-build artifact consumers. +- `deploy plan --env dev|prod --service `, `deploy apply --env dev|prod --service --dry-run` and `artifact-registry deploy-service --env dev|prod --service --commit --dry-run`, all resolving no-build artifact consumers. | Service | desiredCommit | runtimeCommit | artifactExists | devStatus | prodStatus | blockedScopes | recommendedAction | | --- | --- | --- | --- | --- | --- | --- | --- | @@ -41,10 +41,11 @@ Focused read-only evidence for this refresh: | `claudeqq` | `203b1f46684c91340ecbbd8a74502bd55e4f2011` | `203b1f46684c91340ecbbd8a74502bd55e4f2011` from prod `/health.deploy.commit` and `/health.deploy.requestedCommit` | `false` | `missing-dev-service` | `healthy-prod-health-aligned-event-api-unverified` | `registry-artifact`, `dev-service`, `event-api-surface` | Publish the desired artifact, create/verify `unidesk-dev/claudeqq-dev`, then resolve or document the event API paths before prod artifact replacement. | | `todo-note` | `a14ce0eb855a685fa17b47adacd54623e72cd2ff` | `null`; prod health and container labels do not expose source commit | `false` | `consumer-plan-only-no-live-dev` | `healthy-behavior-no-commit-proof` | `registry-artifact`, `runtime-commit-proof`, `health-deploy-metadata` | Publish the desired artifact, then use the no-build Compose artifact consumer to recreate only `todo-note` and verify image labels plus health deploy metadata. | -Repeatable contract: +Repeatable contracts: ```bash bun scripts/issue-9-user-service-artifact-gap-contract-test.ts +bun scripts/issue-9-user-service-deploy-apply-dry-run-contract-test.ts ``` ## Execution Decision diff --git a/scripts/issue-9-user-service-deploy-apply-dry-run-contract-test.ts b/scripts/issue-9-user-service-deploy-apply-dry-run-contract-test.ts new file mode 100644 index 00000000..d2d4a452 --- /dev/null +++ b/scripts/issue-9-user-service-deploy-apply-dry-run-contract-test.ts @@ -0,0 +1,245 @@ +import { spawnSync } from "node:child_process"; + +type JsonRecord = Record; +type Environment = "dev" | "prod"; + +type ServiceCase = + | { + serviceId: "mdtodo"; + environment: "dev"; + commit: "75fb6757b2504ba86d61f2587fb34a9c9ed4019a"; + sourceRepo: "https://github.com/pikasTech/unidesk"; + dockerfile: "src/components/microservices/mdtodo/Dockerfile"; + targetKind: "d601-k3s"; + namespace: "unidesk-dev"; + deployment: "mdtodo-dev"; + service: "mdtodo-dev"; + runtimeImage: "unidesk-mdtodo:75fb6757b2504ba86d61f2587fb34a9c9ed4019a"; + expectedValidationSnippets: string[]; + rollbackType: "d601-k3s-previous-commit"; + } + | { + serviceId: "claudeqq"; + environment: "dev"; + commit: "203b1f46684c91340ecbbd8a74502bd55e4f2011"; + sourceRepo: "https://gitee.com/lyon1998/agent_skills"; + dockerfile: "claudeqq/Dockerfile"; + targetKind: "d601-k3s"; + namespace: "unidesk-dev"; + deployment: "claudeqq-dev"; + service: "claudeqq-dev"; + runtimeImage: "unidesk-claudeqq:203b1f46684c91340ecbbd8a74502bd55e4f2011"; + expectedValidationSnippets: string[]; + rollbackType: "d601-k3s-previous-commit"; + } + | { + serviceId: "todo-note"; + environment: "prod"; + commit: "a14ce0eb855a685fa17b47adacd54623e72cd2ff"; + sourceRepo: "https://gitee.com/Lyon1998/todo_note"; + dockerfile: "Dockerfile"; + targetKind: "compose"; + runtimeHost: "main-server"; + composeService: "todo-note"; + containerName: "todo-note-backend"; + runtimeImage: "todo-note:a14ce0eb855a685fa17b47adacd54623e72cd2ff"; + deployEnvPrefix: "UNIDESK_TODO_NOTE_DEPLOY"; + expectedValidationSnippets: string[]; + rollbackType: "compose-retag-recreate"; + }; + +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 runDeployApplyDryRun(environment: Environment, serviceId: string): JsonRecord { + const result = spawnSync("bun", ["scripts/cli.ts", "deploy", "apply", "--env", environment, "--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 ${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 apply dry-run envelope should be ok for ${environment}/${serviceId}`, envelope); + const data = asRecord(envelope.data, "deploy apply dry-run data"); + assertCondition(data.action === "apply", `deploy apply dry-run should report action=apply for ${environment}/${serviceId}`, data); + assertCondition(data.environment === environment, `deploy apply dry-run environment mismatch for ${environment}/${serviceId}`, data); + assertCondition(data.executor === "d601-registry-artifact-consumer", `deploy apply dry-run executor mismatch for ${environment}/${serviceId}`, data); + assertCondition(data.dryRun === true, `deploy apply dry-run should set dryRun=true for ${environment}/${serviceId}`, data); + const results = asArray(data.results, `deploy apply dry-run results for ${environment}/${serviceId}`); + assertCondition(results.length === 1, `deploy apply dry-run should return one service result for ${environment}/${serviceId}`, data); + return asRecord(results[0], `deploy apply dry-run service result for ${environment}/${serviceId}`); +} + +function assertK3sTarget(result: JsonRecord, item: Extract): void { + const target = asRecord(result.target, `${item.serviceId} target`); + const deployments = asArray(target.deployments, `${item.serviceId} deployments`).map((deployment, index) => asRecord(deployment, `${item.serviceId} deployment ${index}`)); + assertCondition(target.kind === "d601-k3s", `${item.serviceId} target kind mismatch`, target); + assertCondition(target.namespace === item.namespace, `${item.serviceId} namespace mismatch`, target); + assertCondition(target.deployment === item.deployment, `${item.serviceId} deployment mismatch`, target); + assertCondition(target.service === item.service, `${item.serviceId} service mismatch`, target); + assertCondition(target.runtimeImage === item.runtimeImage, `${item.serviceId} runtime image mismatch`, target); + assertCondition(target.stableImage === `unidesk-${item.serviceId}:dev`, `${item.serviceId} stable image mismatch`, target); + assertCondition(String(target.deployCommandShape ?? "").includes("kubectl set image"), `${item.serviceId} deploy command shape must be k3s`, target); + assertCondition(deployments.length === 1 && deployments[0]?.name === item.deployment, `${item.serviceId} deployment list mismatch`, deployments); + assertCondition(deployments[0]?.containerName === item.serviceId, `${item.serviceId} deployment container mismatch`, deployments); +} + +function assertComposeTarget(result: JsonRecord, item: Extract): void { + const target = asRecord(result.target, `${item.serviceId} target`); + assertCondition(target.kind === "compose", `${item.serviceId} target kind mismatch`, target); + assertCondition(target.runtimeHost === item.runtimeHost, `${item.serviceId} runtime host mismatch`, target); + assertCondition(target.composeService === item.composeService, `${item.serviceId} compose service mismatch`, target); + assertCondition(target.containerName === item.containerName, `${item.serviceId} container mismatch`, target); + assertCondition(target.targetImage === item.composeService, `${item.serviceId} target image mismatch`, target); + assertCondition(target.runtimeImage === item.runtimeImage, `${item.serviceId} runtime image mismatch`, target); + assertCondition(target.deployEnvPrefix === item.deployEnvPrefix, `${item.serviceId} deploy env prefix mismatch`, target); + assertCondition(target.deployCommandShape === "docker compose up -d --no-build --no-deps --force-recreate todo-note", `${item.serviceId} deploy command shape mismatch`, target); +} + +function assertCommonContract(result: JsonRecord, item: ServiceCase): void { + const source = asRecord(result.source, `${item.serviceId} source`); + const registry = asRecord(result.registry, `${item.serviceId} registry`); + const build = asRecord(result.build, `${item.serviceId} build`); + const labels = asRecord(result.requiredLabels, `${item.serviceId} requiredLabels`); + const registryProbe = asRecord(result.registryProbe, `${item.serviceId} registryProbe`); + const validation = asArray(result.validation, `${item.serviceId} validation`).map(String); + const liveApply = asRecord(result.liveApply, `${item.serviceId} liveApply`); + const rollback = asRecord(result.rollback, `${item.serviceId} rollback`); + + assertCondition(result.ok === true, `${item.serviceId} dry-run service result must be ok`, result); + assertCondition(result.supported === true, `${item.serviceId} dry-run service result must be supported`, result); + assertCondition(result.dryRun === true && result.mutation === false, `${item.serviceId} dry-run must be non-mutating`, result); + assertCondition(result.environment === item.environment, `${item.serviceId} environment mismatch`, result); + assertCondition(result.providerId === "D601", `${item.serviceId} provider mismatch`, result); + assertCondition(result.serviceId === item.serviceId, `${item.serviceId} service id mismatch`, result); + assertCondition(result.commit === item.commit, `${item.serviceId} commit mismatch`, result); + assertCondition(result.sourceRepo === item.sourceRepo, `${item.serviceId} source repo mismatch`, result); + assertCondition(result.deployRef === `origin/master:deploy.json#environments.${item.environment}.services.${item.serviceId}`, `${item.serviceId} deployRef mismatch`, result); + assertCondition(result.sourceImage === `127.0.0.1:5000/unidesk/${item.serviceId}:${item.commit}`, `${item.serviceId} source image mismatch`, result); + assertCondition(source.repo === item.sourceRepo, `${item.serviceId} source.repo mismatch`, source); + assertCondition(source.commit === item.commit, `${item.serviceId} source.commit mismatch`, source); + assertCondition(source.dockerfile === item.dockerfile, `${item.serviceId} source.dockerfile mismatch`, source); + assertCondition(registry.endpoint === "http://127.0.0.1:5000", `${item.serviceId} registry endpoint mismatch`, registry); + assertCondition(registry.repository === `unidesk/${item.serviceId}`, `${item.serviceId} registry repository mismatch`, registry); + assertCondition(registry.tag === item.commit, `${item.serviceId} registry tag mismatch`, registry); + assertCondition(registry.imageRef === `127.0.0.1:5000/unidesk/${item.serviceId}:${item.commit}`, `${item.serviceId} registry imageRef mismatch`, registry); + assertCondition(registry.digest === null, `${item.serviceId} dry-run must not fake a digest`, registry); + assertCondition(String(registry.digestSource ?? "").includes("live apply must read this digest"), `${item.serviceId} digest source mismatch`, registry); + assertCondition(build.willCompile === false, `${item.serviceId} dry-run must not compile`, build); + assertCondition(build.willRunCargoBuild === false, `${item.serviceId} dry-run must not run cargo build`, build); + assertCondition(build.willRunDockerBuild === false, `${item.serviceId} dry-run must not run docker build`, build); + assertCondition(build.willRunDockerComposeBuild === false, `${item.serviceId} dry-run must not run docker compose build`, build); + assertCondition(build.producerBoundary === "ci publish-user-service", `${item.serviceId} producer boundary mismatch`, build); + assertCondition(labels["unidesk.ai/service-id"] === item.serviceId, `${item.serviceId} service label mismatch`, labels); + assertCondition(labels["unidesk.ai/source-commit"] === item.commit, `${item.serviceId} commit label mismatch`, labels); + assertCondition(labels["unidesk.ai/dockerfile"] === item.dockerfile, `${item.serviceId} dockerfile label mismatch`, labels); + assertCondition(registryProbe.method === "HEAD", `${item.serviceId} registry probe must be HEAD`, registryProbe); + assertCondition(String(registryProbe.url ?? "").includes(`/v2/unidesk/${item.serviceId}/manifests/${item.commit}`), `${item.serviceId} registry probe url mismatch`, registryProbe); + assertCondition(String(result.boundary ?? "").includes("artifact-consumer only"), `${item.serviceId} boundary should say artifact-consumer only`, result); + assertCondition(String(result.boundary ?? "").includes("never builds source on the runtime target"), `${item.serviceId} boundary should forbid target builds`, result); + assertCondition(liveApply.allowed === true, `${item.serviceId} dry-run should remain live-apply eligible`, liveApply); + assertCondition(liveApply.reason === null, `${item.serviceId} dry-run liveApply reason should be null`, liveApply); + assertCondition(rollback.type === item.rollbackType, `${item.serviceId} rollback type mismatch`, rollback); + assertCondition(String(rollback.commandShape ?? "").includes(""), `${item.serviceId} rollback command should keep previous-full-sha placeholder`, rollback); + + for (const snippet of item.expectedValidationSnippets) { + assertCondition(validation.some((line) => line.includes(snippet)), `${item.serviceId} validation should include ${snippet}`, validation); + } +} + +const cases: ServiceCase[] = [ + { + serviceId: "mdtodo", + environment: "dev", + commit: "75fb6757b2504ba86d61f2587fb34a9c9ed4019a", + sourceRepo: "https://github.com/pikasTech/unidesk", + dockerfile: "src/components/microservices/mdtodo/Dockerfile", + targetKind: "d601-k3s", + namespace: "unidesk-dev", + deployment: "mdtodo-dev", + service: "mdtodo-dev", + runtimeImage: "unidesk-mdtodo:75fb6757b2504ba86d61f2587fb34a9c9ed4019a", + expectedValidationSnippets: [ + "D601 registry /v2 manifest exists", + "native k3s containerd has the commit image and stable runtime image tag", + "service health via Kubernetes API service proxy returns the same deploy.commit and deploy.requestedCommit", + ], + rollbackType: "d601-k3s-previous-commit", + }, + { + serviceId: "claudeqq", + environment: "dev", + commit: "203b1f46684c91340ecbbd8a74502bd55e4f2011", + sourceRepo: "https://gitee.com/lyon1998/agent_skills", + dockerfile: "claudeqq/Dockerfile", + targetKind: "d601-k3s", + namespace: "unidesk-dev", + deployment: "claudeqq-dev", + service: "claudeqq-dev", + runtimeImage: "unidesk-claudeqq:203b1f46684c91340ecbbd8a74502bd55e4f2011", + expectedValidationSnippets: [ + "D601 registry /v2 manifest exists", + "native k3s containerd has the commit image and stable runtime image tag", + "service health via Kubernetes API service proxy returns the same deploy.commit and deploy.requestedCommit", + ], + rollbackType: "d601-k3s-previous-commit", + }, + { + serviceId: "todo-note", + environment: "prod", + commit: "a14ce0eb855a685fa17b47adacd54623e72cd2ff", + sourceRepo: "https://gitee.com/Lyon1998/todo_note", + dockerfile: "Dockerfile", + targetKind: "compose", + runtimeHost: "main-server", + composeService: "todo-note", + containerName: "todo-note-backend", + runtimeImage: "todo-note:a14ce0eb855a685fa17b47adacd54623e72cd2ff", + deployEnvPrefix: "UNIDESK_TODO_NOTE_DEPLOY", + expectedValidationSnippets: [ + "D601 registry /v2 manifest exists for the commit tag", + "running Compose container image label matches the requested commit", + "todo-note runtime health probe succeeds and reports deploy.commit/deploy.requestedCommit matching the artifact commit", + ], + rollbackType: "compose-retag-recreate", + }, +]; + +for (const item of cases) { + const result = runDeployApplyDryRun(item.environment, item.serviceId); + if (item.targetKind === "d601-k3s") assertK3sTarget(result, item); + else assertComposeTarget(result, item); + assertCommonContract(result, item); +} + +process.stdout.write(`${JSON.stringify({ + ok: true, + checks: [ + "deploy apply --env dev --service mdtodo --dry-run remains a non-mutating D601 k3s artifact consumer", + "deploy apply --env dev --service claudeqq --dry-run remains a non-mutating D601 k3s artifact consumer", + "deploy apply --env prod --service todo-note --dry-run remains a non-mutating main-server Compose artifact consumer", + "dry-run output keeps registry/source/build boundaries and live-apply eligibility explicit", + "dry-run output keeps rollback hints and health validation snippets intact", + ], + services: cases.map((service) => ({ + serviceId: service.serviceId, + environment: service.environment, + commit: service.commit, + targetKind: service.targetKind, + })), +}, null, 2)}\n`);