test: lock backend-core artifact readiness contract
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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\"",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user