test: lock backend-core artifact readiness contract

This commit is contained in:
Codex
2026-05-21 12:55:01 +00:00
parent b334430c00
commit 569cf7a74c
4 changed files with 237 additions and 7 deletions
@@ -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<string, unknown>;
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>): 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<void> {
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();
}
+15 -6
View File
@@ -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<string, unknown> | 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\"",
+1
View File
@@ -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.",
+8 -1
View File
@@ -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),