fix: add deploy desired-state plan (#87)
Commander review: read-only desired-state planner/checker only. Verified no PROD/apply/publish/restart path and no DEV-LIVE claim. Local review ran node --check, focused node tests, desired-state --check, target-tag plan, and diff hygiene.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import {
|
||||
runDeployDesiredStatePlanCli,
|
||||
writeDeployDesiredStatePlanError
|
||||
} from "./src/deploy-desired-state-plan.mjs";
|
||||
|
||||
runDeployDesiredStatePlanCli().catch(writeDeployDesiredStatePlanError);
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user