From 9982b400100ba8283bb6d86b95c7f276e8fa64c7 Mon Sep 17 00:00:00 2001 From: HWLAB Code Queue Date: Fri, 22 May 2026 06:59:29 +0000 Subject: [PATCH] fix: add deploy desired-state plan --- deploy/README.md | 22 + docs/artifact-catalog.md | 40 +- docs/dev-deploy-apply.md | 13 + docs/dev-gate-preflight.md | 8 + package.json | 6 +- scripts/deploy-desired-state-plan.mjs | 7 + scripts/deploy-desired-state-plan.test.mjs | 142 ++++ scripts/src/deploy-desired-state-plan.mjs | 743 +++++++++++++++++++++ 8 files changed, 977 insertions(+), 4 deletions(-) create mode 100644 scripts/deploy-desired-state-plan.mjs create mode 100644 scripts/deploy-desired-state-plan.test.mjs create mode 100644 scripts/src/deploy-desired-state-plan.mjs diff --git a/deploy/README.md b/deploy/README.md index 715e1f8a..9ead6542 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 --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 diff --git a/docs/artifact-catalog.md b/docs/artifact-catalog.md index 7c39383f..32a2661d 100644 --- a/docs/artifact-catalog.md +++ b/docs/artifact-catalog.md @@ -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 --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. diff --git a/docs/dev-deploy-apply.md b/docs/dev-deploy-apply.md index d4071545..e2e3b6a9 100644 --- a/docs/dev-deploy-apply.md +++ b/docs/dev-deploy-apply.md @@ -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. diff --git a/docs/dev-gate-preflight.md b/docs/dev-gate-preflight.md index 6f8c2e1d..b15e475e 100644 --- a/docs/dev-gate-preflight.md +++ b/docs/dev-gate-preflight.md @@ -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 ``` diff --git a/package.json b/package.json index 26c4966d..7f23bf01 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check", - "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", diff --git a/scripts/deploy-desired-state-plan.mjs b/scripts/deploy-desired-state-plan.mjs new file mode 100644 index 00000000..a4887b31 --- /dev/null +++ b/scripts/deploy-desired-state-plan.mjs @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { + runDeployDesiredStatePlanCli, + writeDeployDesiredStatePlanError +} from "./src/deploy-desired-state-plan.mjs"; + +runDeployDesiredStatePlanCli().catch(writeDeployDesiredStatePlanError); diff --git a/scripts/deploy-desired-state-plan.test.mjs b/scripts/deploy-desired-state-plan.test.mjs new file mode 100644 index 00000000..8ff47ae7 --- /dev/null +++ b/scripts/deploy-desired-state-plan.test.mjs @@ -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")); +}); diff --git a/scripts/src/deploy-desired-state-plan.mjs b/scripts/src/deploy-desired-state-plan.mjs new file mode 100644 index 00000000..da60c4a6 --- /dev/null +++ b/scripts/src/deploy-desired-state-plan.mjs @@ -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; +}