diff --git a/scripts/backend-core-artifact-readiness-contract-test.ts b/scripts/backend-core-artifact-readiness-contract-test.ts new file mode 100644 index 00000000..8c549b63 --- /dev/null +++ b/scripts/backend-core-artifact-readiness-contract-test.ts @@ -0,0 +1,213 @@ +import { spawnSync } from "node:child_process"; +import { readConfig } from "./src/config"; +import { runCiPublishBackendCoreDryRunPreflight, type PublishPreflightTransport } from "./src/ci"; +import { artifactRegistryReadonlyResultFromCommand, buildArtifactRegistryReadonlyProbe, parseArtifactRegistryOptions } from "./src/artifact-registry"; +import type { CommandResult } from "./src/command"; + +type JsonRecord = Record; + +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 asStringArray(value: unknown, label: string): string[] { + assertCondition(Array.isArray(value), `${label} must be an array`, { value }); + return (value as unknown[]).map(String); +} + +function command(overrides: Partial): CommandResult { + return { + command: ["frontend", "/api/dispatch", "D601", "host.ssh", "health"], + cwd: ".", + exitCode: 0, + stdout: "", + stderr: "", + signal: null, + timedOut: false, + ...overrides, + }; +} + +function registryDriftStdout(): string { + return [ + "readonly=true", + "unit_exists=true", + "compose_exists=true", + "config_exists=true", + "storage_exists=true", + "systemctl_available=true", + "unit_active=active", + "unit_enabled=enabled", + "docker_available=true", + "container_running=true", + "container_status=running", + "container_image=registry:2.8.3", + "container_restart_policy=unless-stopped", + "listener_count=1", + "bad_listener_count=0", + "loopback_only=true", + "curl_available=true", + "v2_http_code=200", + "config_hash=contract-config-observed", + "compose_hash=contract-compose", + "unit_hash=contract-unit-observed", + "expected_config_hash=contract-config-expected", + "expected_compose_hash=contract-compose", + "expected_unit_hash=contract-unit-expected", + "config_hash_matches=false", + "compose_hash_matches=true", + "unit_hash_matches=false", + "image_matches=false", + "", + ].join("\n"); +} + +async function main(): Promise { + const commit = "0123456789abcdef0123456789abcdef01234567"; + const config = readConfig(); + const registryOptions = parseArtifactRegistryOptions(["--provider-id", "D601"]); + const registryProbe = buildArtifactRegistryReadonlyProbe("health", registryOptions); + const registry = asRecord(artifactRegistryReadonlyResultFromCommand(registryProbe, command({ + stdout: registryDriftStdout(), + })), "registry health"); + + const transport: PublishPreflightTransport = { + kind: "remote-frontend", + remoteHost: "https://example.invalid", + coreFetch: async () => ({ + ok: true, + status: 200, + body: { ok: true, dbReady: true }, + }), + dispatchHostSsh: async (remoteCommand) => { + if (remoteCommand.includes("/v2/")) { + return { + ok: true, + taskId: "task-registry", + status: "succeeded", + stdout: registryDriftStdout(), + stderr: "", + exitCode: 0, + raw: {}, + }; + } + return { + ok: true, + taskId: "task-ci-runner", + status: "succeeded", + stdout: [ + "provider_host_ssh=ok", + "kubectl=ok", + "docker=ok", + "docker_socket=true", + "namespace=true", + "tekton_pipeline=true", + "tekton_task=true", + "service_account=true", + "pvc=true", + "source_parent_directory=true", + ].join("\n"), + stderr: "", + exitCode: 0, + raw: {}, + }; + }, + commandCwd: "/workspace/unidesk", + artifactRegistryCommand: (probe) => ["mock", probe.action, probe.remoteCommandShape], + }; + + const publish = asRecord(await runCiPublishBackendCoreDryRunPreflight(config, [ + "publish-backend-core", + "--commit", + commit, + "--dry-run", + ], transport), "publish dry-run"); + const publishRequirements = asRecord(publish.artifactRequirements, "publish artifactRequirements"); + const publishLabels = asRecord(publishRequirements.requiredLabels, "publish requiredLabels"); + const publishSummary = asRecord(publish.artifactSummary, "publish artifactSummary"); + const publishDevApplyPath = asRecord(publish.devApplyPath, "publish devApplyPath"); + + const applyResult = spawnSync("bun", [ + "scripts/cli.ts", + "deploy", + "apply", + "--env", + "dev", + "--service", + "backend-core", + "--commit", + commit, + "--dry-run", + ], { + cwd: process.cwd(), + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }); + assertCondition(applyResult.status === 0, "dev deploy dry-run should exit 0", { + status: applyResult.status, + stdoutTail: applyResult.stdout.slice(-2000), + stderrTail: applyResult.stderr.slice(-2000), + }); + const applyEnvelope = asRecord(JSON.parse(applyResult.stdout) as unknown, "deploy apply envelope"); + const applyData = asRecord(applyEnvelope.data, "deploy apply data"); + const applyItems = Array.isArray(applyData.results) ? applyData.results : []; + assertCondition(applyItems.length === 1, "dev deploy dry-run should return one backend-core item", applyData); + const apply = asRecord(applyItems[0], "deploy apply backend-core"); + const applySource = asRecord(apply.source, "deploy apply source"); + const applyRegistry = asRecord(apply.registry, "deploy apply registry"); + const applyBuild = asRecord(apply.build, "deploy apply build"); + const applyLabels = asRecord(apply.requiredLabels, "deploy apply requiredLabels"); + const applyProbe = asRecord(apply.registryProbe, "deploy apply registryProbe"); + const registryFailedScopes = asStringArray(registry.failedScopes, "registry failedScopes"); + const publishBlockedScopes = asStringArray(publish.blockedScopes, "publish blockedScopes"); + + assertCondition(registry.ok === false, "registry health drift should be blocking", registry); + assertCondition(registry.failureClassification === "registry-unhealthy", "registry drift should classify registry-unhealthy", registry); + assertCondition(registryFailedScopes.includes("rendered-config"), "registry drift must expose rendered-config", registry); + assertCondition(registryFailedScopes.includes("registry-image"), "registry drift must expose registry-image", registry); + assertCondition(publish.ok === false, "publish preflight must block on registry drift", publish); + assertCondition(publish.failureClassification === registry.failureClassification, "publish failure classification should match registry health", publish); + assertCondition(publishBlockedScopes.includes("rendered-config"), "publish blockedScopes must include registry rendered-config drift", publish); + assertCondition(publishBlockedScopes.includes("registry-image"), "publish blockedScopes must include registry image drift", publish); + + assertCondition(publish.sourceRepo === apply.sourceRepo, "sourceRepo should match between publish and dev deploy dry-run", { publish, apply }); + assertCondition(publish.targetCommit === apply.commit, "targetCommit should match dev deploy commit", { publish, apply }); + assertCondition(publishSummary.imageRef === apply.sourceImage, "publish artifact image must match dev deploy sourceImage", { publishSummary, apply }); + assertCondition(publish.registryTarget === "127.0.0.1:5000/unidesk/backend-core", "publish registry target mismatch", publish); + assertCondition(applyRegistry.imageRef === publishSummary.imageRef, "dev deploy registry image should match artifactSummary imageRef", { applyRegistry, publishSummary }); + assertCondition(applyRegistry.digest === null && publishSummary.digest === null, "dry-runs must not fake digest values", { applyRegistry, publishSummary }); + assertCondition(applyRegistry.digestHeader === "Docker-Content-Digest", "dev deploy must name digest header", applyRegistry); + assertCondition(applyProbe.digestHeader === "Docker-Content-Digest", "registry probe must name digest header", applyProbe); + assertCondition(applyBuild.willCompile === false, "dev deploy CD must not compile", applyBuild); + assertCondition(applyBuild.willRunCargoBuild === false, "dev deploy CD must not run cargo build", applyBuild); + assertCondition(applyBuild.willRunDockerBuild === false, "dev deploy CD must not run docker build", applyBuild); + assertCondition(applyBuild.producerBoundary === "ci publish-backend-core", "dev deploy producer boundary mismatch", applyBuild); + assertCondition(publishDevApplyPath.pullOnly === true, "publish devApplyPath must be pull-only", publishDevApplyPath); + assertCondition(String(publishDevApplyPath.dryRun ?? "").includes("deploy apply --env dev --service backend-core"), "publish devApplyPath must expose dev apply dry-run", publishDevApplyPath); + assertCondition(!String(publishDevApplyPath.apply ?? "").includes("--env prod"), "publish devApplyPath must not point to prod", publishDevApplyPath); + + for (const [key, value] of Object.entries(publishLabels)) { + assertCondition(applyLabels[key] === value, `deploy apply required label ${key} should match publish preflight`, { publishLabels, applyLabels }); + } + assertCondition(applySource.repo === publish.sourceRepo, "deploy apply source.repo should match publish sourceRepo", { applySource, publish }); + assertCondition(applySource.commit === publish.targetCommit, "deploy apply source.commit should match publish targetCommit", { applySource, publish }); + + process.stdout.write(`${JSON.stringify({ + ok: true, + checks: [ + "backend-core publish dry-run, registry health drift, and dev deploy dry-run agree on source repo, commit, registry image, labels, digest header, and no-build CD", + "registry drift remains a blocker with rendered-config and registry-image failed scopes", + "publish readiness exposes a dev apply dry-run path and never points to prod", + ], + blockedScopes: publishBlockedScopes, + }, null, 2)}\n`); +} + +if (import.meta.main) { + await main(); +} diff --git a/scripts/src/artifact-registry.ts b/scripts/src/artifact-registry.ts index 2a8dab00..bc818c8e 100644 --- a/scripts/src/artifact-registry.ts +++ b/scripts/src/artifact-registry.ts @@ -1333,7 +1333,7 @@ function artifactConsumerLiveBlock(spec: ArtifactConsumerSpec, options: Artifact providerId: options.providerId, reason: spec.prodLiveBlockReason ?? `${spec.serviceId} does not yet satisfy the artifact consumer runtime verification contract.`, requiredBeforeLiveApply: [ - "CI can publish a commit-pinned image with matching service id, source commit, and Dockerfile labels", + "CI can publish a commit-pinned image with matching service id, source repo, source commit, and Dockerfile labels", "runtime Compose env injects deploy commit/requestedCommit metadata", "service health reports deploy.commit and deploy.requestedCommit for strict verification", ], @@ -1823,25 +1823,29 @@ function verifyLocalArtifactLabels( localLoadedImage: string, spec: ArtifactConsumerSpec, commit: string, + sourceRepo: string, ): Record | null { const inspectPulled = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{json .Config.Labels}}"], repoRoot); const labelCommit = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/source-commit\" }}"], repoRoot); const labelService = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/service-id\" }}"], repoRoot); + const labelRepo = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/source-repo\" }}"], repoRoot); const labelDockerfile = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/dockerfile\" }}"], repoRoot); const observed = { commit: labelCommit.stdout.trim(), serviceId: labelService.stdout.trim(), + sourceRepo: labelRepo.stdout.trim(), dockerfile: labelDockerfile.stdout.trim(), labels: inspectPulled.stdout.trim(), }; const ok = observed.commit === commit && observed.serviceId === spec.serviceId + && observed.sourceRepo === sourceRepo && observed.dockerfile === spec.dockerfile; if (ok) return null; return { ok: false, step: "image-label-verify", - expected: { commit, serviceId: spec.serviceId, dockerfile: spec.dockerfile }, + expected: { commit, serviceId: spec.serviceId, sourceRepo, dockerfile: spec.dockerfile }, observed, }; } @@ -1937,7 +1941,7 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec: }; } const localLoadedImage = sourceImage; - const labelFailure = verifyLocalArtifactLabels(localLoadedImage, spec, commit); + const labelFailure = verifyLocalArtifactLabels(localLoadedImage, spec, commit, sourceRepoFor(options, spec)); if (labelFailure !== null) return { ...labelFailure, registryProbe: commandTail(registryProbe) }; const tag = runCommand(["docker", "tag", localLoadedImage, composeImage], repoRoot); if (tag.exitCode !== 0 || tag.timedOut) { @@ -2100,9 +2104,11 @@ function d601ComposeArtifactDeployScript(options: ArtifactRegistryOptions, spec: "docker pull -q \"$registry_image\" >/dev/null", "label_commit=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}')", "label_service=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/service-id\" }}')", + "label_repo=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/source-repo\" }}')", "label_dockerfile=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/dockerfile\" }}')", "test \"$label_commit\" = \"$commit\"", "test \"$label_service\" = \"$service_id\"", + "test \"$label_repo\" = \"$source_repo\"", "test \"$label_dockerfile\" = \"$dockerfile\"", "test -d \"$work_dir\"", "test -f \"$work_dir/$compose_file\"", @@ -2290,6 +2296,7 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti }, requiredLabels: { "unidesk.ai/service-id": spec.serviceId, + "unidesk.ai/source-repo": sourceRepoFor(options, spec), "unidesk.ai/source-commit": commit, "unidesk.ai/dockerfile": spec.dockerfile, }, @@ -2338,8 +2345,8 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti validation: [ "D601 registry /v2 manifest exists for the commit tag", spec.kind === "d601-compose" - ? "D601-pulled image labels match service id, source commit, and Dockerfile" - : "loaded image labels match service id, source commit, and Dockerfile", + ? "D601-pulled image labels match service id, source repo, source commit, and Dockerfile" + : "loaded image labels match service id, source repo, source commit, and Dockerfile", spec.kind === "d601-compose" ? "running D601 Compose container is recreated with a no-build override that points the service image at the artifact" : "running Compose container image label matches the requested commit", @@ -2380,7 +2387,7 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti }, validation: [ "D601 registry /v2 manifest exists for the commit tag before mutation", - "D601 Docker-pulled image labels match service id, source commit, and Dockerfile", + "D601 Docker-pulled image labels match service id, source repo, source commit, and Dockerfile", "native k3s containerd has the commit image and stable runtime image tag", "Deployment annotation and pod image id label match the requested commit", "service health via Kubernetes API service proxy returns the same deploy.commit and deploy.requestedCommit", @@ -2485,9 +2492,11 @@ function d601K3sArtifactDeployScript(options: ArtifactRegistryOptions, spec: Art "docker pull -q \"$registry_image\" >/dev/null", "label_commit=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}')", "label_service=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/service-id\" }}')", + "label_repo=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/source-repo\" }}')", "label_dockerfile=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/dockerfile\" }}')", "test \"$label_commit\" = \"$commit\"", "test \"$label_service\" = \"$service_id\"", + "test \"$label_repo\" = \"$source_repo\"", "test \"$label_dockerfile\" = \"$dockerfile\"", "docker tag \"$registry_image\" \"$stable_image\"", "docker tag \"$registry_image\" \"$commit_image\"", diff --git a/scripts/src/ci.ts b/scripts/src/ci.ts index 9a0019e9..8f7c3506 100644 --- a/scripts/src/ci.ts +++ b/scripts/src/ci.ts @@ -567,6 +567,7 @@ function backendCoreDevApplyPath(options: CiPublishBackendCoreOptions, plannedAr environment: "dev", pullOnly: true, apply: `bun scripts/cli.ts deploy apply --env dev --service backend-core --commit ${options.commit}`, + dryRun: `bun scripts/cli.ts deploy apply --env dev --service backend-core --commit ${options.commit} --dry-run`, sourceImage: plannedArtifact.imageRef, forbidden: ["--env prod", "cargo build", "docker build", "docker compose build", "server rebuild backend-core"], note: "PROD is intentionally not part of backend-core publish preflight; dev apply still needs explicit authorization after artifact publication.", diff --git a/scripts/src/deploy.ts b/scripts/src/deploy.ts index cabd5410..aa06bb42 100644 --- a/scripts/src/deploy.ts +++ b/scripts/src/deploy.ts @@ -982,7 +982,7 @@ function artifactConsumerPlanValidation(service: UniDeskMicroserviceConfig, envi 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", + "verifies image labels for service id, source repo, source commit, and Dockerfile", "does not build source on the runtime target", ]; if (kind === "d601-k3s-managed") { @@ -2997,6 +2997,7 @@ function environmentDryRunPlan( tag: service.commitId, imageRef: `127.0.0.1:5000/unidesk/${service.id}:${service.commitId}`, digest: null, + digestHeader: "Docker-Content-Digest", digestSource: "planned registry manifest HEAD; live apply records Docker-Content-Digest before mutation", }, source: unsupported ? null : { @@ -3011,6 +3012,12 @@ function environmentDryRunPlan( willRunDockerComposeBuild: false, producerBoundary: service.id === "backend-core" ? "ci publish-backend-core" : "ci publish-user-service", }, + requiredLabels: unsupported ? null : { + "unidesk.ai/service-id": service.id, + "unidesk.ai/source-repo": service.repo, + "unidesk.ai/source-commit": service.commitId, + "unidesk.ai/dockerfile": serviceConfig.repository.dockerfile, + }, noRuntimeSourceBuild: unsupported || planKind !== "d601-dev-target-side-build", dryRunOnly: unsupported || (environment === "prod" && prodArtifactLiveApplyBlockedServiceIds.has(service.id)) || dryRunBlockedReason !== null, blockedReason: unsupportedReason ?? dryRunBlockedReason ?? (environment === "prod" ? prodArtifactLiveApplyBlockedServiceIds.get(service.id) ?? null : null),