214 lines
9.9 KiB
TypeScript
214 lines
9.9 KiB
TypeScript
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();
|
|
}
|