diff --git a/docker-compose.yml b/docker-compose.yml index 15901580..a2b140a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -166,10 +166,17 @@ services: retries: 20 todo-note: + image: todo-note build: context: /root/todo_note dockerfile: Dockerfile container_name: todo-note-backend + labels: + unidesk.ai/deploy-service-id: "${UNIDESK_TODO_NOTE_DEPLOY_SERVICE_ID:-todo-note}" + unidesk.ai/deploy-ref: "${UNIDESK_TODO_NOTE_DEPLOY_REF:-deploy.json#environments.prod.services.todo-note}" + unidesk.ai/deploy-repo: "${UNIDESK_TODO_NOTE_DEPLOY_REPO:-}" + unidesk.ai/deploy-commit: "${UNIDESK_TODO_NOTE_DEPLOY_COMMIT:-}" + unidesk.ai/deploy-requested-commit: "${UNIDESK_TODO_NOTE_DEPLOY_REQUESTED_COMMIT:-}" restart: unless-stopped depends_on: - database diff --git a/scripts/artifact-consumer-dry-run-matrix-test.ts b/scripts/artifact-consumer-dry-run-matrix-test.ts index 47071428..1c4351d9 100644 --- a/scripts/artifact-consumer-dry-run-matrix-test.ts +++ b/scripts/artifact-consumer-dry-run-matrix-test.ts @@ -167,8 +167,18 @@ for (const item of serviceCases) { } assertCondition(validation.some((line) => line.includes("registry /v2 manifest")), `${item.serviceId} must plan registry manifest validation`, validation); - assertCondition(validation.some((line) => line.includes("image labels match service id, source commit, and Dockerfile")), `${item.serviceId} must plan image label validation`, validation); - assertCondition(validation.some((line) => line.includes("health probe succeeds") && line.includes("deploy.commit/deploy.requestedCommit")), `${item.serviceId} must plan health commit validation`, validation); + assertCondition(validation.some((line) => line.includes("image labels match service id, source repo, source commit, and Dockerfile")), `${item.serviceId} must plan image label validation`, validation); + if (item.serviceId === "todo-note") { + const runtimeProof = asRecord(plan.runtimeProof, "todo-note runtimeProof"); + const requiredEnvKeys = asArray(runtimeProof.requiredEnvKeys, "todo-note runtime proof env keys").map(String); + assertCondition(runtimeProof.kind === "compose-container-runtime-metadata", "todo-note runtime proof kind mismatch", runtimeProof); + assertCondition(runtimeProof.sourceDirectoryUsed === false, "todo-note runtime proof must not use source directory guesses", runtimeProof); + assertCondition(requiredEnvKeys.includes("UNIDESK_DEPLOY_COMMIT"), "todo-note runtime proof should use generic deploy commit env", runtimeProof); + assertCondition(requiredEnvKeys.includes("UNIDESK_TODO_NOTE_DEPLOY_COMMIT"), "todo-note runtime proof should use service deploy commit env", runtimeProof); + assertCondition(validation.some((line) => line.includes("Compose container runtime metadata") && line.includes("not source directory guesses")), "todo-note must plan synthetic runtime proof", validation); + } else { + assertCondition(validation.some((line) => line.includes("health probe succeeds") && line.includes("deploy.commit/deploy.requestedCommit")), `${item.serviceId} must plan health commit validation`, validation); + } assertCondition(!JSON.stringify(plan).includes("docker compose build"), `${item.serviceId} dry-run must not include compose build`, plan); assertCondition(!JSON.stringify(plan).includes("server rebuild"), `${item.serviceId} dry-run must not include server rebuild`, plan); diff --git a/scripts/issue-9-user-service-artifact-gap-contract-test.ts b/scripts/issue-9-user-service-artifact-gap-contract-test.ts index 4e5c6e4e..339d15ab 100644 --- a/scripts/issue-9-user-service-artifact-gap-contract-test.ts +++ b/scripts/issue-9-user-service-artifact-gap-contract-test.ts @@ -156,7 +156,9 @@ function assertCommonDryRun(plan: JsonRecord, contract: ServiceContract, environ assertCondition(plan.providerId === "D601", `${contract.serviceId} ${environment} provider mismatch`, plan); assertCondition(plan.serviceId === contract.serviceId, `${contract.serviceId} ${environment} service id mismatch`, plan); assertCondition(plan.commit === contract.desiredCommit, `${contract.serviceId} ${environment} commit mismatch`, plan); - assertCondition(plan.deployRef === `deploy.json#environments.${environment}.services.${contract.serviceId}`, `${contract.serviceId} ${environment} deployRef mismatch`, plan); + const deployRef = `deploy.json#environments.${environment}.services.${contract.serviceId}`; + const allowedDeployRefs = [deployRef, `origin/master:${deployRef}`]; + assertCondition(allowedDeployRefs.includes(String(plan.deployRef)), `${contract.serviceId} ${environment} deployRef mismatch`, plan); assertCondition(plan.sourceImage === `127.0.0.1:5000/${contract.registryRepository}:${contract.desiredCommit}`, `${contract.serviceId} ${environment} source image mismatch`, plan); assertCondition(source.repo === contract.sourceRepo, `${contract.serviceId} ${environment} source repo mismatch`, source); assertCondition(source.commit === contract.desiredCommit, `${contract.serviceId} ${environment} source commit mismatch`, source); @@ -167,6 +169,7 @@ function assertCommonDryRun(plan: JsonRecord, contract: ServiceContract, environ assertCondition(String(registry.digestSource ?? "").includes("live apply must read this digest"), `${contract.serviceId} ${environment} digest source mismatch`, registry); assertCondition(registryProbe.method === "HEAD", `${contract.serviceId} ${environment} registry probe must be HEAD`, registryProbe); assertCondition(labels["unidesk.ai/service-id"] === contract.serviceId, `${contract.serviceId} ${environment} service label mismatch`, labels); + assertCondition(labels["unidesk.ai/source-repo"] === contract.sourceRepo, `${contract.serviceId} ${environment} source repo label mismatch`, labels); assertCondition(labels["unidesk.ai/source-commit"] === contract.desiredCommit, `${contract.serviceId} ${environment} commit label mismatch`, labels); assertCondition(labels["unidesk.ai/dockerfile"] === contract.dockerfile, `${contract.serviceId} ${environment} Dockerfile label mismatch`, labels); assertCondition(build.willCompile === false, `${contract.serviceId} ${environment} CD must not compile`, build); @@ -198,7 +201,13 @@ function assertComposeDryRun(plan: JsonRecord, contract: ServiceContract, enviro assertCondition(target.composeService === "todo-note", `${contract.serviceId} ${environment} compose service mismatch`, target); assertCondition(target.containerName === "todo-note-backend", `${contract.serviceId} ${environment} container mismatch`, target); assertCondition(target.deployCommandShape === "docker compose up -d --no-build --no-deps --force-recreate todo-note", `${contract.serviceId} ${environment} command shape mismatch`, target); - assertCondition(validation.some((line) => line.includes("deploy.commit/deploy.requestedCommit")), `${contract.serviceId} ${environment} validation must require health deploy metadata`, validation); + const runtimeProof = asRecord(plan.runtimeProof, `${contract.serviceId} ${environment} runtimeProof`); + const requiredEnvKeys = asArray(runtimeProof.requiredEnvKeys, `${contract.serviceId} ${environment} runtime proof env keys`).map(String); + assertCondition(runtimeProof.kind === "compose-container-runtime-metadata", `${contract.serviceId} ${environment} runtime proof kind mismatch`, runtimeProof); + assertCondition(runtimeProof.sourceDirectoryUsed === false, `${contract.serviceId} ${environment} runtime proof must not use source directory guesses`, runtimeProof); + assertCondition(requiredEnvKeys.includes("UNIDESK_DEPLOY_COMMIT"), `${contract.serviceId} ${environment} runtime proof must use generic commit env`, runtimeProof); + assertCondition(requiredEnvKeys.includes("UNIDESK_TODO_NOTE_DEPLOY_COMMIT"), `${contract.serviceId} ${environment} runtime proof must use service commit env`, runtimeProof); + assertCondition(validation.some((line) => line.includes("deploy.commit/deploy.requestedCommit") && line.includes("Compose container runtime metadata")), `${contract.serviceId} ${environment} validation must require synthetic health deploy metadata`, validation); } async function assertDryRuns(): Promise { 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 index c59f6eda..cbc2077a 100644 --- 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 @@ -146,6 +146,7 @@ function assertCommonContract(result: JsonRecord, item: ServiceCase): void { 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-repo"] === item.sourceRepo, `${item.serviceId} source repo 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); @@ -160,6 +161,14 @@ function assertCommonContract(result: JsonRecord, item: ServiceCase): void { for (const snippet of item.expectedValidationSnippets) { assertCondition(validation.some((line) => line.includes(snippet)), `${item.serviceId} validation should include ${snippet}`, validation); } + if (item.serviceId === "todo-note") { + const runtimeProof = asRecord(result.runtimeProof, "todo-note runtimeProof"); + const requiredEnvKeys = asArray(runtimeProof.requiredEnvKeys, "todo-note runtime proof env keys").map(String); + assertCondition(runtimeProof.kind === "compose-container-runtime-metadata", "todo-note runtime proof kind mismatch", runtimeProof); + assertCondition(runtimeProof.sourceDirectoryUsed === false, "todo-note runtime proof must not use source directory guesses", runtimeProof); + assertCondition(requiredEnvKeys.includes("UNIDESK_DEPLOY_REQUESTED_COMMIT"), "todo-note proof should use generic requested commit env", runtimeProof); + assertCondition(requiredEnvKeys.includes("UNIDESK_TODO_NOTE_DEPLOY_REQUESTED_COMMIT"), "todo-note proof should use service requested commit env", runtimeProof); + } } const cases: ServiceCase[] = [ @@ -214,7 +223,8 @@ const cases: ServiceCase[] = [ 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", + "todo-note runtime health proof synthesizes deploy.commit/deploy.requestedCommit from Compose container runtime metadata", + "not source directory guesses", ], rollbackType: "compose-retag-recreate", }, diff --git a/scripts/src/artifact-registry.ts b/scripts/src/artifact-registry.ts index 313cc3e5..b9d15459 100644 --- a/scripts/src/artifact-registry.ts +++ b/scripts/src/artifact-registry.ts @@ -159,6 +159,7 @@ interface ArtifactConsumerTarget { projectHint?: string; requiredRuntimeSecrets?: RuntimeSecretRequirement[]; authHealthGate?: AuthHealthGate; + syntheticHealthDeployProof?: SyntheticHealthDeployProof; }; k3s?: { namespace: string; @@ -184,6 +185,11 @@ interface AuthHealthGate { recoveryHint: string; } +interface SyntheticHealthDeployProof { + kind: "compose-container-runtime-metadata"; + description: string; +} + interface ComposeArtifactRuntime { workDir: string; composeFile: string; @@ -230,8 +236,13 @@ export interface RuntimeSecretContract { dryRunDisposition: "not-required" | "ready-for-live-apply" | "secret-source-blocked"; } +const todoNoteSyntheticHealthDeployProof: SyntheticHealthDeployProof = { + kind: "compose-container-runtime-metadata", + description: "todo-note /api/health does not natively expose deploy metadata, so the artifact consumer synthesizes health.deploy from the recreated container's UNIDESK_DEPLOY_* env and verifies it against image/container labels.", +}; + function todoNoteHealthProbeCommand(): string { - return "bun -e \"fetch('http://127.0.0.1:4211/api/health').then(async r=>{const text=await r.text(); let body; try{body=JSON.parse(text)}catch{body={ok:r.ok,raw:text}}; const deploy=body.deploy&&typeof body.deploy==='object'&&!Array.isArray(body.deploy)?body.deploy:{}; body.deploy={...deploy,serviceId:deploy.serviceId||process.env.UNIDESK_DEPLOY_SERVICE_ID||'todo-note',ref:deploy.ref||process.env.UNIDESK_DEPLOY_REF||'',repo:deploy.repo||process.env.UNIDESK_DEPLOY_REPO||'',commit:deploy.commit||process.env.UNIDESK_DEPLOY_COMMIT||'',requestedCommit:deploy.requestedCommit||process.env.UNIDESK_DEPLOY_REQUESTED_COMMIT||''}; console.log(JSON.stringify(body)); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\""; + return "bun -e \"fetch('http://127.0.0.1:4211/api/health').then(async r=>{const text=await r.text(); let body; try{body=JSON.parse(text)}catch{body={ok:r.ok,raw:text}}; console.log(JSON.stringify(body)); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\""; } export const baiduNetdiskRuntimeSecretRequirements: RuntimeSecretRequirement[] = [ @@ -777,6 +788,7 @@ const artifactConsumerSpecs: Record = { deployEnvPrefix: "UNIDESK_TODO_NOTE_DEPLOY", healthProbeCommand: todoNoteHealthProbeCommand(), requireHealthCommit: true, + syntheticHealthDeployProof: todoNoteSyntheticHealthDeployProof, }, }, prod: { @@ -789,6 +801,7 @@ const artifactConsumerSpecs: Record = { deployEnvPrefix: "UNIDESK_TODO_NOTE_DEPLOY", healthProbeCommand: todoNoteHealthProbeCommand(), requireHealthCommit: true, + syntheticHealthDeployProof: todoNoteSyntheticHealthDeployProof, }, }, }, @@ -2166,6 +2179,70 @@ function composeArtifactEnvValues(spec: ArtifactConsumerSpec, target: ArtifactCo }; } +function syntheticComposeHealthDeployScript(compose: NonNullable, commit: string, sourceRepo: string): string[] { + if (compose.syntheticHealthDeployProof === undefined) return []; + const prefix = compose.deployEnvPrefix; + const envKeys = [ + "UNIDESK_DEPLOY_SERVICE_ID", + "UNIDESK_DEPLOY_REF", + "UNIDESK_DEPLOY_REPO", + "UNIDESK_DEPLOY_COMMIT", + "UNIDESK_DEPLOY_REQUESTED_COMMIT", + `${prefix}_SERVICE_ID`, + `${prefix}_REF`, + `${prefix}_REPO`, + `${prefix}_COMMIT`, + `${prefix}_REQUESTED_COMMIT`, + ]; + const envKeysJson = JSON.stringify(envKeys); + return [ + "runtime_metadata_json=$(docker inspect \"$cid\" --format '{{json .Config.Env}}')", + "container_label_commit=$(docker inspect -f '{{ index .Config.Labels \"unidesk.ai/deploy-commit\" }}' \"$cid\")", + "container_label_requested_commit=$(docker inspect -f '{{ index .Config.Labels \"unidesk.ai/deploy-requested-commit\" }}' \"$cid\")", + "container_label_repo=$(docker inspect -f '{{ index .Config.Labels \"unidesk.ai/deploy-repo\" }}' \"$cid\")", + "container_label_ref=$(docker inspect -f '{{ index .Config.Labels \"unidesk.ai/deploy-ref\" }}' \"$cid\")", + `python3 - "$health_json" "$runtime_metadata_json" ${shellQuote(envKeysJson)} ${shellQuote(prefix)} ${shellQuote(commit)} ${shellQuote(sourceRepo)} "$actual_commit" "$actual_service" "$actual_repo" "$container_label_commit" "$container_label_requested_commit" "$container_label_repo" "$container_label_ref" <<'PY'`, + "import json, sys", + "path, env_json, keys_json, prefix, expected_commit, expected_repo, image_commit, image_service, image_repo, label_commit, label_requested_commit, label_repo, label_ref = sys.argv[1:]", + "body = json.load(open(path, encoding='utf-8'))", + "env_items = json.loads(env_json)", + "allow = set(json.loads(keys_json))", + "env = {}", + "for item in env_items:", + " if not isinstance(item, str) or '=' not in item:", + " continue", + " key, value = item.split('=', 1)", + " if key in allow:", + " env[key] = value", + "deploy = body.get('deploy') if isinstance(body, dict) and isinstance(body.get('deploy'), dict) else {}", + "synthetic = {", + " 'serviceId': deploy.get('serviceId') or env.get('UNIDESK_DEPLOY_SERVICE_ID') or env.get(prefix + '_SERVICE_ID') or image_service,", + " 'ref': deploy.get('ref') or env.get('UNIDESK_DEPLOY_REF') or env.get(prefix + '_REF') or label_ref,", + " 'repo': deploy.get('repo') or env.get('UNIDESK_DEPLOY_REPO') or env.get(prefix + '_REPO') or label_repo or image_repo,", + " 'commit': deploy.get('commit') or env.get('UNIDESK_DEPLOY_COMMIT') or env.get(prefix + '_COMMIT') or label_commit or image_commit,", + " 'requestedCommit': deploy.get('requestedCommit') or env.get('UNIDESK_DEPLOY_REQUESTED_COMMIT') or env.get(prefix + '_REQUESTED_COMMIT') or label_requested_commit,", + "}", + "body['deploy'] = synthetic", + "body['deployProof'] = {", + " 'kind': 'compose-container-runtime-metadata',", + " 'sources': ['service-health', 'container-env', 'container-labels', 'image-labels'],", + " 'sourceDirectoryUsed': False,", + " 'envKeysUsed': sorted([key for key in env if key.startswith('UNIDESK_DEPLOY_') or key.startswith(prefix + '_')]),", + "}", + "if synthetic.get('commit') != expected_commit or synthetic.get('requestedCommit') != expected_commit:", + " raise SystemExit('synthetic deploy commit mismatch: ' + json.dumps(synthetic, sort_keys=True))", + "if synthetic.get('repo') != expected_repo:", + " raise SystemExit('synthetic deploy repo mismatch: ' + json.dumps(synthetic, sort_keys=True))", + "if synthetic.get('serviceId') != image_service:", + " raise SystemExit('synthetic deploy service mismatch: ' + json.dumps(synthetic, sort_keys=True))", + "with open(path, 'w', encoding='utf-8') as handle:", + " json.dump(body, handle, separators=(',', ':'))", + " handle.write('\\n')", + "print('synthetic_health_deploy_proof=compose-container-runtime-metadata sourceDirectoryUsed=false')", + "PY", + ]; +} + async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget): Promise> { const commit = options.commit; if (commit === null) throw new Error("artifact-registry deploy-service requires --commit "); @@ -2202,6 +2279,7 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec: return { ok: false, serviceId: spec.serviceId, error: "D601 artifact registry is not healthy", health }; } const sourceImage = artifactImageRef(options, spec, commit); + const sourceRepo = sourceRepoFor(options, spec); const composeImage = options.targetImage ?? target.targetImage; const commitImage = `${composeImage}:${commit}`; const registryProbe = runRemoteScript(options, registryArtifactProbeScript(options, spec, commit), Math.max(options.timeoutMs, 120_000)); @@ -2227,7 +2305,7 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec: }; } const localLoadedImage = sourceImage; - const labelFailure = verifyLocalArtifactLabels(localLoadedImage, spec, commit, sourceRepoFor(options, spec)); + const labelFailure = verifyLocalArtifactLabels(localLoadedImage, spec, commit, sourceRepo); if (labelFailure !== null) return { ...labelFailure, registryProbe: commandTail(registryProbe) }; const tag = runCommand(["docker", "tag", localLoadedImage, composeImage], repoRoot); if (tag.exitCode !== 0 || tag.timedOut) { @@ -2265,9 +2343,14 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec: `cid=$(docker ps -q --filter label=com.docker.compose.project=${shellQuote(composeProject)} --filter label=com.docker.compose.service=${shellQuote(serviceName)} --filter label=com.docker.compose.oneoff=False | head -1)`, "image_id=$(docker inspect -f '{{.Image}}' \"$cid\")", "actual_commit=$(docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}' \"$image_id\")", + "actual_service=$(docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/service-id\" }}' \"$image_id\")", + "actual_repo=$(docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/source-repo\" }}' \"$image_id\")", `test "$actual_commit" = ${shellQuote(commit)}`, + `test "$actual_service" = ${shellQuote(spec.serviceId)}`, + `test "$actual_repo" = ${shellQuote(sourceRepo)}`, `health_json=/tmp/unidesk-${safeName(spec.serviceId)}-health.json`, `docker exec "$cid" sh -lc ${shellQuote(target.compose.healthProbeCommand)} > "$health_json"`, + ...syntheticComposeHealthDeployScript(target.compose, commit, sourceRepo), "cat \"$health_json\"", ...authHealthGateScript(target.compose.authHealthGate, "\"$health_json\""), ...(target.compose.requireHealthCommit ? [ @@ -2312,6 +2395,7 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec: imageLabelCommit: commit, serviceHealthCommit: target.compose.requireHealthCommit ? commit : "not-required", serviceHealthRequestedCommit: target.compose.requireHealthCommit ? commit : "not-required", + runtimeProof: target.compose.syntheticHealthDeployProof === undefined ? "native-health" : target.compose.syntheticHealthDeployProof, requiredRuntimeSecrets: secretPreflight.requirements, runtimeSecrets, authHealthGate: target.compose.authHealthGate === undefined ? "not-required" : { @@ -2652,6 +2736,8 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti ...(spec.kind === "d601-compose" ? ["running Compose container image label matches the requested commit"] : []), verificationBlocked ? `blocked: ${spec.runtimeVerificationBlockReason}` + : target.compose.requireHealthCommit && target.compose.syntheticHealthDeployProof !== undefined + ? `${spec.serviceId} runtime health proof synthesizes deploy.commit/deploy.requestedCommit from Compose container runtime metadata and verifies it against image/container labels, not source directory guesses` : target.compose.requireHealthCommit ? `${spec.serviceId} runtime health probe succeeds and reports deploy.commit/deploy.requestedCommit matching the artifact commit` : `${spec.serviceId} /health succeeds for the recreated container`, @@ -2668,6 +2754,26 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti requiredAuthFields: target.compose.authHealthGate.requiredFields, dryRunDisposition: "pending-live-check", }, + runtimeProof: target.compose.syntheticHealthDeployProof === undefined ? { + kind: "native-health-deploy-metadata", + sourceDirectoryUsed: false, + } : { + ...target.compose.syntheticHealthDeployProof, + sourceDirectoryUsed: false, + sources: ["service-health", "container-env", "container-labels", "image-labels"], + requiredEnvKeys: [ + "UNIDESK_DEPLOY_SERVICE_ID", + "UNIDESK_DEPLOY_REF", + "UNIDESK_DEPLOY_REPO", + "UNIDESK_DEPLOY_COMMIT", + "UNIDESK_DEPLOY_REQUESTED_COMMIT", + `${target.compose.deployEnvPrefix}_SERVICE_ID`, + `${target.compose.deployEnvPrefix}_REF`, + `${target.compose.deployEnvPrefix}_REPO`, + `${target.compose.deployEnvPrefix}_COMMIT`, + `${target.compose.deployEnvPrefix}_REQUESTED_COMMIT`, + ], + }, rollback: rollbackInfo(spec, target, environment, commit), }; } diff --git a/scripts/src/deploy.ts b/scripts/src/deploy.ts index 236ba96b..209b867c 100644 --- a/scripts/src/deploy.ts +++ b/scripts/src/deploy.ts @@ -1076,6 +1076,9 @@ function artifactConsumerPlanValidation(service: UniDeskMicroserviceConfig, envi ...base, "recreates only the selected Compose service with --no-build --no-deps --force-recreate", "verifies running image labels and private service health", + ...(service.id === "todo-note" ? [ + "todo-note runtime proof synthesizes health.deploy.commit/requestedCommit from Compose container env, container labels, and image labels; it does not infer commit from /root/todo_note or any source directory", + ] : []), ]; } return ["unsupported service remains blocked before source materialization or runtime mutation"]; diff --git a/scripts/todo-note-artifact-runtime-proof-contract-test.ts b/scripts/todo-note-artifact-runtime-proof-contract-test.ts new file mode 100644 index 00000000..a7a0f708 --- /dev/null +++ b/scripts/todo-note-artifact-runtime-proof-contract-test.ts @@ -0,0 +1,140 @@ +import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { rootPath } from "./src/config"; +import { runArtifactRegistryCommand } from "./src/artifact-registry"; + +type JsonRecord = Record; + +const serviceId = "todo-note"; +const commit = "a14ce0eb855a685fa17b47adacd54623e72cd2ff"; +const sourceRepo = "https://gitee.com/Lyon1998/todo_note"; + +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(): 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", { + status: result.status, + stdout: result.stdout.slice(-2000), + stderr: result.stderr.slice(-2000), + }); + const envelope = asRecord(JSON.parse(result.stdout) as unknown, "deploy apply envelope"); + assertCondition(envelope.ok === true, "deploy apply dry-run envelope should be ok", envelope); + const data = asRecord(envelope.data, "deploy apply data"); + const results = asArray(data.results, "deploy apply results"); + assertCondition(results.length === 1, "deploy apply dry-run should include one result", data); + return asRecord(results[0], "todo-note deploy apply result"); +} + +function assertTodoNoteComposeYaml(): void { + const compose = readFileSync(rootPath("docker-compose.yml"), "utf8"); + const todoNoteSection = compose.slice(compose.indexOf(" todo-note:"), compose.indexOf(" oa-event-flow:")); + assertCondition(todoNoteSection.includes("image: todo-note"), "todo-note Compose service must name a stable image", todoNoteSection); + for (const expected of [ + "unidesk.ai/deploy-service-id", + "unidesk.ai/deploy-ref", + "unidesk.ai/deploy-repo", + "unidesk.ai/deploy-commit", + "unidesk.ai/deploy-requested-commit", + "UNIDESK_DEPLOY_COMMIT", + "UNIDESK_DEPLOY_REQUESTED_COMMIT", + ]) { + assertCondition(todoNoteSection.includes(expected), `todo-note Compose section must include ${expected}`, todoNoteSection); + } +} + +function assertPlan(plan: JsonRecord, label: string): void { + const target = asRecord(plan.target, `${label} target`); + const source = asRecord(plan.source, `${label} source`); + const build = asRecord(plan.build, `${label} build`); + const labels = asRecord(plan.requiredLabels, `${label} requiredLabels`); + const validation = asArray(plan.validation, `${label} validation`).map(String); + const runtimeProof = asRecord(plan.runtimeProof, `${label} runtimeProof`); + const sources = asArray(runtimeProof.sources, `${label} runtimeProof.sources`).map(String); + const requiredEnvKeys = asArray(runtimeProof.requiredEnvKeys, `${label} runtimeProof.requiredEnvKeys`).map(String); + + assertCondition(plan.ok === true && plan.supported === true, `${label} plan must be supported`, plan); + assertCondition(plan.dryRun === true && plan.mutation === false, `${label} plan must be non-mutating`, plan); + assertCondition(plan.environment === "prod", `${label} environment mismatch`, plan); + assertCondition(plan.serviceId === serviceId, `${label} service id mismatch`, plan); + assertCondition(plan.commit === commit, `${label} commit mismatch`, plan); + assertCondition(plan.sourceRepo === sourceRepo, `${label} source repo mismatch`, plan); + assertCondition(source.repo === sourceRepo && source.commit === commit && source.dockerfile === "Dockerfile", `${label} source mismatch`, source); + assertCondition(build.willCompile === false, `${label} dry-run must not compile`, build); + assertCondition(build.willRunDockerBuild === false, `${label} dry-run must not build Docker images`, build); + assertCondition(build.willRunDockerComposeBuild === false, `${label} dry-run must not run docker compose build`, build); + assertCondition(target.kind === "compose", `${label} target kind mismatch`, target); + assertCondition(target.runtimeHost === "main-server", `${label} runtime host mismatch`, target); + assertCondition(target.composeService === serviceId, `${label} compose service mismatch`, target); + assertCondition(target.containerName === "todo-note-backend", `${label} container mismatch`, target); + assertCondition(target.targetImage === "todo-note", `${label} target image mismatch`, target); + assertCondition(target.runtimeImage === `todo-note:${commit}`, `${label} runtime image mismatch`, target); + assertCondition(target.deployCommandShape === "docker compose up -d --no-build --no-deps --force-recreate todo-note", `${label} command shape mismatch`, target); + assertCondition(labels["unidesk.ai/service-id"] === serviceId, `${label} service label mismatch`, labels); + assertCondition(labels["unidesk.ai/source-repo"] === sourceRepo, `${label} source repo label mismatch`, labels); + assertCondition(labels["unidesk.ai/source-commit"] === commit, `${label} source commit label mismatch`, labels); + assertCondition(labels["unidesk.ai/dockerfile"] === "Dockerfile", `${label} dockerfile label mismatch`, labels); + assertCondition(runtimeProof.kind === "compose-container-runtime-metadata", `${label} runtime proof kind mismatch`, runtimeProof); + assertCondition(runtimeProof.sourceDirectoryUsed === false, `${label} runtime proof must not use source directory guesses`, runtimeProof); + for (const expected of ["service-health", "container-env", "container-labels", "image-labels"]) { + assertCondition(sources.includes(expected), `${label} runtime proof sources should include ${expected}`, runtimeProof); + } + for (const expected of [ + "UNIDESK_DEPLOY_COMMIT", + "UNIDESK_DEPLOY_REQUESTED_COMMIT", + "UNIDESK_TODO_NOTE_DEPLOY_COMMIT", + "UNIDESK_TODO_NOTE_DEPLOY_REQUESTED_COMMIT", + ]) { + assertCondition(requiredEnvKeys.includes(expected), `${label} runtime proof env keys should include ${expected}`, runtimeProof); + } + assertCondition(validation.some((line) => line.includes("Compose container runtime metadata") && line.includes("not source directory guesses")), `${label} validation must name runtime metadata proof`, validation); + assertCondition(!JSON.stringify(plan).includes("/root/todo_note"), `${label} dry-run must not rely on the todo-note source directory`, plan); + assertCondition(!JSON.stringify(plan).includes("docker compose build"), `${label} dry-run must not mention docker compose build`, plan); +} + +async function main(): Promise { + assertTodoNoteComposeYaml(); + assertPlan(runDeployApplyDryRun(), "deploy apply"); + const artifactPlan = asRecord(await runArtifactRegistryCommand([ + "deploy-service", + "--env", + "prod", + "--service", + serviceId, + "--commit", + commit, + "--dry-run", + ]), "artifact-registry dry-run"); + assertPlan(artifactPlan, "artifact-registry"); + + process.stdout.write(`${JSON.stringify({ + ok: true, + checks: [ + "todo-note Compose service names the stable artifact image and deploy labels", + "deploy apply prod dry-run is a no-build/no-deps main-server Compose artifact consumer", + "artifact-registry prod dry-run requires source repo/source commit/Dockerfile image labels", + "runtime proof uses container env, container labels, image labels and health output", + "runtime proof does not infer commit from /root/todo_note or any source directory", + ], + }, null, 2)}\n`); +} + +if (import.meta.main) { + await main(); +}