test: guard artifact runtime readiness
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user