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(); }