fix: add deploy desired-state plan

This commit is contained in:
HWLAB Code Queue
2026-05-22 06:59:29 +00:00
parent 8e89409dda
commit 9982b40010
8 changed files with 977 additions and 4 deletions
+22
View File
@@ -37,6 +37,28 @@ does not call `kubectl apply`, does not restart services, and does not touch
PROD. Later work can wire the same source fields into `hwlab edge apply` or
`deploy apply`.
Desired-state commit/image convergence review:
```sh
node scripts/deploy-desired-state-plan.mjs --pretty
node scripts/deploy-desired-state-plan.mjs --check
node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --pretty
node scripts/deploy-desired-state-plan.mjs --target-tag <tag> --pretty
```
The desired-state plan reads only `deploy/deploy.json`,
`deploy/artifact-catalog.dev.json`, `deploy/k8s/base/workloads.yaml`, and the
optional artifact report snapshot. It checks that commit IDs, service image
tags, workload images, and present env mirrors such as `HWLAB_COMMIT_ID`,
`HWLAB_IMAGE`, `HWLAB_IMAGE_TAG`, and `HWLAB_SKILLS_COMMIT_ID` converge. With
`--target-ref` or `--target-tag`, a uniform older desired-state is reported as a
read-only promotion plan; `--check` fails only for missing/invalid source files,
internal mirror drift, invalid target tags, or partial target drift.
This is source/dry-run support only. It does not prove a registry image exists,
does not build, pull, push, apply, restart, or touch PROD, and must not be used
as M3 DEV-LIVE evidence.
Next DEV deploy smoke commands, for a separately authorized deployment task:
```sh
+38 -2
View File
@@ -37,6 +37,7 @@ metadata, then be copied into the catalog by the refresh command below.
| `deploy/artifact-catalog.dev.json` | Machine-readable DEV artifact catalog sample and static source of truth for the skeleton. |
| `deploy/deploy.json` | DEV deploy manifest skeleton that the catalog explains. |
| `CI.json` | Lightweight command and forbidden-action skeleton. |
| `scripts/deploy-desired-state-plan.mjs` | Read-only planner/checker for commit, image tag, workload image, and env mirror convergence across deploy desired-state files. |
| `scripts/refresh-artifact-catalog.mjs` | Refreshes source/artifact commit tags and records either blocked `not_published` state or proven publish digests. |
| `scripts/validate-artifact-catalog.mjs` | Local static validator for the catalog, deploy manifest, and CI forbidden list. |
| `scripts/preflight-dev-base-image.mjs` | Local DEV builder base-image preflight for future artifact publish. |
@@ -68,6 +69,36 @@ and the image tag. If a later target ref changes artifact build inputs after the
publish source, the DEV gate preflight may reopen `artifact-source-commit` even
when the base-image blocker is already closed.
## Desired-State Plan
Use the read-only planner before refreshing or reviewing deploy desired-state:
```sh
node scripts/deploy-desired-state-plan.mjs --pretty
node scripts/deploy-desired-state-plan.mjs --check
node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --pretty
node scripts/deploy-desired-state-plan.mjs --target-tag <tag> --pretty
```
The planner treats `deploy/deploy.json`,
`deploy/artifact-catalog.dev.json`, and `deploy/k8s/base/workloads.yaml` as the
authoritative source desired-state. It reports top-level commit IDs, catalog
service `commitId`/`image`/`imageTag`, deploy service image tags, workload
images, and present env mirrors including `HWLAB_COMMIT_ID`, `HWLAB_IMAGE`,
`HWLAB_IMAGE_TAG`, and `HWLAB_SKILLS_COMMIT_ID`.
`--check` exits non-zero only when the source files are missing or invalid, the
desired-state has internal partial drift, an explicit target tag is invalid or
mutable, or some authoritative fields already point at a target while others do
not. A uniform older state under `--target-ref` is a promotion review plan, not a
failure. The optional report snapshot is contextual evidence only and is not an
authoritative desired-state source.
The planner does not prove registry existence, publish success, DEV apply, or
M3 DEV-LIVE. Only real DEV observation of
`res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1` with operation,
trace, audit, and evidence records can be called M3 DEV-LIVE.
## Refresh Workflow
When the source commit changes and no publish report proves digests, refresh the
@@ -132,6 +163,10 @@ publish.
Run the local static validator:
```sh
node --check scripts/deploy-desired-state-plan.mjs
node --check scripts/src/deploy-desired-state-plan.mjs
node --test scripts/deploy-desired-state-plan.test.mjs
node scripts/deploy-desired-state-plan.mjs --check
node --check scripts/validate-artifact-catalog.mjs
node --check scripts/refresh-artifact-catalog.mjs
node --check scripts/preflight-dev-base-image.mjs
@@ -140,5 +175,6 @@ node scripts/validate-artifact-catalog.mjs
```
The validator and refresh command read only local repository files and optional
publish reports. They do not contact a registry, read secrets, build images,
push images, deploy to DEV, or deploy to PROD.
publish reports. The desired-state planner has the same read-only boundary.
These commands do not contact a registry, read secrets, build images, push
images, deploy to DEV, or deploy to PROD.
+13
View File
@@ -10,6 +10,9 @@ not be used for PROD.
- Work from latest `origin/main`.
- Read only HWLAB repository deploy inputs: `deploy/deploy.json`,
`deploy/artifact-catalog.dev.json`, and `deploy/k8s/dev`.
- Use `scripts/deploy-desired-state-plan.mjs` as source/dry-run support to
review commit, image, tag, workload image, and env mirror convergence before
any apply review.
- Validate artifact identity, image commit tags, Kubernetes workload/service
shape, DEV health, and cloud-api DB connectivity.
- Emit an operator-readable plan in `devDeployApply`: plan/apply boundary,
@@ -28,6 +31,7 @@ Run a preflight that is expected to report blockers on the current skeleton:
```sh
node --check scripts/dev-deploy-apply.mjs
node --check scripts/src/dev-deploy-apply.mjs
node scripts/deploy-desired-state-plan.mjs --check
node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --write-report
node scripts/validate-dev-gate-report.mjs
```
@@ -49,6 +53,10 @@ 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. It is a
read-only source planner and does not prove registry image existence, DEV
cluster pullability, DEV apply, service restart, or M3 DEV-LIVE evidence.
## Report Contract
The machine-readable result is written to
@@ -141,3 +149,8 @@ config without printing the connection string.
No PROD resource, secret value, UniDesk runtime substitute, browser e2e,
heavyweight e2e, or force push is part of this flow.
Source plans, dry-run reports, and desired-state convergence checks are M3
support only. They must not be labelled DEV-LIVE without a real DEV observation
of the `DO1 -> patch-panel -> DI1` loop plus operation, trace, audit, and
evidence records.
+8
View File
@@ -16,7 +16,10 @@ Run from the repository root:
node --check scripts/dev-gate-preflight.mjs
node --check scripts/src/dev-gate-preflight.mjs
node --check scripts/src/registry-capabilities.mjs
node --check scripts/deploy-desired-state-plan.mjs
node --check scripts/src/deploy-desired-state-plan.mjs
node --check scripts/refresh-artifact-catalog.mjs
node scripts/deploy-desired-state-plan.mjs --check
node scripts/dev-gate-preflight.mjs
```
@@ -41,6 +44,9 @@ The preflight checks:
- `deploy/deploy.json` and `deploy/artifact-catalog.dev.json` are internally
consistent and DEV-only.
- `scripts/deploy-desired-state-plan.mjs --check` can be run as a read-only
source support check for deploy commit/image/tag/workload/env mirror
convergence. It does not contact a registry or prove DEV runtime state.
- The deploy manifest, artifact catalog, catalog service commits, and image tags
are covered by the selected `origin/main` target. If `origin/main` is a
report-only evidence commit whose changes do not touch artifact build inputs,
@@ -115,6 +121,7 @@ For catalog commit mismatch, the preflight emits an
commit changed but publish is still blocked:
```sh
node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --pretty
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked
```
@@ -122,6 +129,7 @@ Only use the published refresh path after `reports/dev-gate/dev-artifacts.json`
proves every frozen service has a registry digest for that same source commit:
```sh
node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --pretty
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report reports/dev-gate/dev-artifacts.json
```
+4 -2
View File
@@ -4,8 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.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/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.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/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-gate-report.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/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 scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && 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 --test scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.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",
"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/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.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/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/validate-dev-gate-report.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/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 scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.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-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 --test scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.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",
@@ -15,6 +15,8 @@
"dev-edge:smoke": "node scripts/dev-edge-health-smoke.mjs",
"deploy:contract:plan": "node scripts/deploy-contract-plan.mjs --pretty",
"deploy:contract:check": "node scripts/deploy-contract-plan.mjs --check",
"deploy:desired-state:plan": "node scripts/deploy-desired-state-plan.mjs --pretty",
"deploy:desired-state:check": "node scripts/deploy-desired-state-plan.mjs --check",
"cli:health": "node tools/hwlab-cli/bin/hwlab-cli.mjs health",
"cli:dry-run": "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp",
"cli:projects": "node tools/hwlab-cli/bin/hwlab-cli.mjs project list",
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env node
import {
runDeployDesiredStatePlanCli,
writeDeployDesiredStatePlanError
} from "./src/deploy-desired-state-plan.mjs";
runDeployDesiredStatePlanCli().catch(writeDeployDesiredStatePlanError);
+142
View File
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { buildDesiredStatePlan } from "./src/deploy-desired-state-plan.mjs";
async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workloadEnvTag = commitId } = {}) {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-desired-state-"));
await mkdir(path.join(root, "deploy/k8s/base"), { recursive: true });
await mkdir(path.join(root, "reports/dev-gate"), { recursive: true });
const image = `127.0.0.1:5000/hwlab/hwlab-cloud-api:${commitId}`;
const workloadImage = `127.0.0.1:5000/hwlab/hwlab-cloud-api:${workloadTag}`;
const deploy = {
manifestVersion: "v1",
environment: "dev",
commitId,
services: [
{
serviceId: "hwlab-cloud-api",
image,
namespace: "hwlab-dev",
healthPath: "/health/live",
profile: "dev",
replicas: 1,
env: {
HWLAB_COMMIT_ID: commitId,
HWLAB_IMAGE: image,
HWLAB_IMAGE_TAG: commitId
}
}
]
};
const catalog = {
catalogVersion: "v1",
kind: "hwlab-artifact-catalog",
environment: "dev",
profile: "dev",
namespace: "hwlab-dev",
endpoint: "http://74.48.78.17:16667",
commitId,
artifactState: "contract-skeleton",
publish: {
ciPublished: false,
registryVerified: false,
provenance: "not_available_until_publish"
},
services: [
{
serviceId: "hwlab-cloud-api",
commitId,
image,
imageTag: commitId,
digest: "not_published",
publishState: "skeleton-only",
artifactRequired: true
}
]
};
const workloads = {
apiVersion: "v1",
kind: "List",
items: [
{
apiVersion: "apps/v1",
kind: "Deployment",
metadata: {
name: "hwlab-cloud-api",
namespace: "hwlab-dev",
labels: {
"hwlab.pikastech.local/service-id": "hwlab-cloud-api"
}
},
spec: {
template: {
spec: {
containers: [
{
name: "hwlab-cloud-api",
image: workloadImage,
env: [
{ name: "HWLAB_COMMIT_ID", value: commitId },
{ name: "HWLAB_IMAGE", value: image },
{ name: "HWLAB_IMAGE_TAG", value: workloadEnvTag }
]
}
]
}
}
}
}
]
};
await writeFile(path.join(root, "deploy/deploy.json"), `${JSON.stringify(deploy, null, 2)}\n`);
await writeFile(path.join(root, "deploy/artifact-catalog.dev.json"), `${JSON.stringify(catalog, null, 2)}\n`);
await writeFile(path.join(root, "deploy/k8s/base/workloads.yaml"), `${JSON.stringify(workloads, null, 2)}\n`);
return root;
}
test("passes internally consistent desired-state", async () => {
const repoRoot = await makeFixture();
const plan = await buildDesiredStatePlan({ repoRoot });
assert.equal(plan.status, "pass");
assert.equal(plan.summary.desiredCommitId, "abc1234");
assert.equal(plan.summary.presentMirrorCount, 6);
assert.deepEqual(plan.diagnostics, []);
});
test("target tag review is read-only promotion_pending when current state is uniformly older", async () => {
const repoRoot = await makeFixture();
const plan = await buildDesiredStatePlan({ repoRoot, targetTag: "def5678" });
assert.equal(plan.status, "planned");
assert.equal(plan.target.convergence.state, "promotion_pending");
assert.equal(plan.summary.blockers, 0);
assert.match(plan.services[0].promotion.deployImage, /:def5678$/u);
});
test("blocks on mirror drift", async () => {
const repoRoot = await makeFixture({ workloadEnvTag: "badcafe" });
const plan = await buildDesiredStatePlan({ repoRoot });
assert.equal(plan.status, "blocked");
assert.equal(plan.summary.blockers, 1);
assert.equal(plan.diagnostics[0].code, "mirror_mismatch");
assert.match(plan.diagnostics[0].path, /HWLAB_IMAGE_TAG/u);
});
test("blocks on workload image drift", async () => {
const repoRoot = await makeFixture({ workloadTag: "badcafe" });
const plan = await buildDesiredStatePlan({ repoRoot });
assert.equal(plan.status, "blocked");
assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "image_tag_mismatch"));
assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "image_mismatch"));
});
test("reports partial target drift as a blocker", async () => {
const repoRoot = await makeFixture({ workloadTag: "def5678", workloadEnvTag: "def5678" });
const plan = await buildDesiredStatePlan({ repoRoot, targetTag: "def5678" });
assert.equal(plan.status, "blocked");
assert.equal(plan.target.convergence.state, "partial_drift");
assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "partial_target_drift"));
});
+743
View File
@@ -0,0 +1,743 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const deployPath = "deploy/deploy.json";
const catalogPath = "deploy/artifact-catalog.dev.json";
const workloadsPath = "deploy/k8s/base/workloads.yaml";
const artifactReportPath = "reports/dev-gate/dev-artifacts.json";
const mutableTags = new Set(["latest", "dev", "main", "master", "prod", "production"]);
const mirrorEnvNames = [
"HWLAB_COMMIT_ID",
"HWLAB_IMAGE",
"HWLAB_IMAGE_TAG",
"HWLAB_SKILLS_COMMIT_ID",
"HWLAB_IMAGE_DIGEST"
];
const commitMirrorEnvNames = new Set(["HWLAB_COMMIT_ID", "HWLAB_SKILLS_COMMIT_ID"]);
const imageMirrorEnvNames = new Set(["HWLAB_IMAGE"]);
const tagMirrorEnvNames = new Set(["HWLAB_IMAGE_TAG"]);
const digestMirrorEnvNames = new Set(["HWLAB_IMAGE_DIGEST"]);
const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
const commitPattern = /^[a-f0-9]{7,40}$/;
function parseArgs(argv) {
const args = { check: false, pretty: false, targetRef: null, targetTag: null, help: false };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--check") {
args.check = true;
} else if (arg === "--pretty") {
args.pretty = true;
} else if (arg === "--target-ref") {
args.targetRef = requireValue(argv, index, arg);
index += 1;
} else if (arg === "--target-tag") {
args.targetTag = requireValue(argv, index, arg);
index += 1;
} else if (arg === "--help" || arg === "-h") {
args.help = true;
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
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: node scripts/deploy-desired-state-plan.mjs [--check] [--pretty] [--target-ref REF] [--target-tag TAG]",
"",
"Read-only DEV desired-state commit/image planner.",
"",
"Options:",
" --check exit non-zero only when desired-state sources are missing, invalid, or partially drifted",
" --pretty print indented JSON",
" --target-ref REF resolve REF with git and review promotion to its short commit tag",
" --target-tag TAG review promotion to an explicit image tag without proving registry existence",
"",
"This command reads repository files only. It does not build, pull, push, kubectl apply, restart services, or touch PROD."
].join("\n");
}
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 parseImageRef(image) {
if (typeof image !== "string" || image.length === 0) {
return { valid: false, repository: null, tag: null, reason: "image must be a non-empty string" };
}
if (image.includes("@")) {
return { valid: false, repository: null, tag: null, reason: "digest-qualified image refs are not desired-state tags" };
}
const slashIndex = image.lastIndexOf("/");
const colonIndex = image.lastIndexOf(":");
if (colonIndex <= slashIndex) {
return { valid: false, repository: image, tag: null, reason: "image ref is missing a tag" };
}
const repository = image.slice(0, colonIndex);
const tag = image.slice(colonIndex + 1);
if (!repository || !tag) {
return { valid: false, repository, tag, reason: "image repository and tag must be non-empty" };
}
return { valid: true, repository, tag, reason: null };
}
function replaceImageTag(image, targetTag) {
const parsed = parseImageRef(image);
return parsed.valid ? `${parsed.repository}:${targetTag}` : null;
}
function shortCommit(value) {
return typeof value === "string" && value.length >= 7 ? value.slice(0, 7) : value;
}
function commitEquivalent(actual, expected) {
if (typeof actual !== "string" || typeof expected !== "string") return false;
return actual === expected || actual === expected.slice(0, 7) || expected === actual.slice(0, 7);
}
function listItems(document) {
if (!document) return [];
return document.kind === "List" ? document.items ?? [] : [document];
}
function serviceIdForWorkload(item, container) {
return (
item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ||
item?.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ||
container?.name ||
item?.metadata?.name
);
}
function workloadContainers(item) {
return item?.spec?.template?.spec?.containers ?? [];
}
function envListToObject(envList) {
const env = {};
if (!Array.isArray(envList)) return env;
for (const entry of envList) {
if (!entry?.name) continue;
if (Object.hasOwn(entry, "value")) {
env[entry.name] = entry.value;
} else if (entry.valueFrom) {
env[entry.name] = { valueFrom: entry.valueFrom };
}
}
return env;
}
function addDiagnostic(ctx, diagnostic) {
ctx.diagnostics.push({
severity: "blocker",
...diagnostic
});
}
function addMismatch(ctx, code, sourcePath, message, expected, actual, extra = {}) {
addDiagnostic(ctx, {
code,
path: sourcePath,
message,
expected,
actual,
...extra
});
}
function recordObservation(ctx, observation) {
ctx.observations.push(observation);
}
function expectedEnvValue(name, service, desiredCommitId) {
if (name === "HWLAB_COMMIT_ID" || name === "HWLAB_SKILLS_COMMIT_ID") return desiredCommitId;
if (name === "HWLAB_IMAGE") return service?.image ?? null;
if (name === "HWLAB_IMAGE_TAG") return service?.imageTag ?? null;
if (name === "HWLAB_IMAGE_DIGEST") return service?.digest ?? "not_published";
return null;
}
function observationKindForEnv(name) {
if (commitMirrorEnvNames.has(name)) return "commit";
if (imageMirrorEnvNames.has(name)) return "image";
if (tagMirrorEnvNames.has(name)) return "tag";
if (digestMirrorEnvNames.has(name)) return "digest";
return "unknown";
}
function valueMatchesExpected(kind, actual, expected) {
if (expected === null || expected === undefined) return true;
if (kind === "commit") return commitEquivalent(actual, expected);
return Object.is(actual, expected);
}
function inspectEnvMirrors(ctx, { source, basePath, serviceId, env, service, desiredCommitId }) {
const present = {};
const missing = [];
const diagnostics = [];
for (const name of mirrorEnvNames) {
if (!Object.hasOwn(env ?? {}, name)) {
missing.push(name);
continue;
}
const actual = env[name];
const kind = observationKindForEnv(name);
const expected = expectedEnvValue(name, service, desiredCommitId);
const record = {
name,
value: actual,
expected,
status: valueMatchesExpected(kind, actual, expected) ? "match" : "drift"
};
present[name] = record;
recordObservation(ctx, {
source,
serviceId,
path: `${basePath}.${name}`,
field: name,
kind,
value: actual,
expected
});
if (record.status === "drift") {
const message = `${source} ${serviceId} ${name} mirror does not match desired ${kind}`;
const diagnostic = {
code: "mirror_mismatch",
path: `${basePath}.${name}`,
message,
expected,
actual
};
diagnostics.push(diagnostic);
addDiagnostic(ctx, diagnostic);
}
}
return { present, missing, diagnostics };
}
function indexWorkloads(workloads) {
const byService = new Map();
for (const item of listItems(workloads)) {
for (const [containerIndex, container] of workloadContainers(item).entries()) {
const serviceId = serviceIdForWorkload(item, container);
if (!serviceId) continue;
const record = {
kind: item?.kind ?? "unknown",
name: item?.metadata?.name ?? "unknown",
namespace: item?.metadata?.namespace ?? null,
containerIndex,
containerName: container?.name ?? "unknown",
image: container?.image ?? null,
env: envListToObject(container?.env),
path: `${workloadsPath}.${item?.kind ?? "Workload"}.${item?.metadata?.name ?? serviceId}.containers[${containerIndex}]`
};
const list = byService.get(serviceId) ?? [];
list.push(record);
byService.set(serviceId, list);
}
}
return byService;
}
function serviceIdsFrom(...sources) {
const ids = [];
for (const source of sources) {
for (const id of source) {
if (typeof id === "string" && id.length > 0 && !ids.includes(id)) ids.push(id);
}
}
return ids;
}
function assertTargetTag(ctx, tag, pathName) {
if (!tagPattern.test(tag)) {
addMismatch(ctx, "invalid_target_tag", pathName, "target tag is not a valid Docker tag", "Docker tag syntax", tag);
}
if (mutableTags.has(tag)) {
addMismatch(ctx, "mutable_target_tag", pathName, `target tag ${tag} is mutable and forbidden for desired-state pinning`, "immutable tag", tag);
}
}
async function resolveTarget(repoRoot, { targetRef, targetTag }, ctx) {
if (!targetRef && !targetTag) return null;
let commitId = null;
let shortCommitId = null;
if (targetRef) {
try {
const result = await execFileAsync("git", ["rev-parse", "--verify", `${targetRef}^{commit}`], { cwd: repoRoot });
commitId = result.stdout.trim();
shortCommitId = commitId.slice(0, 7);
} catch (error) {
addDiagnostic(ctx, {
code: "target_ref_unresolved",
path: "--target-ref",
message: `target ref ${targetRef} could not be resolved with git`,
expected: "local git ref",
actual: error instanceof Error ? error.message : String(error)
});
}
}
const tag = targetTag ?? shortCommitId;
if (tag) assertTargetTag(ctx, tag, "--target-tag");
return {
targetRef,
commitId,
shortCommitId,
tag,
tagSource: targetTag ? "target-tag" : "target-ref-short-commit"
};
}
function observationMatchesTarget(observation, target) {
if (!target) return null;
if (observation.kind === "commit") {
return target.commitId ? commitEquivalent(observation.value, target.commitId) : null;
}
if (observation.kind === "tag") {
return target.tag ? observation.value === target.tag : null;
}
if (observation.kind === "image") {
return target.tag ? parseImageRef(observation.value).tag === target.tag : null;
}
return null;
}
function buildTargetConvergence(ctx, target) {
if (!target) return null;
if (target.targetRef && !target.commitId) {
return {
state: "target_unresolved",
comparableFields: 0,
matchingTargetFields: 0,
pendingTargetFields: 0,
matchingPaths: [],
pendingPaths: []
};
}
const comparable = ctx.observations
.map((observation) => ({
...observation,
matchesTarget: observationMatchesTarget(observation, target)
}))
.filter((observation) => observation.matchesTarget !== null);
const matching = comparable.filter((observation) => observation.matchesTarget === true);
const pending = comparable.filter((observation) => observation.matchesTarget === false);
const partial = matching.length > 0 && pending.length > 0;
if (partial) {
addDiagnostic(ctx, {
code: "partial_target_drift",
path: "desired-state",
message: "desired-state fields partially match the target while other authoritative fields still point elsewhere",
expected: target.tag,
actual: {
matching: matching.length,
pending: pending.length
}
});
}
const state = partial
? "partial_drift"
: pending.length === 0
? "already_promoted"
: "promotion_pending";
return {
state,
comparableFields: comparable.length,
matchingTargetFields: matching.length,
pendingTargetFields: pending.length,
matchingPaths: matching.map((item) => item.path),
pendingPaths: pending.map((item) => item.path)
};
}
function reportHints(report) {
if (!report) {
return {
path: artifactReportPath,
present: false,
authoritative: false,
note: "optional report snapshot is absent; desired-state review still uses deploy files only"
};
}
const artifactPublish = report.artifactPublish ?? {};
return {
path: artifactReportPath,
present: true,
authoritative: false,
note: "report snapshots are contextual evidence only; deploy desired-state remains authoritative in deploy/, not reports/",
commitId: report.commitId ?? null,
artifactPublish: {
status: artifactPublish.status ?? null,
sourceCommitId: artifactPublish.sourceCommitId ?? null,
publishedCount: artifactPublish.publishedCount ?? null,
serviceCount: artifactPublish.serviceCount ?? null,
registryVerified: artifactPublish.registryVerified ?? null,
registryCapabilitiesClassification: artifactPublish.registryCapabilities?.classification ?? null
}
};
}
function servicePromotion(service, target) {
if (!target?.tag) return null;
return {
targetCommitId: target.commitId,
targetImageTag: target.tag,
deployImage: service.deploy?.image ? replaceImageTag(service.deploy.image, target.tag) : null,
catalogImage: service.catalog?.image ? replaceImageTag(service.catalog.image, target.tag) : null,
workloadImages: (service.workloads ?? []).map((workload) => ({
workload: workload.name,
container: workload.container,
image: workload.image ? replaceImageTag(workload.image, target.tag) : null
}))
};
}
function targetCommands(target) {
if (!target?.targetRef) return [];
return [
`node scripts/deploy-desired-state-plan.mjs --target-ref ${target.targetRef} --pretty`,
`node scripts/refresh-artifact-catalog.mjs --target-ref ${target.targetRef} --blocked`,
`node scripts/refresh-artifact-catalog.mjs --target-ref ${target.targetRef} --publish-report ${artifactReportPath}`
];
}
export async function buildDesiredStatePlan(options = {}) {
const repoRoot = options.repoRoot ?? defaultRepoRoot;
const ctx = { diagnostics: [], observations: [] };
const [deploy, catalog, workloads, artifactReport] = await Promise.all([
readJson(repoRoot, deployPath),
readJson(repoRoot, catalogPath),
readJson(repoRoot, workloadsPath),
readOptionalJson(repoRoot, artifactReportPath)
]);
const target = await resolveTarget(repoRoot, {
targetRef: options.targetRef ?? null,
targetTag: options.targetTag ?? null
}, ctx);
const deployServices = deploy.services ?? [];
const catalogServices = catalog.services ?? [];
const deployByService = new Map(deployServices.map((service) => [service.serviceId, service]));
const catalogByService = new Map(catalogServices.map((service) => [service.serviceId, service]));
const workloadByService = indexWorkloads(workloads);
const desiredCommitId = deploy.commitId;
const desiredImageTag = shortCommit(desiredCommitId);
recordObservation(ctx, {
source: "deploy",
path: `${deployPath}.commitId`,
field: "commitId",
kind: "commit",
value: deploy.commitId,
expected: desiredCommitId
});
recordObservation(ctx, {
source: "catalog",
path: `${catalogPath}.commitId`,
field: "commitId",
kind: "commit",
value: catalog.commitId,
expected: desiredCommitId
});
if (!commitPattern.test(String(deploy.commitId ?? ""))) {
addMismatch(ctx, "invalid_commit", `${deployPath}.commitId`, "deploy commitId must be a short or full lowercase Git SHA", "7-40 lowercase hex", deploy.commitId);
}
if (!commitEquivalent(catalog.commitId, desiredCommitId)) {
addMismatch(ctx, "commit_mismatch", `${catalogPath}.commitId`, "artifact catalog commitId must match deploy commitId", desiredCommitId, catalog.commitId);
}
const serviceIds = serviceIdsFrom(
deployServices.map((service) => service.serviceId),
catalogServices.map((service) => service.serviceId),
[...workloadByService.keys()]
);
const services = [];
for (const serviceId of serviceIds) {
const deployService = deployByService.get(serviceId);
const catalogService = catalogByService.get(serviceId);
const workloadsForService = workloadByService.get(serviceId) ?? [];
const service = {
serviceId,
deploy: null,
catalog: null,
workloads: [],
promotion: null
};
if (!deployService) {
addMismatch(ctx, "missing_service", `${deployPath}.services.${serviceId}`, `${serviceId} missing from deploy manifest`, "service entry", null, { serviceId });
}
if (!catalogService) {
addMismatch(ctx, "missing_service", `${catalogPath}.services.${serviceId}`, `${serviceId} missing from artifact catalog`, "service entry", null, { serviceId });
}
if (!workloadsForService.length) {
addMismatch(ctx, "missing_workload", `${workloadsPath}.${serviceId}`, `${serviceId} missing from k8s workloads`, "workload container", null, { serviceId });
}
const deployImage = parseImageRef(deployService?.image);
const catalogImage = parseImageRef(catalogService?.image);
const serviceImageTag = catalogService?.imageTag ?? deployImage.tag;
const expectedService = {
image: deployService?.image ?? catalogService?.image ?? null,
imageTag: desiredImageTag,
digest: catalogService?.digest ?? "not_published"
};
if (deployService) {
const deployPathBase = `${deployPath}.services.${serviceId}`;
recordObservation(ctx, {
source: "deploy",
serviceId,
path: `${deployPathBase}.image`,
field: "image",
kind: "image",
value: deployService.image,
expected: catalogService?.image ?? deployService.image
});
if (!deployImage.valid) {
addMismatch(ctx, "invalid_image", `${deployPathBase}.image`, deployImage.reason, "tagged image ref", deployService.image, { serviceId });
} else if (deployImage.tag !== desiredImageTag) {
addMismatch(ctx, "image_tag_mismatch", `${deployPathBase}.image`, `${serviceId} deploy image tag must match deploy commit tag`, desiredImageTag, deployImage.tag, { serviceId });
}
service.deploy = {
image: deployService.image,
imageTag: deployImage.tag,
envMirrors: inspectEnvMirrors(ctx, {
source: "deploy",
basePath: `${deployPathBase}.env`,
serviceId,
env: deployService.env ?? {},
service: expectedService,
desiredCommitId
})
};
}
if (catalogService) {
const catalogPathBase = `${catalogPath}.services.${serviceId}`;
recordObservation(ctx, {
source: "catalog",
serviceId,
path: `${catalogPathBase}.commitId`,
field: "commitId",
kind: "commit",
value: catalogService.commitId,
expected: desiredCommitId
});
recordObservation(ctx, {
source: "catalog",
serviceId,
path: `${catalogPathBase}.image`,
field: "image",
kind: "image",
value: catalogService.image,
expected: deployService?.image ?? catalogService.image
});
recordObservation(ctx, {
source: "catalog",
serviceId,
path: `${catalogPathBase}.imageTag`,
field: "imageTag",
kind: "tag",
value: catalogService.imageTag,
expected: desiredImageTag
});
if (!commitEquivalent(catalogService.commitId, desiredCommitId)) {
addMismatch(ctx, "commit_mismatch", `${catalogPathBase}.commitId`, `${serviceId} catalog service commitId must match deploy commitId`, desiredCommitId, catalogService.commitId, { serviceId });
}
if (!catalogImage.valid) {
addMismatch(ctx, "invalid_image", `${catalogPathBase}.image`, catalogImage.reason, "tagged image ref", catalogService.image, { serviceId });
}
if (catalogService.imageTag !== desiredImageTag) {
addMismatch(ctx, "image_tag_mismatch", `${catalogPathBase}.imageTag`, `${serviceId} catalog imageTag must match deploy commit tag`, desiredImageTag, catalogService.imageTag, { serviceId });
}
if (catalogImage.valid && catalogImage.tag !== catalogService.imageTag) {
addMismatch(ctx, "image_tag_mismatch", `${catalogPathBase}.image`, `${serviceId} catalog image tag must match catalog imageTag`, catalogService.imageTag, catalogImage.tag, { serviceId });
}
if (deployService?.image && catalogService.image !== deployService.image) {
addMismatch(ctx, "image_mismatch", `${catalogPathBase}.image`, `${serviceId} catalog image must match deploy image`, deployService.image, catalogService.image, { serviceId });
}
service.catalog = {
commitId: catalogService.commitId,
image: catalogService.image,
imageTag: catalogService.imageTag,
digest: catalogService.digest,
publishState: catalogService.publishState,
artifactRequired: catalogService.artifactRequired
};
}
for (const workload of workloadsForService) {
const workloadImage = parseImageRef(workload.image);
recordObservation(ctx, {
source: "workload",
serviceId,
path: `${workload.path}.image`,
field: "image",
kind: "image",
value: workload.image,
expected: deployService?.image ?? catalogService?.image ?? null
});
if (!workloadImage.valid) {
addMismatch(ctx, "invalid_image", `${workload.path}.image`, workloadImage.reason, "tagged image ref", workload.image, { serviceId });
} else if (workloadImage.tag !== desiredImageTag) {
addMismatch(ctx, "image_tag_mismatch", `${workload.path}.image`, `${serviceId} workload image tag must match deploy commit tag`, desiredImageTag, workloadImage.tag, { serviceId });
}
if (deployService?.image && workload.image !== deployService.image) {
addMismatch(ctx, "image_mismatch", `${workload.path}.image`, `${serviceId} workload image must match deploy image`, deployService.image, workload.image, { serviceId });
}
service.workloads.push({
kind: workload.kind,
name: workload.name,
namespace: workload.namespace,
container: workload.containerName,
image: workload.image,
imageTag: workloadImage.tag,
envMirrors: inspectEnvMirrors(ctx, {
source: "workload",
basePath: `${workload.path}.env`,
serviceId,
env: workload.env,
service: expectedService,
desiredCommitId
})
});
}
service.promotion = servicePromotion(service, target);
services.push(service);
}
const targetConvergence = buildTargetConvergence(ctx, target);
const blockers = ctx.diagnostics.filter((diagnostic) => diagnostic.severity === "blocker");
const status = blockers.length > 0
? "blocked"
: targetConvergence?.state === "promotion_pending"
? "planned"
: "pass";
const presentMirrorCount = services.reduce((count, service) => {
const deployCount = service.deploy ? Object.keys(service.deploy.envMirrors.present).length : 0;
const workloadCount = service.workloads.reduce((inner, workload) => inner + Object.keys(workload.envMirrors.present).length, 0);
return count + deployCount + workloadCount;
}, 0);
return {
kind: "hwlab-deploy-desired-state-plan",
mode: "read-only-plan",
status,
source: {
deploy: deployPath,
artifactCatalog: catalogPath,
workloads: workloadsPath,
optionalReport: artifactReportPath
},
safety: {
readOnly: true,
sourceOrDryRunSupportOnly: true,
kubectlApply: false,
registryPull: false,
registryPush: false,
imageBuild: false,
serviceRestart: false,
prod: false,
devLiveClaim: false
},
checkSemantics: "--check exits non-zero only for missing/invalid desired-state sources, internal commit/image mirror drift, invalid target tags, or partial target drift. A uniform older desired-state under --target-ref is a read-only promotion plan, not a check failure.",
summary: {
desiredCommitId,
desiredImageTag,
artifactState: catalog.artifactState,
ciPublished: catalog.publish?.ciPublished === true,
registryVerified: catalog.publish?.registryVerified === true,
services: services.length,
workloadContainers: [...workloadByService.values()].reduce((sum, entries) => sum + entries.length, 0),
presentMirrorCount,
diagnostics: ctx.diagnostics.length,
blockers: blockers.length,
targetConvergence: targetConvergence?.state ?? "not_requested"
},
target: target ? {
...target,
convergence: targetConvergence,
commands: targetCommands(target)
} : null,
promotionBoundary: {
writes: [],
authoritativeDesiredState: [deployPath, catalogPath, workloadsPath],
nonAuthoritativeEvidence: [artifactReportPath],
note: "This planner reviews source desired-state only. It does not prove image existence, registry reachability, a real DEV apply, or M3 DEV-LIVE hardware-loop evidence."
},
reportHints: reportHints(artifactReport),
services,
diagnostics: ctx.diagnostics
};
}
export async function runDeployDesiredStatePlanCli(argv = process.argv.slice(2), options = {}) {
const args = parseArgs(argv);
if (args.help) {
process.stdout.write(`${usage()}\n`);
return;
}
const plan = await buildDesiredStatePlan({
repoRoot: options.repoRoot,
targetRef: args.targetRef,
targetTag: args.targetTag
});
process.stdout.write(`${JSON.stringify(plan, null, args.pretty ? 2 : 0)}\n`);
if (args.check && plan.status === "blocked") {
process.exitCode = 1;
}
}
export function writeDeployDesiredStatePlanError(error) {
process.stdout.write(`${JSON.stringify({
kind: "hwlab-deploy-desired-state-plan",
mode: "read-only-plan",
status: "error",
error: error instanceof Error ? error.message : String(error),
safety: {
readOnly: true,
kubectlApply: false,
registryPull: false,
registryPush: false,
imageBuild: false,
serviceRestart: false,
prod: false,
devLiveClaim: false
}
})}\n`);
process.exitCode = 1;
}