diff --git a/docs/dev-artifact-publish.md b/docs/dev-artifact-publish.md index 0da41f24..347ede4b 100644 --- a/docs/dev-artifact-publish.md +++ b/docs/dev-artifact-publish.md @@ -157,6 +157,18 @@ blocked: node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked ``` +Before any later DEV apply review, run the read-only artifact/runtime guard so +artifact evidence is not mistaken for deployed runtime evidence: + +```sh +node scripts/artifact-runtime-readiness-guard.mjs --target-ref origin/main --check --no-report --expect-blocked +``` + +The guard must remain blocked until the target commit, publish report, artifact +catalog, desired-state files, Cloud API `/health/live`, and Cloud Web +`:16666/health/live` all report the same identity. It does not publish, deploy, +restart services, mutate Kubernetes, touch PROD, or read secrets. + The report also includes `artifactPublish.baseImagePreflight` with the image source, local tag, local image ID, `publishUsable` gate, blockers, and next steps. It also carries `recommendation`, `provision`, and `blockedReport` diff --git a/docs/dev-deploy-apply.md b/docs/dev-deploy-apply.md index 6bc396e7..89614bcc 100644 --- a/docs/dev-deploy-apply.md +++ b/docs/dev-deploy-apply.md @@ -40,6 +40,7 @@ node --check scripts/dev-runtime-migration.mjs node --check scripts/src/dev-runtime-migration.mjs node scripts/deploy-desired-state-plan.mjs --check node scripts/dev-runtime-migration.mjs --check +node scripts/artifact-runtime-readiness-guard.mjs --target-ref origin/main --check --no-report --expect-blocked node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --write-report node scripts/validate-dev-gate-report.mjs ``` @@ -61,6 +62,11 @@ The script refuses PROD flags, secret-read flags, heavyweight e2e flags, and force-push flags. It also refuses to apply if the DEV artifact catalog still contains skeleton-only unpublished images. +`deploy-desired-state-plan.mjs` is not an apply preflight substitute. Pair it +with `artifact-runtime-readiness-guard.mjs` before apply review so stale +artifact catalogs, stale desired-state, stale API runtime identity, or a stale +Cloud Web `:16666` build stay explicit blockers. + Kubeconfig selection is explicit and safe. The script uses this precedence: 1. `--kubeconfig PATH` diff --git a/docs/reference/deployment-publish.md b/docs/reference/deployment-publish.md index 780b49dc..7cc81adf 100644 --- a/docs/reference/deployment-publish.md +++ b/docs/reference/deployment-publish.md @@ -84,6 +84,28 @@ image, workload, and mirror-env fields are blocked before any DEV apply. Reports under `reports/` are evidence snapshots only and cannot override this desired-state contract. +## Latest-Main Artifact/Runtime Guard + +Before any controlled DEV apply for latest `origin/main`, run the read-only +artifact/runtime guard: + +```sh +node scripts/artifact-runtime-readiness-guard.mjs --target-ref origin/main --check --no-report +``` + +When current readiness is expected to be blocked, use: + +```sh +node scripts/artifact-runtime-readiness-guard.mjs --target-ref origin/main --check --no-report --expect-blocked +``` + +The guard compares target main, `reports/dev-gate/dev-artifacts.json`, +`deploy/artifact-catalog.dev.json`, the desired-state planner, Cloud API +`/health/live`, and Cloud Web `:16666/health/live`. It is report-only and must +not be used as an apply command. A local source check, static workbench smoke, +or stale report snapshot cannot claim latest-main deployment unless the guard's +runtime and artifact identity checks also pass. + ## API, Edge, Health, And FRP The public route is fixed: diff --git a/package.json b/package.json index 6842d168..68a9358f 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node --test scripts/artifact-runtime-readiness-guard.test.mjs", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", diff --git a/scripts/artifact-runtime-readiness-guard.mjs b/scripts/artifact-runtime-readiness-guard.mjs new file mode 100644 index 00000000..9c01529e --- /dev/null +++ b/scripts/artifact-runtime-readiness-guard.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import { + formatArtifactRuntimeReadinessFailure, + runArtifactRuntimeReadinessGuardCli +} from "./src/artifact-runtime-readiness-guard.mjs"; + +try { + process.exitCode = await runArtifactRuntimeReadinessGuardCli(process.argv.slice(2)); +} catch (error) { + process.stdout.write(`${JSON.stringify(formatArtifactRuntimeReadinessFailure(error), null, 2)}\n`); + process.exitCode = 1; +} diff --git a/scripts/artifact-runtime-readiness-guard.test.mjs b/scripts/artifact-runtime-readiness-guard.test.mjs new file mode 100644 index 00000000..a65cf59f --- /dev/null +++ b/scripts/artifact-runtime-readiness-guard.test.mjs @@ -0,0 +1,248 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + commitsMatch, + evaluateArtifactRuntimeReadiness, + normalizeCommit +} from "./src/artifact-runtime-readiness-guard.mjs"; + +const targetSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const staleSha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111"; + +function artifactReport(sourceCommitId = targetSha, overrides = {}) { + return { + commitId: sourceCommitId.slice(0, 7), + artifactPublish: { + status: "published", + sourceCommitId, + serviceCount: 2, + requiredServiceCount: 2, + publishedCount: 2, + registryPrefix: "127.0.0.1:5000/hwlab", + services: [ + { + serviceId: "hwlab-cloud-api", + artifactRequired: true, + status: "published", + imageTag: sourceCommitId.slice(0, 7), + digest + }, + { + serviceId: "hwlab-cloud-web", + artifactRequired: true, + status: "published", + imageTag: sourceCommitId.slice(0, 7), + digest, + runtimeKind: "cloud-web", + distFreshness: { status: "pass" } + } + ], + ...overrides + } + }; +} + +function artifactCatalog(commitId = targetSha, { + ciPublished = true, + registryVerified = true, + serviceDigest = digest, + artifactState = "published" +} = {}) { + return { + artifactState, + commitId, + publish: { + ciPublished, + registryVerified + }, + services: [ + { + serviceId: "hwlab-cloud-api", + commitId, + artifactRequired: true, + publishState: serviceDigest === "not_published" ? "skeleton-only" : "published", + imageTag: commitId.slice(0, 7), + digest: serviceDigest + }, + { + serviceId: "hwlab-cloud-web", + commitId, + artifactRequired: true, + publishState: serviceDigest === "not_published" ? "skeleton-only" : "published", + imageTag: commitId.slice(0, 7), + digest: serviceDigest + } + ] + }; +} + +function desiredStatePlan(commitId = targetSha, state = "already_promoted", status = "pass") { + return { + status, + summary: { + desiredCommitId: commitId, + desiredImageTag: commitId.slice(0, 7), + diagnostics: status === "blocked" ? 1 : 0, + blockers: status === "blocked" ? 1 : 0 + }, + target: { + convergence: { + state, + comparableFields: 8, + matchingTargetFields: state === "already_promoted" ? 8 : 0, + pendingTargetFields: state === "already_promoted" ? 0 : 8 + } + }, + services: [ + { + serviceId: "hwlab-cloud-web", + catalog: { commitId } + } + ] + }; +} + +function liveRuntime(commitId = targetSha, { cloudWebCommitId = commitId, apiCommitId = commitId } = {}) { + return { + mode: "live-read-only-http", + api: { + status: "observed", + source: "health-live", + evidenceSource: "live-http", + endpoint: "http://74.48.78.17:16667/health/live", + serviceId: "hwlab-cloud-api", + environment: "dev", + healthStatus: "ok", + commitId: apiCommitId, + shortCommitId: apiCommitId.slice(0, 7), + commitSource: "runtime-env", + imageTag: apiCommitId.slice(0, 7), + observedAt: "2026-05-22T00:00:00.000Z" + }, + cloudWeb: { + status: "observed", + source: "health-live", + evidenceSource: "live-http", + endpoint: "http://74.48.78.17:16666/health/live", + serviceId: "hwlab-cloud-web", + environment: "dev", + healthStatus: "ok", + artifactKind: "cloud-web", + commitId: cloudWebCommitId, + shortCommitId: cloudWebCommitId.slice(0, 7), + commitSource: "runtime-env", + imageTag: cloudWebCommitId.slice(0, 7), + observedAt: "2026-05-22T00:00:00.000Z" + } + }; +} + +function readiness(overrides = {}) { + return evaluateArtifactRuntimeReadiness({ + artifactReport: artifactReport(overrides.artifactSource ?? targetSha, overrides.artifactReportOverrides), + artifactCatalog: artifactCatalog(overrides.catalogCommit ?? targetSha, overrides.catalogOptions ?? {}), + desiredStatePlan: overrides.desiredPlan ?? desiredStatePlan(overrides.desiredCommit ?? targetSha, overrides.desiredState ?? "already_promoted", overrides.desiredStatus ?? "pass"), + runtimeReport: null, + liveRuntime: overrides.live ?? liveRuntime(targetSha), + targetRef: "origin/main", + targetCommitId: overrides.targetCommit ?? targetSha + }); +} + +test("commit comparison accepts full and short lowercase SHA forms only", () => { + assert.equal(normalizeCommit("ABCDEF1"), "abcdef1"); + assert.equal(normalizeCommit("not-a-sha"), "unknown"); + assert.equal(commitsMatch(targetSha, targetSha.slice(0, 7)), true); + assert.equal(commitsMatch(targetSha, "unknown"), false); +}); + +test("readiness passes only when artifact, desired-state, API runtime, and Cloud Web runtime match target", () => { + const result = readiness(); + assert.equal(result.ready, true); + assert.equal(result.canInferFromSourceLocalStatic, false); + assert.deepEqual(result.blockers, []); +}); + +test("stale local artifact report cannot imply latest-main readiness", () => { + const result = readiness({ targetCommit: staleSha }); + assert.equal(result.ready, false); + assert.equal(result.canInferFromSourceLocalStatic, false); + assert.deepEqual( + result.blockers.map((item) => item.id), + [ + "artifact-report-target-match", + "artifact-catalog-target-match", + "desired-state-target-match", + "api-runtime-target-match", + "cloud-web-runtime-target-match" + ] + ); +}); + +test("desired-state promotion pending blocks before apply", () => { + const result = readiness({ + desiredCommit: staleSha, + desiredState: "promotion_pending", + desiredStatus: "planned" + }); + assert.equal(result.ready, false); + assert.equal(result.desiredState.targetConvergence.state, "promotion_pending"); + assert.ok(result.blockers.some((item) => item.id === "desired-state-target-match")); +}); + +test("unpublished catalog digests keep readiness blocked", () => { + const result = readiness({ + catalogOptions: { + artifactState: "contract-skeleton", + ciPublished: false, + registryVerified: false, + serviceDigest: "not_published" + } + }); + assert.equal(result.ready, false); + assert.equal(result.catalog.ciPublished, false); + assert.equal(result.catalog.registryVerified, false); + assert.deepEqual( + result.blockers.map((item) => item.id), + ["artifact-catalog-published"] + ); +}); + +test("Cloud Web served runtime identity must match artifact and target", () => { + const result = readiness({ + live: liveRuntime(targetSha, { cloudWebCommitId: staleSha }) + }); + assert.equal(result.ready, false); + assert.ok(result.blockers.some((item) => item.id === "cloud-web-runtime-target-match")); + assert.ok(result.blockers.some((item) => item.id === "cloud-web-runtime-artifact-match")); + assert.equal(result.runtime.cloudWeb.effective.shortCommitId, staleSha.slice(0, 7)); +}); + +test("Cloud Web publish report must retain build freshness evidence", () => { + const result = readiness({ + artifactReportOverrides: { + services: [ + { + serviceId: "hwlab-cloud-api", + artifactRequired: true, + status: "published", + imageTag: targetSha.slice(0, 7), + digest + }, + { + serviceId: "hwlab-cloud-web", + artifactRequired: true, + status: "published", + imageTag: targetSha.slice(0, 7), + digest, + runtimeKind: "cloud-web" + } + ] + } + }); + assert.equal(result.ready, false); + assert.equal(result.artifact.cloudWeb.distFreshnessStatus, "missing"); + assert.ok(result.blockers.some((item) => item.id === "cloud-web-published-build-freshness")); +}); diff --git a/scripts/src/artifact-runtime-readiness-guard.mjs b/scripts/src/artifact-runtime-readiness-guard.mjs new file mode 100644 index 00000000..9f97cddd --- /dev/null +++ b/scripts/src/artifact-runtime-readiness-guard.mjs @@ -0,0 +1,890 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs"; +import { DEV_ENDPOINT, DEV_FRONTEND_ENDPOINT } from "../../internal/protocol/index.mjs"; +import { buildDesiredStatePlan } from "./deploy-desired-state-plan.mjs"; + +const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +const defaultArtifactReport = "reports/dev-gate/dev-artifacts.json"; +const defaultArtifactCatalog = "deploy/artifact-catalog.dev.json"; +const defaultRuntimeReport = "reports/dev-gate/dev-cloud-workbench-live.json"; +const defaultOutputReport = "reports/dev-gate/dev-artifact-runtime-readiness.json"; +const defaultTargetRef = "origin/main"; +const guardCommand = `node scripts/artifact-runtime-readiness-guard.mjs --target-ref ${defaultTargetRef} --check --no-report`; +const expectedBlockedGuardCommand = `${guardCommand} --expect-blocked`; +const digestPattern = /^sha256:[a-f0-9]{64}$/u; +const commitPattern = /^[a-f0-9]{7,40}$/u; + +export function normalizeCommit(value) { + if (typeof value !== "string") return "unknown"; + const trimmed = value.trim().toLowerCase(); + return commitPattern.test(trimmed) ? trimmed : "unknown"; +} + +export function shortCommit(value) { + const commit = normalizeCommit(value); + return commit === "unknown" ? "unknown" : commit.slice(0, 7); +} + +export function commitsMatch(left, right) { + const a = normalizeCommit(left); + const b = normalizeCommit(right); + return a !== "unknown" && b !== "unknown" && (a === b || a.startsWith(b) || b.startsWith(a)); +} + +export function evaluateArtifactRuntimeReadiness({ + artifactReport, + artifactCatalog, + desiredStatePlan, + runtimeReport, + liveRuntime, + targetRef, + targetCommitId +}) { + const target = { + ref: targetRef, + commitId: normalizeCommit(targetCommitId), + shortCommitId: shortCommit(targetCommitId) + }; + const artifact = summarizeArtifactReport(artifactReport); + const catalog = summarizeArtifactCatalog(artifactCatalog); + const desiredState = summarizeDesiredStatePlan(desiredStatePlan, target); + const runtime = summarizeRuntimeSources(runtimeReport, liveRuntime); + const apiRuntime = runtime.api.effective; + const cloudWebRuntime = runtime.cloudWeb.effective; + const checks = []; + + addCheck(checks, { + id: "target-ref-resolved", + pass: target.commitId !== "unknown", + type: "contract_blocker", + summary: `Target ${target.ref} resolves to ${target.shortCommitId}.`, + blockedSummary: `Target ${target.ref} could not be resolved to a Git commit.`, + nextTask: "Fetch origin/main and rerun this guard before preparing any DEV apply." + }); + addCheck(checks, { + id: "artifact-report-published", + pass: artifact.publishVerified, + type: "runtime_blocker", + summary: `Artifact report status=${artifact.status}; required published=${artifact.publishedRequiredCount}/${artifact.requiredServiceCount}; sha256=${artifact.digestCounts.sha256}; invalid=${artifact.digestCounts.invalid}.`, + nextTask: "Complete the DEV artifact publish workflow and keep real sha256 digests for every required service." + }); + addCheck(checks, { + id: "artifact-report-target-match", + pass: commitsMatch(artifact.sourceCommitId, target.commitId), + type: "observability_blocker", + summary: `Artifact report source=${artifact.shortCommitId}; target=${target.shortCommitId}.`, + nextTask: "Publish artifacts for latest origin/main or keep rollout readiness blocked." + }); + addCheck(checks, { + id: "artifact-catalog-published", + pass: catalog.publishVerified, + type: "runtime_blocker", + summary: `Artifact catalog state=${catalog.artifactState}; ciPublished=${catalog.ciPublished}; registryVerified=${catalog.registryVerified}; sha256=${catalog.digestCounts.sha256}; not_published=${catalog.digestCounts.notPublished}; invalid=${catalog.digestCounts.invalid}.`, + nextTask: "Refresh deploy/artifact-catalog.dev.json only from a successful DEV artifact publish report." + }); + addCheck(checks, { + id: "artifact-catalog-target-match", + pass: commitsMatch(catalog.commitId, target.commitId), + type: "observability_blocker", + summary: `Artifact catalog commit=${catalog.shortCommitId}; target=${target.shortCommitId}.`, + nextTask: "Refresh desired-state and artifact catalog identity to latest origin/main before apply." + }); + addCheck(checks, { + id: "artifact-report-catalog-match", + pass: commitsMatch(artifact.sourceCommitId, catalog.commitId), + type: "observability_blocker", + summary: `Artifact report source=${artifact.shortCommitId}; catalog=${catalog.shortCommitId}.`, + nextTask: "Keep reports/dev-gate/dev-artifacts.json and deploy/artifact-catalog.dev.json aligned to the same artifact source." + }); + addCheck(checks, { + id: "desired-state-internal-valid", + pass: desiredState.status !== "blocked" && desiredState.status !== "missing", + type: "contract_blocker", + summary: `Desired-state planner status=${desiredState.status}; convergence=${desiredState.targetConvergence.state}.`, + nextTask: "Repair deploy/deploy.json, deploy/artifact-catalog.dev.json, and deploy/k8s/base/workloads.yaml before any apply." + }); + addCheck(checks, { + id: "desired-state-target-match", + pass: desiredState.matchesTarget, + type: "observability_blocker", + summary: `Desired-state deploy=${desiredState.deployCommitId}; target=${target.shortCommitId}; convergence=${desiredState.targetConvergence.state}; pending=${desiredState.targetConvergence.pendingTargetFields}.`, + nextTask: `Run node scripts/deploy-desired-state-plan.mjs --promotion-commit ${target.shortCommitId} --check after refreshing desired-state for latest origin/main.` + }); + addCheck(checks, { + id: "rollout-read-access", + pass: runtime.live.mode === "live-read-only-http", + type: "observability_blocker", + summary: `Runtime rollout-read mode=${runtime.live.mode}.`, + nextTask: "Run this guard with read-only DEV endpoint access; SOURCE/local/static checks cannot claim latest-main deployment." + }); + addCheck(checks, { + id: "api-runtime-identity-observed", + pass: apiRuntime.observed, + type: "observability_blocker", + summary: `API /health/live identity status=${apiRuntime.status}; source=${apiRuntime.evidenceSource}; commit=${apiRuntime.shortCommitId}.`, + nextTask: "Restore read-only access to Cloud API /health/live before treating DEV runtime identity as observed." + }); + addCheck(checks, { + id: "api-runtime-target-match", + pass: apiRuntime.observed && commitsMatch(apiRuntime.commitId, target.commitId), + type: "runtime_blocker", + summary: `API /health/live commit=${apiRuntime.shortCommitId}; target=${target.shortCommitId}.`, + nextTask: "After publish and desired-state refresh, run the authorized DEV rollout path and re-observe /health/live." + }); + addCheck(checks, { + id: "api-runtime-artifact-match", + pass: apiRuntime.observed && commitsMatch(apiRuntime.commitId, catalog.commitId) && commitsMatch(apiRuntime.commitId, artifact.sourceCommitId), + type: "runtime_blocker", + summary: `API /health/live commit=${apiRuntime.shortCommitId}; catalog=${catalog.shortCommitId}; artifact=${artifact.shortCommitId}.`, + nextTask: "Do not treat a healthy API route as current unless runtime, artifact report, and catalog identity all match." + }); + addCheck(checks, { + id: "cloud-web-runtime-identity-observed", + pass: cloudWebRuntime.observed, + type: "observability_blocker", + summary: `Cloud Web :16666 /health/live identity status=${cloudWebRuntime.status}; source=${cloudWebRuntime.evidenceSource}; commit=${cloudWebRuntime.shortCommitId}.`, + nextTask: "Observe Cloud Web /health/live on :16666; HTML and static asset checks alone do not prove the deployed artifact revision." + }); + addCheck(checks, { + id: "cloud-web-runtime-target-match", + pass: cloudWebRuntime.observed && commitsMatch(cloudWebRuntime.commitId, target.commitId), + type: "runtime_blocker", + summary: `Cloud Web :16666 revision=${cloudWebRuntime.shortCommitId}; target=${target.shortCommitId}.`, + nextTask: "Roll out the latest Cloud Web artifact through the authorized DEV path, then re-check :16666 /health/live." + }); + addCheck(checks, { + id: "cloud-web-runtime-artifact-match", + pass: cloudWebRuntime.observed && commitsMatch(cloudWebRuntime.commitId, catalog.commitId) && commitsMatch(cloudWebRuntime.commitId, artifact.sourceCommitId), + type: "runtime_blocker", + summary: `Cloud Web revision=${cloudWebRuntime.shortCommitId}; catalog=${catalog.shortCommitId}; artifact=${artifact.shortCommitId}.`, + nextTask: "Block rollout claims until the Cloud Web served runtime identity matches the artifact catalog and publish report." + }); + addCheck(checks, { + id: "cloud-web-published-build-freshness", + pass: artifact.cloudWeb.published && digestPattern.test(artifact.cloudWeb.digest) && artifact.cloudWeb.distFreshnessStatus === "pass", + type: "runtime_blocker", + summary: `Cloud Web artifact status=${artifact.cloudWeb.status}; imageTag=${artifact.cloudWeb.imageTag}; digest=${artifact.cloudWeb.digestStatus}; distFreshness=${artifact.cloudWeb.distFreshnessStatus}.`, + nextTask: "Run the Cloud Web build before publish and keep dist freshness evidence in the artifact publish report." + }); + addCheck(checks, { + id: "source-local-static-not-authoritative", + pass: true, + type: "safety_blocker", + summary: "SOURCE, LOCAL, STATIC, and report snapshots remain non-authoritative until runtime and artifact identity checks above pass.", + nextTask: "Use this guard before apply and after any authorized rollout; never infer deployed latest-main from local source checks." + }); + + const blockers = checks.filter((item) => item.status === "blocked"); + return { + target, + artifact, + catalog, + desiredState, + runtime, + ready: blockers.length === 0, + canInferFromSourceLocalStatic: false, + checks, + blockers + }; +} + +export function buildArtifactRuntimeReadinessReport({ + artifactReport, + artifactCatalog, + desiredStatePlan, + runtimeReport, + liveRuntime, + targetRef, + targetCommitId, + generatedFromCommit = "unknown" +}) { + const readiness = evaluateArtifactRuntimeReadiness({ + artifactReport, + artifactCatalog, + desiredStatePlan, + runtimeReport, + liveRuntime, + targetRef, + targetCommitId + }); + const status = readiness.ready ? "pass" : "blocked"; + return { + $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", + $id: "https://hwlab.pikastech.local/reports/dev-gate/dev-artifact-runtime-readiness.json", + reportVersion: "v1", + status, + issue: "pikasTech/HWLAB#164", + supports: [ + "pikasTech/HWLAB#7", + "pikasTech/HWLAB#78", + "pikasTech/HWLAB#131" + ], + taskId: "dev-artifact-runtime-readiness", + commitId: shortCommit(generatedFromCommit), + acceptanceLevel: "dev_artifact_runtime_readiness", + devOnly: true, + prodDisabled: true, + generatedAt: new Date().toISOString(), + evidenceLevel: status === "pass" ? "DEV-LIVE-RUNTIME-IDENTITY-NON-M3-M4-M5" : "BLOCKED", + devLive: status === "pass", + reportLifecycle: activeReportLifecycle("Current latest-main DEV artifact/runtime readiness guard; SOURCE and static checks cannot imply deployed 16666 readiness."), + sourceContract: { + status: "pass", + documents: [ + "docs/dev-artifact-publish.md", + "docs/dev-deploy-apply.md", + "docs/reference/deployment-publish.md" + ], + summary: "Target commit, artifact publish/catalog identity, desired-state convergence, API runtime identity, and Cloud Web served runtime identity are separate gates." + }, + validationCommands: [ + "node --check scripts/artifact-runtime-readiness-guard.mjs", + "node --check scripts/src/artifact-runtime-readiness-guard.mjs", + "node --test scripts/artifact-runtime-readiness-guard.test.mjs", + guardCommand, + expectedBlockedGuardCommand + ], + localSmoke: { + status, + commands: [expectedBlockedGuardCommand], + evidence: [ + defaultArtifactReport, + defaultArtifactCatalog, + defaultRuntimeReport, + `${DEV_FRONTEND_ENDPOINT}/health/live`, + `${DEV_ENDPOINT}/health/live` + ], + summary: "The guard is read-only and blocks unless artifact, desired-state, API runtime, and Cloud Web runtime identities all align." + }, + dryRun: { + status: "not_applicable", + commands: [guardCommand], + evidence: ["No deploy, publish, apply, restart, k8s mutation, PROD access, or secret read is performed."], + summary: "This is a readiness guard, not a deployment dry-run or rollout substitute." + }, + devPreconditions: { + status, + requirements: [ + "Latest origin/main must resolve to a concrete target commit.", + "DEV artifact publish report and artifact catalog must match the target and contain real registry digests.", + "Desired-state deploy, catalog, workload image, and mirror env fields must converge to the target commit before apply.", + "Cloud API /health/live must report the target runtime identity.", + "Cloud Web :16666 /health/live must report the target served artifact identity.", + "M3/M4/M5 acceptance remains out of scope for this guard." + ], + commands: [guardCommand], + summary: readiness.ready + ? "Latest-main artifact, desired-state, API runtime, and Cloud Web runtime identity are aligned." + : "Latest-main DEV rollout readiness is blocked; see actionable blockers." + }, + artifactRuntimeReadiness: { + version: "v1", + artifactReportPath: defaultArtifactReport, + artifactCatalogPath: defaultArtifactCatalog, + runtimeReportPath: defaultRuntimeReport, + ...readiness + }, + blockedReasons: readiness.blockers.map((item) => ({ + scope: item.id, + type: item.type, + summary: item.summary, + nextTask: item.nextTask + })), + blockers: readiness.blockers.map((item) => ({ + type: item.type, + scope: item.id, + status: "open", + summary: item.summary, + nextTask: item.nextTask + })), + safety: { + readOnly: true, + sourceOnlyWhenBlocked: true, + publicHttpGetOnly: readiness.runtime.live.mode === "live-read-only-http", + kubectlApply: false, + k8sMutation: false, + registryPush: false, + imageBuild: false, + serviceRestart: false, + prodTouched: false, + secretsRead: false, + canInferReadinessFromSourceLocalStatic: false, + statement: "No SOURCE, LOCAL, STATIC, or report-only check may claim deployed latest-main without matching artifact, desired-state, API runtime, and Cloud Web runtime identity." + } + }; +} + +export async function observeLiveRuntimeIdentities({ live = true, timeoutMs = 5000 } = {}) { + const endpoints = { + api: new URL("/health/live", DEV_ENDPOINT).toString(), + cloudWeb: new URL("/health/live", DEV_FRONTEND_ENDPOINT).toString() + }; + if (!live) { + return { + mode: "disabled", + endpoints, + api: notObservedRuntimeIdentity(endpoints.api, "live observation disabled by --no-live", "live-http"), + cloudWeb: notObservedRuntimeIdentity(endpoints.cloudWeb, "live observation disabled by --no-live", "live-http") + }; + } + const [api, cloudWeb] = await Promise.all([ + observeHealthLive(endpoints.api, { evidenceSource: "live-http", timeoutMs }), + observeHealthLive(endpoints.cloudWeb, { evidenceSource: "live-http", timeoutMs }) + ]); + return { + mode: "live-read-only-http", + endpoints, + api, + cloudWeb + }; +} + +export async function runArtifactRuntimeReadinessGuardCli(argv = process.argv.slice(2), options = {}) { + const repoRoot = options.repoRoot ?? defaultRepoRoot; + const args = parseArgs(argv); + if (args.help) { + process.stdout.write(`${usage()}\n`); + return 0; + } + + const [artifactReport, artifactCatalog, runtimeReport, liveRuntime] = await Promise.all([ + readOptionalJson(repoRoot, args.artifactReport), + readOptionalJson(repoRoot, args.artifactCatalog), + readOptionalJson(repoRoot, args.runtimeReport), + observeLiveRuntimeIdentities({ live: args.live, timeoutMs: args.timeoutMs }) + ]); + const targetCommitId = resolveTargetCommit(repoRoot, args.targetRef); + const desiredStatePlan = await buildDesiredStatePlanOrBlocked(repoRoot, args.targetRef); + const report = buildArtifactRuntimeReadinessReport({ + artifactReport, + artifactCatalog, + desiredStatePlan, + runtimeReport, + liveRuntime, + targetRef: args.targetRef, + targetCommitId, + generatedFromCommit: resolveTargetCommit(repoRoot, "HEAD") + }); + + if (args.writeReport) { + const outputPath = path.join(repoRoot, args.report); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + } + + const output = args.check ? summaryForCheck(report) : report; + process.stdout.write(`${JSON.stringify(output, null, args.pretty || args.check ? 2 : 0)}\n`); + + if (args.expectBlocked) { + return report.status === "blocked" ? 0 : 2; + } + if (args.check && report.status !== "pass") { + return 2; + } + return 0; +} + +export function formatArtifactRuntimeReadinessFailure(error) { + return { + kind: "hwlab-artifact-runtime-readiness", + status: "error", + error: error instanceof Error ? error.message : String(error), + safety: { + readOnly: true, + kubectlApply: false, + k8sMutation: false, + registryPush: false, + imageBuild: false, + serviceRestart: false, + prodTouched: false, + secretsRead: false + } + }; +} + +async function buildDesiredStatePlanOrBlocked(repoRoot, targetRef) { + try { + return await buildDesiredStatePlan({ repoRoot, targetRef }); + } catch (error) { + return { + kind: "hwlab-deploy-desired-state-plan", + status: "blocked", + summary: { + desiredCommitId: "unknown", + diagnostics: 1, + blockers: 1 + }, + target: { + targetRef, + convergence: { + state: "planner_error", + comparableFields: 0, + matchingTargetFields: 0, + pendingTargetFields: 0 + } + }, + diagnostics: [{ + severity: "blocker", + code: "planner_error", + message: error instanceof Error ? error.message : String(error) + }] + }; + } +} + +function summarizeArtifactReport(report) { + const publish = report?.artifactPublish; + if (!publish || typeof publish !== "object") { + return missingArtifactReport(); + } + const services = Array.isArray(publish.services) ? publish.services : []; + const required = services.filter((service) => service?.artifactRequired === true); + const cloudWeb = services.find((service) => service?.serviceId === "hwlab-cloud-web") ?? null; + const digestCounts = countDigests(required); + const publishedRequiredCount = required.filter((service) => service?.status === "published").length; + const requiredServiceCount = Number.isInteger(publish.requiredServiceCount) + ? publish.requiredServiceCount + : required.length; + const allRequiredServicesPublished = requiredServiceCount > 0 && + publishedRequiredCount === requiredServiceCount && + digestCounts.sha256 === requiredServiceCount; + return { + status: sanitizeString(publish.status), + sourceCommitId: normalizeCommit(publish.sourceCommitId ?? report.commitId), + shortCommitId: shortCommit(publish.sourceCommitId ?? report.commitId), + registryPrefix: sanitizeString(publish.registryPrefix), + serviceCount: Number.isInteger(publish.serviceCount) ? publish.serviceCount : services.length, + requiredServiceCount, + publishedRequiredCount, + allRequiredServicesPublished, + publishVerified: publish.status === "published" && allRequiredServicesPublished, + digestCounts, + cloudWeb: summarizeArtifactService(cloudWeb) + }; +} + +function summarizeArtifactCatalog(catalog) { + if (!catalog || typeof catalog !== "object") { + return missingArtifactCatalog(); + } + const services = Array.isArray(catalog.services) ? catalog.services : []; + const required = services.filter((service) => service?.artifactRequired === true); + const digestCounts = countDigests(required); + const publishedRequiredCount = required.filter((service) => service?.publishState === "published").length; + const allRequiredServicesPublished = required.length > 0 && + publishedRequiredCount === required.length && + digestCounts.sha256 === required.length; + return { + artifactState: sanitizeString(catalog.artifactState), + commitId: normalizeCommit(catalog.commitId), + shortCommitId: shortCommit(catalog.commitId), + ciPublished: catalog.publish?.ciPublished === true, + registryVerified: catalog.publish?.registryVerified === true, + serviceCount: services.length, + requiredServiceCount: required.length, + publishedRequiredCount, + digestCounts, + publishVerified: catalog.artifactState === "published" && + catalog.publish?.ciPublished === true && + catalog.publish?.registryVerified === true && + allRequiredServicesPublished + }; +} + +function summarizeDesiredStatePlan(plan, target) { + if (!plan || typeof plan !== "object") { + return { + status: "missing", + deployCommitId: "unknown", + shortCommitId: "unknown", + targetConvergence: emptyConvergence("missing"), + matchesTarget: false + }; + } + const deployCommitId = normalizeCommit(plan.summary?.desiredCommitId); + const targetConvergence = plan.target?.convergence ?? emptyConvergence("not_requested"); + const pendingTargetFields = Number.isInteger(targetConvergence.pendingTargetFields) + ? targetConvergence.pendingTargetFields + : 0; + return { + status: sanitizeString(plan.status), + deployCommitId: shortCommit(deployCommitId), + fullDeployCommitId: deployCommitId, + targetConvergence: { + state: sanitizeString(targetConvergence.state), + comparableFields: Number.isInteger(targetConvergence.comparableFields) ? targetConvergence.comparableFields : 0, + matchingTargetFields: Number.isInteger(targetConvergence.matchingTargetFields) ? targetConvergence.matchingTargetFields : 0, + pendingTargetFields + }, + matchesTarget: commitsMatch(deployCommitId, target.commitId) && pendingTargetFields === 0 + }; +} + +function summarizeRuntimeSources(runtimeReport, liveRuntime) { + const reportApi = normalizeRuntimeIdentity(runtimeReport?.runtimeIdentity, "report-snapshot"); + const live = liveRuntime ?? { + mode: "missing", + endpoints: {}, + api: null, + cloudWeb: null + }; + return { + live: { + mode: sanitizeString(live.mode), + endpoints: live.endpoints ?? {} + }, + reportSnapshot: { + api: reportApi, + status: sanitizeString(runtimeReport?.status) + }, + api: { + live: normalizeRuntimeIdentity(live.api, "live-http"), + reportSnapshot: reportApi, + effective: normalizeRuntimeIdentity(live.api, "live-http") + }, + cloudWeb: { + live: normalizeRuntimeIdentity(live.cloudWeb, "live-http"), + reportSnapshot: normalizeRuntimeIdentity(runtimeReport?.cloudWebRuntimeIdentity, "report-snapshot"), + effective: normalizeRuntimeIdentity(live.cloudWeb, "live-http") + } + }; +} + +function summarizeArtifactService(service) { + if (!service) { + return { + status: "missing", + published: false, + imageTag: "unknown", + digest: "unknown", + digestStatus: "missing", + distFreshnessStatus: "missing" + }; + } + const digest = sanitizeString(service.digest); + return { + status: sanitizeString(service.status), + published: service.status === "published", + imageTag: sanitizeString(service.imageTag), + digest, + digestStatus: digestPattern.test(digest) ? "sha256" : digest === "not_published" ? "not_published" : "invalid", + distFreshnessStatus: service.distFreshness?.status ? sanitizeString(service.distFreshness.status) : "missing" + }; +} + +function countDigests(services) { + const counts = { + sha256: 0, + notPublished: 0, + invalid: 0 + }; + for (const service of services) { + const digest = service?.digest; + if (digestPattern.test(String(digest ?? ""))) { + counts.sha256 += 1; + } else if (digest === "not_published" || digest == null) { + counts.notPublished += 1; + } else { + counts.invalid += 1; + } + } + return counts; +} + +async function observeHealthLive(endpoint, { evidenceSource, timeoutMs }) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(endpoint, { + method: "GET", + headers: { accept: "application/json" }, + signal: controller.signal + }); + const text = await response.text(); + const body = parseJson(text); + if (!response.ok || !body) { + return notObservedRuntimeIdentity( + endpoint, + body ? `HTTP ${response.status}` : `HTTP ${response.status}; response was not JSON`, + evidenceSource, + response.status + ); + } + return observedRuntimeIdentity(endpoint, body, evidenceSource, response.status); + } catch (error) { + return notObservedRuntimeIdentity( + endpoint, + error instanceof Error ? error.message : String(error), + evidenceSource + ); + } finally { + clearTimeout(timeout); + } +} + +function observedRuntimeIdentity(endpoint, body, evidenceSource, httpStatus) { + const commitId = body.commit?.id ?? body.commitId ?? body.revision ?? body.runtime?.commitId; + const imageTag = body.image?.tag ?? body.imageTag ?? body.runtime?.imageTag; + const imageDigest = body.image?.digest ?? body.imageDigest ?? body.runtime?.imageDigest; + return { + status: "observed", + observed: true, + source: "health-live", + evidenceSource, + endpoint, + serviceId: sanitizeString(body.serviceId), + environment: sanitizeString(body.environment), + healthStatus: sanitizeString(body.status), + artifactKind: sanitizeString(body.artifactKind), + commitId: normalizeCommit(commitId), + shortCommitId: shortCommit(commitId), + commitSource: sanitizeString(body.commit?.source ?? body.commitSource ?? (body.revision ? "runtime-env" : "unknown")), + imageTag: sanitizeString(imageTag), + imageDigest: sanitizeString(imageDigest), + observedAt: sanitizeTimestamp(body.observedAt), + httpStatus, + reason: null + }; +} + +function notObservedRuntimeIdentity(endpoint, reason, evidenceSource, httpStatus = null) { + return { + status: "not_observed", + observed: false, + source: "health-live", + evidenceSource, + endpoint, + serviceId: "not_observed", + environment: "not_observed", + healthStatus: "not_observed", + artifactKind: "not_observed", + commitId: "unknown", + shortCommitId: "unknown", + commitSource: "not_observed", + imageTag: "not_observed", + imageDigest: "not_observed", + observedAt: new Date().toISOString(), + httpStatus, + reason: oneLine(reason) + }; +} + +function normalizeRuntimeIdentity(identity, evidenceSource) { + if (!identity || identity.status !== "observed") { + return notObservedRuntimeIdentity(identity?.endpoint ?? "unknown", identity?.reason ?? "runtime identity was not observed", evidenceSource); + } + return { + ...identity, + observed: true, + evidenceSource: identity.evidenceSource ?? evidenceSource, + commitId: normalizeCommit(identity.commitId ?? identity.revision), + shortCommitId: shortCommit(identity.commitId ?? identity.revision), + serviceId: sanitizeString(identity.serviceId), + environment: sanitizeString(identity.environment), + healthStatus: sanitizeString(identity.healthStatus ?? identity.status) + }; +} + +function missingArtifactReport() { + return { + status: "missing", + sourceCommitId: "unknown", + shortCommitId: "unknown", + registryPrefix: "unknown", + serviceCount: 0, + requiredServiceCount: 0, + publishedRequiredCount: 0, + allRequiredServicesPublished: false, + publishVerified: false, + digestCounts: { sha256: 0, notPublished: 0, invalid: 0 }, + cloudWeb: summarizeArtifactService(null) + }; +} + +function missingArtifactCatalog() { + return { + artifactState: "missing", + commitId: "unknown", + shortCommitId: "unknown", + ciPublished: false, + registryVerified: false, + serviceCount: 0, + requiredServiceCount: 0, + publishedRequiredCount: 0, + digestCounts: { sha256: 0, notPublished: 0, invalid: 0 }, + publishVerified: false + }; +} + +function emptyConvergence(state) { + return { + state, + comparableFields: 0, + matchingTargetFields: 0, + pendingTargetFields: 0 + }; +} + +function addCheck(checks, { id, pass, type, summary, blockedSummary = null, nextTask }) { + checks.push({ + id, + status: pass ? "pass" : "blocked", + type, + summary: pass ? summary : (blockedSummary ?? summary), + nextTask + }); +} + +async function readJson(repoRoot, relativePath) { + const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); + return JSON.parse(raw); +} + +async function readOptionalJson(repoRoot, relativePath) { + try { + return await readJson(repoRoot, relativePath); + } catch { + return null; + } +} + +function resolveTargetCommit(repoRoot, ref) { + try { + return execFileSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 5000 + }).trim(); + } catch { + return "unknown"; + } +} + +function parseArgs(argv) { + const args = { + targetRef: defaultTargetRef, + artifactReport: defaultArtifactReport, + artifactCatalog: defaultArtifactCatalog, + runtimeReport: defaultRuntimeReport, + report: defaultOutputReport, + timeoutMs: 5000, + live: true, + writeReport: false, + check: false, + expectBlocked: false, + pretty: false, + help: false + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--target-ref") { + args.targetRef = requireValue(argv, index, arg); + index += 1; + } else if (arg === "--artifact-report") { + args.artifactReport = requireValue(argv, index, arg); + index += 1; + } else if (arg === "--artifact-catalog") { + args.artifactCatalog = requireValue(argv, index, arg); + index += 1; + } else if (arg === "--runtime-report") { + args.runtimeReport = requireValue(argv, index, arg); + index += 1; + } else if (arg === "--report") { + args.report = requireValue(argv, index, arg); + index += 1; + } else if (arg === "--timeout-ms") { + args.timeoutMs = Number.parseInt(requireValue(argv, index, arg), 10); + index += 1; + } else if (arg === "--write-report") { + args.writeReport = true; + } else if (arg === "--no-report") { + args.writeReport = false; + } else if (arg === "--no-live") { + args.live = false; + } else if (arg === "--check") { + args.check = true; + } else if (arg === "--expect-blocked") { + args.expectBlocked = true; + } else if (arg === "--pretty") { + args.pretty = true; + } else if (arg === "--help" || arg === "-h") { + args.help = true; + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + + if (!Number.isInteger(args.timeoutMs) || args.timeoutMs <= 0) { + throw new Error("--timeout-ms must be a positive integer"); + } + return args; +} + +function requireValue(argv, index, arg) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${arg} requires a value`); + } + return value; +} + +function usage() { + return [ + `usage: ${guardCommand} [--write-report] [--no-live] [--expect-blocked]`, + "", + "Read-only latest-main DEV artifact/runtime readiness guard.", + "", + "The command compares target main, artifact publish/catalog identity, desired-state convergence,", + "Cloud API /health/live identity, and Cloud Web :16666 /health/live identity.", + "It does not deploy, publish, apply, restart services, mutate k8s, touch PROD, or read secrets." + ].join("\n"); +} + +function summaryForCheck(report) { + return { + status: report.status, + target: report.artifactRuntimeReadiness.target.shortCommitId, + desiredState: { + deployCommitId: report.artifactRuntimeReadiness.desiredState.deployCommitId, + convergence: report.artifactRuntimeReadiness.desiredState.targetConvergence.state + }, + artifact: { + sourceCommitId: report.artifactRuntimeReadiness.artifact.shortCommitId, + catalogCommitId: report.artifactRuntimeReadiness.catalog.shortCommitId, + reportPublished: report.artifactRuntimeReadiness.artifact.publishVerified, + catalogPublished: report.artifactRuntimeReadiness.catalog.publishVerified + }, + runtime: { + api: report.artifactRuntimeReadiness.runtime.api.effective.shortCommitId, + cloudWeb16666: report.artifactRuntimeReadiness.runtime.cloudWeb.effective.shortCommitId + }, + blockedReasons: report.blockedReasons + }; +} + +function sanitizeString(value) { + return typeof value === "string" && value.trim().length > 0 ? oneLine(value) : "unknown"; +} + +function sanitizeTimestamp(value) { + return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : new Date().toISOString(); +} + +function parseJson(text) { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function oneLine(value) { + return String(value ?? "unknown").replace(/\s+/gu, " ").trim().slice(0, 240) || "unknown"; +}