From a8d27d546b18d9fd52e461bfb7ecdc9bd01d97f1 Mon Sep 17 00:00:00 2001 From: HWLAB Code Queue Date: Thu, 21 May 2026 17:45:53 +0000 Subject: [PATCH] feat: add dev deploy apply plan report --- docs/dev-deploy-apply.md | 54 ++- reports/dev-gate/dev-deploy-report.json | 542 +++++++++++++++++++++++- scripts/src/dev-deploy-apply.mjs | 205 +++++++++ scripts/validate-dev-gate-report.mjs | 158 +++++++ 4 files changed, 951 insertions(+), 8 deletions(-) diff --git a/docs/dev-deploy-apply.md b/docs/dev-deploy-apply.md index 8b2e7f7e..e6391d39 100644 --- a/docs/dev-deploy-apply.md +++ b/docs/dev-deploy-apply.md @@ -1,7 +1,8 @@ # HWLAB DEV Deploy Apply This document records the DEV-only apply/preflight path for -`pikasTech/HWLAB#33`. It targets only the D601 `hwlab-dev` namespace and must +`pikasTech/HWLAB#33` and the apply-plan/report path for +`pikasTech/HWLAB#50`. It targets only the D601 `hwlab-dev` namespace and must not be used for PROD. ## Scope @@ -11,6 +12,9 @@ not be used for PROD. `deploy/artifact-catalog.dev.json`, and `deploy/k8s/dev`. - 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, + expected artifact commit, target namespace, workloads, services, remaining + blockers, minimal manual commands, and rollback hints. - Stop before mutation when artifact publish evidence, registry digests, `kubectl`, DEV health, or cloud-api DB connectivity are missing. @@ -20,6 +24,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/dev-deploy-apply.mjs --dry-run --expect-blocked --write-report node scripts/validate-dev-gate-report.mjs ``` @@ -41,6 +46,47 @@ 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. +## Report Contract + +The machine-readable result is written to +`reports/dev-gate/dev-deploy-report.json` when `--write-report` is used. The +report remains compatible with the #33 DEV gate validator and adds these +operator fields: + +- `devDeployApply.conclusion`: `blocked` while blockers are open, otherwise + `ready` for human approval. +- `devDeployApply.applyBoundary`: default no-write behavior, required apply + flags, DEV-only write scope, no-write scope, and forbidden actions. +- `devDeployApply.artifactPlan`: expected artifact commit, source commit, + deploy/catalog commit IDs, publish/registry status, and unpublished services. +- `devDeployApply.target`: DEV endpoint and `hwlab-dev` namespace. +- `devDeployApply.workloadPlan` and `servicePlan`: planned Kubernetes + workloads, containers, images, service IDs, ports, and replica intent. +- `devDeployApply.manualCommands`: dry-run/report commands before human + approval; the apply command is present only when the plan is ready. +- `devDeployApply.rollbackHint`: pre-apply capture commands, deployment + rollbacks, suspended job cleanup, and post-rollback health checks. +- `devDeployApply.remainingBlockers`: open blockers with concrete unblock + hints. These do not claim live apply happened. + +When `conclusion.status` is `blocked`, operators should clear the listed +`remainingBlockers` and rerun: + +```sh +node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --write-report +node scripts/validate-dev-gate-report.mjs +``` + +When `conclusion.status` is `ready`, run the dry-run plan and validator +immediately before human approval: + +```sh +node scripts/dev-deploy-apply.mjs --dry-run --write-report +node scripts/validate-dev-gate-report.mjs +``` + +Only after that approval may the DEV-only apply command be used. + ## Current Result The current `origin/main` deploy contract is blocked for real DEV apply: @@ -57,7 +103,5 @@ The current `origin/main` deploy contract is blocked for real DEV apply: - `hwlab-cloud-api` has no DEV DB connection configuration and its local health payload reports `db.connected: false`. -The machine-readable result is written to -`reports/dev-gate/dev-deploy-report.json`. No PROD resource, secret value, -UniDesk runtime substitute, browser e2e, heavyweight e2e, or force push is part -of this flow. +No PROD resource, secret value, UniDesk runtime substitute, browser e2e, +heavyweight e2e, or force push is part of this flow. diff --git a/reports/dev-gate/dev-deploy-report.json b/reports/dev-gate/dev-deploy-report.json index a4ec234f..db73f8f0 100644 --- a/reports/dev-gate/dev-deploy-report.json +++ b/reports/dev-gate/dev-deploy-report.json @@ -3,13 +3,21 @@ "$id": "https://hwlab.pikastech.local/reports/dev-gate/dev-deploy-report.json", "reportVersion": "v1", "issue": "pikasTech/HWLAB#33", + "supports": [ + "pikasTech/HWLAB#50", + "pikasTech/HWLAB#7", + "pikasTech/HWLAB#33", + "pikasTech/HWLAB#17", + "pikasTech/HWLAB#32", + "pikasTech/HWLAB#30" + ], "taskId": "dev-deploy-apply", - "commitId": "d312cd0f3f2e", + "commitId": "845133ae149a", "acceptanceLevel": "dev_deploy_apply", "devOnly": true, "prodDisabled": true, "status": "blocked", - "generatedAt": "2026-05-21T16:43:19.846Z", + "generatedAt": "2026-05-21T17:47:32.687Z", "namespace": "hwlab-dev", "endpoint": "http://74.48.78.17:6667", "sourceContract": { @@ -27,6 +35,7 @@ "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs", "node --check scripts/dev-deploy-apply.mjs", + "node --check scripts/src/dev-deploy-apply.mjs", "node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked" ], "localSmoke": { @@ -47,6 +56,9 @@ ], "evidence": [ "artifact services checked: 13", + "expected artifact commit: 24eb3bf", + "namespace: hwlab-dev", + "workloads planned: 13", "kubectl executor: missing", "live health: blocked" ], @@ -64,11 +76,535 @@ }, "devDeployApply": { "mode": "dry-run", + "conclusion": { + "status": "blocked", + "reason": "6 blocker(s) must be cleared before any live DEV apply.", + "blockerCount": 6 + }, + "applyBoundary": { + "currentMode": "dry-run", + "defaultNoWrite": true, + "mutationAttempted": false, + "mutationAllowed": false, + "applyRequiresFlags": [ + "--apply", + "--confirm-dev", + "--confirmed-non-production" + ], + "writeScope": "kubectl apply -k deploy/k8s/dev, namespace hwlab-dev only", + "noWriteScope": "source/catalog/k8s validation, optional DEV health probe, kubectl read, and server-side dry-run only", + "forbiddenActions": [ + "prod-deploy", + "secret-read", + "runtime-substitute", + "unidesk-restart", + "code-queue-restart", + "backend-core-restart", + "heavy-master-e2e", + "force-push" + ] + }, "mutationAttempted": false, + "target": { + "environment": "dev", + "namespace": "hwlab-dev", + "endpoint": "http://74.48.78.17:6667", + "prodDisabled": true + }, + "artifactPlan": { + "expectedArtifactCommit": "24eb3bf", + "deployCommitId": "24eb3bf", + "catalogCommitId": "24eb3bf", + "sourceCommitId": "845133ae149a", + "serviceCommitIds": [ + "24eb3bf" + ], + "matchesSourceCommit": false, + "published": false, + "registryVerified": false, + "imageCount": 13, + "unpublishedServices": [ + "hwlab-cloud-api", + "hwlab-cloud-web", + "hwlab-agent-mgr", + "hwlab-agent-worker", + "hwlab-gateway", + "hwlab-gateway-simu", + "hwlab-box-simu", + "hwlab-patch-panel", + "hwlab-router", + "hwlab-tunnel-client", + "hwlab-edge-proxy", + "hwlab-cli", + "hwlab-agent-skills" + ] + }, + "workloadPlan": [ + { + "kind": "Deployment", + "name": "hwlab-cloud-api", + "namespace": "hwlab-dev", + "replicas": 1, + "serviceIds": [ + "hwlab-cloud-api" + ], + "containers": [ + { + "name": "hwlab-cloud-api", + "serviceId": "hwlab-cloud-api", + "image": "ghcr.io/pikastech/hwlab-cloud-api:24eb3bf" + } + ] + }, + { + "kind": "Deployment", + "name": "hwlab-cloud-web", + "namespace": "hwlab-dev", + "replicas": 1, + "serviceIds": [ + "hwlab-cloud-web" + ], + "containers": [ + { + "name": "hwlab-cloud-web", + "serviceId": "hwlab-cloud-web", + "image": "ghcr.io/pikastech/hwlab-cloud-web:24eb3bf" + } + ] + }, + { + "kind": "Deployment", + "name": "hwlab-agent-mgr", + "namespace": "hwlab-dev", + "replicas": 1, + "serviceIds": [ + "hwlab-agent-mgr" + ], + "containers": [ + { + "name": "hwlab-agent-mgr", + "serviceId": "hwlab-agent-mgr", + "image": "ghcr.io/pikastech/hwlab-agent-mgr:24eb3bf" + } + ] + }, + { + "kind": "Job", + "name": "hwlab-agent-worker-template", + "namespace": "hwlab-dev", + "replicas": 0, + "serviceIds": [ + "hwlab-agent-worker" + ], + "containers": [ + { + "name": "hwlab-agent-worker", + "serviceId": "hwlab-agent-worker", + "image": "ghcr.io/pikastech/hwlab-agent-worker:24eb3bf" + } + ] + }, + { + "kind": "Deployment", + "name": "hwlab-gateway", + "namespace": "hwlab-dev", + "replicas": 0, + "serviceIds": [ + "hwlab-gateway" + ], + "containers": [ + { + "name": "hwlab-gateway", + "serviceId": "hwlab-gateway", + "image": "ghcr.io/pikastech/hwlab-gateway:24eb3bf" + } + ] + }, + { + "kind": "Deployment", + "name": "hwlab-gateway-simu", + "namespace": "hwlab-dev", + "replicas": 2, + "serviceIds": [ + "hwlab-gateway-simu" + ], + "containers": [ + { + "name": "hwlab-gateway-simu", + "serviceId": "hwlab-gateway-simu", + "image": "ghcr.io/pikastech/hwlab-gateway-simu:24eb3bf" + } + ] + }, + { + "kind": "Deployment", + "name": "hwlab-box-simu", + "namespace": "hwlab-dev", + "replicas": 2, + "serviceIds": [ + "hwlab-box-simu" + ], + "containers": [ + { + "name": "hwlab-box-simu", + "serviceId": "hwlab-box-simu", + "image": "ghcr.io/pikastech/hwlab-box-simu:24eb3bf" + } + ] + }, + { + "kind": "Deployment", + "name": "hwlab-patch-panel", + "namespace": "hwlab-dev", + "replicas": 1, + "serviceIds": [ + "hwlab-patch-panel" + ], + "containers": [ + { + "name": "hwlab-patch-panel", + "serviceId": "hwlab-patch-panel", + "image": "ghcr.io/pikastech/hwlab-patch-panel:24eb3bf" + } + ] + }, + { + "kind": "Deployment", + "name": "hwlab-router", + "namespace": "hwlab-dev", + "replicas": 1, + "serviceIds": [ + "hwlab-router" + ], + "containers": [ + { + "name": "hwlab-router", + "serviceId": "hwlab-router", + "image": "ghcr.io/pikastech/hwlab-router:24eb3bf" + } + ] + }, + { + "kind": "Deployment", + "name": "hwlab-tunnel-client", + "namespace": "hwlab-dev", + "replicas": 1, + "serviceIds": [ + "hwlab-tunnel-client" + ], + "containers": [ + { + "name": "hwlab-tunnel-client", + "serviceId": "hwlab-tunnel-client", + "image": "ghcr.io/pikastech/hwlab-tunnel-client:24eb3bf" + } + ] + }, + { + "kind": "Deployment", + "name": "hwlab-edge-proxy", + "namespace": "hwlab-dev", + "replicas": 1, + "serviceIds": [ + "hwlab-edge-proxy" + ], + "containers": [ + { + "name": "hwlab-edge-proxy", + "serviceId": "hwlab-edge-proxy", + "image": "ghcr.io/pikastech/hwlab-edge-proxy:24eb3bf" + } + ] + }, + { + "kind": "Job", + "name": "hwlab-cli-template", + "namespace": "hwlab-dev", + "replicas": 0, + "serviceIds": [ + "hwlab-cli" + ], + "containers": [ + { + "name": "hwlab-cli", + "serviceId": "hwlab-cli", + "image": "ghcr.io/pikastech/hwlab-cli:24eb3bf" + } + ] + }, + { + "kind": "Deployment", + "name": "hwlab-agent-skills", + "namespace": "hwlab-dev", + "replicas": 1, + "serviceIds": [ + "hwlab-agent-skills" + ], + "containers": [ + { + "name": "hwlab-agent-skills", + "serviceId": "hwlab-agent-skills", + "image": "ghcr.io/pikastech/hwlab-agent-skills:24eb3bf" + } + ] + } + ], + "servicePlan": [ + { + "kind": "Service", + "name": "hwlab-cloud-api", + "namespace": "hwlab-dev", + "serviceId": "hwlab-cloud-api", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 6667, + "targetPort": "http" + } + ] + }, + { + "kind": "Service", + "name": "hwlab-cloud-web", + "namespace": "hwlab-dev", + "serviceId": "hwlab-cloud-web", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 8080, + "targetPort": "http" + } + ] + }, + { + "kind": "Service", + "name": "hwlab-gateway", + "namespace": "hwlab-dev", + "serviceId": "hwlab-gateway", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 7001, + "targetPort": "http" + } + ] + }, + { + "kind": "Service", + "name": "hwlab-cli", + "namespace": "hwlab-dev", + "serviceId": "hwlab-cli", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 7501, + "targetPort": "http" + } + ] + }, + { + "kind": "Service", + "name": "hwlab-agent-skills", + "namespace": "hwlab-dev", + "serviceId": "hwlab-agent-skills", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 7502, + "targetPort": "http" + } + ] + }, + { + "kind": "Service", + "name": "hwlab-agent-mgr", + "namespace": "hwlab-dev", + "serviceId": "hwlab-agent-mgr", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 7410, + "targetPort": "http" + } + ] + }, + { + "kind": "Service", + "name": "hwlab-gateway-simu", + "namespace": "hwlab-dev", + "serviceId": "hwlab-gateway-simu", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 7101, + "targetPort": "http" + } + ] + }, + { + "kind": "Service", + "name": "hwlab-box-simu", + "namespace": "hwlab-dev", + "serviceId": "hwlab-box-simu", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 7201, + "targetPort": "http" + } + ] + }, + { + "kind": "Service", + "name": "hwlab-patch-panel", + "namespace": "hwlab-dev", + "serviceId": "hwlab-patch-panel", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 7301, + "targetPort": "http" + } + ] + }, + { + "kind": "Service", + "name": "hwlab-router", + "namespace": "hwlab-dev", + "serviceId": "hwlab-router", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 7401, + "targetPort": "http" + } + ] + }, + { + "kind": "Service", + "name": "hwlab-tunnel-client", + "namespace": "hwlab-dev", + "serviceId": "hwlab-tunnel-client", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 7402, + "targetPort": "http" + } + ] + }, + { + "kind": "Service", + "name": "hwlab-edge-proxy", + "namespace": "hwlab-dev", + "serviceId": "hwlab-edge-proxy", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 6667, + "targetPort": "http" + } + ] + } + ], "applyStep": { "status": "not_run", "summary": "Skipped because preflight blockers are open" }, + "manualCommands": { + "status": "blocked", + "beforeHumanApproval": [ + "node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --write-report", + "node scripts/validate-dev-gate-report.mjs" + ], + "afterHumanApproval": [], + "summary": "No apply command is offered while blockers remain open." + }, + "rollbackHint": { + "namespace": "hwlab-dev", + "strategy": "DEV-only Kubernetes rollback using deployment revision history; do not touch PROD or UniDesk services.", + "captureBeforeApply": [ + "kubectl -n hwlab-dev get pods,services,configmaps,deployments,jobs -o wide", + "kubectl -n hwlab-dev get deployments -o json" + ], + "deploymentRollbackCommands": [ + "kubectl -n hwlab-dev rollout undo deployment/hwlab-cloud-api", + "kubectl -n hwlab-dev rollout undo deployment/hwlab-cloud-web", + "kubectl -n hwlab-dev rollout undo deployment/hwlab-agent-mgr", + "kubectl -n hwlab-dev rollout undo deployment/hwlab-gateway", + "kubectl -n hwlab-dev rollout undo deployment/hwlab-gateway-simu", + "kubectl -n hwlab-dev rollout undo deployment/hwlab-box-simu", + "kubectl -n hwlab-dev rollout undo deployment/hwlab-patch-panel", + "kubectl -n hwlab-dev rollout undo deployment/hwlab-router", + "kubectl -n hwlab-dev rollout undo deployment/hwlab-tunnel-client", + "kubectl -n hwlab-dev rollout undo deployment/hwlab-edge-proxy", + "kubectl -n hwlab-dev rollout undo deployment/hwlab-agent-skills" + ], + "jobCleanupCommands": [ + "kubectl -n hwlab-dev delete job hwlab-agent-worker-template --ignore-not-found", + "kubectl -n hwlab-dev delete job hwlab-cli-template --ignore-not-found" + ], + "postRollbackChecks": [ + "kubectl -n hwlab-dev rollout status deployment/hwlab-cloud-api --timeout=120s", + "curl -fsS http://74.48.78.17:6667/health/live" + ] + }, + "remainingBlockers": [ + { + "type": "observability_blocker", + "scope": "artifact-source-commit", + "status": "open", + "summary": "deploy/catalog artifact commit 24eb3bf/24eb3bf does not match source 845133ae149a", + "unblockHint": "Regenerate deploy/deploy.json and deploy/artifact-catalog.dev.json for the source commit that will be promoted." + }, + { + "type": "observability_blocker", + "scope": "artifact-publish", + "status": "open", + "summary": "DEV artifact catalog has no CI publish, registry verification, or registry digests", + "unblockHint": "Publish DEV images from the intended commit and update the catalog with registry digests and verification evidence." + }, + { + "type": "runtime_blocker", + "scope": "cloud-api-db-config", + "status": "open", + "summary": "cloud-api has no DEV DB connection env configured", + "unblockHint": "Configure and verify the DEV cloud-api DB connection through health output, without reading secret values." + }, + { + "type": "runtime_blocker", + "scope": "cloud-api-db", + "status": "open", + "summary": "cloud-api health reports DB not connected in this artifact", + "unblockHint": "Configure and verify the DEV cloud-api DB connection through health output, without reading secret values." + }, + { + "type": "environment_blocker", + "scope": "kubectl", + "status": "open", + "summary": "kubectl is not installed in the runner", + "unblockHint": "Provide a kubectl client configured for the D601 DEV k3s context without exposing token or secret values." + }, + { + "type": "network_blocker", + "scope": "dev-health", + "status": "open", + "summary": "Cannot reach http://74.48.78.17:6667/health/live: connect ECONNREFUSED 74.48.78.17:6667", + "unblockHint": "Restore the public DEV health path and confirm it identifies HWLAB dev, not a substitute runtime." + } + ], "artifactEvidence": [ { "serviceId": "hwlab-cloud-api", @@ -278,7 +814,7 @@ "type": "observability_blocker", "scope": "artifact-source-commit", "status": "open", - "summary": "deploy/catalog artifact commit 24eb3bf/24eb3bf does not match source d312cd0f3f2e" + "summary": "deploy/catalog artifact commit 24eb3bf/24eb3bf does not match source 845133ae149a" }, { "type": "observability_blocker", diff --git a/scripts/src/dev-deploy-apply.mjs b/scripts/src/dev-deploy-apply.mjs index ae92cc01..a2313dc3 100755 --- a/scripts/src/dev-deploy-apply.mjs +++ b/scripts/src/dev-deploy-apply.mjs @@ -16,8 +16,22 @@ const requiredValidationCommands = [ "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs", "node --check scripts/dev-deploy-apply.mjs", + "node --check scripts/src/dev-deploy-apply.mjs", "node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked" ]; +const applyCommand = "node scripts/dev-deploy-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report"; +const dryRunPlanCommand = "node scripts/dev-deploy-apply.mjs --dry-run --write-report"; +const blockedDryRunCommand = "node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --write-report"; +const forbiddenActions = [ + "prod-deploy", + "secret-read", + "runtime-substitute", + "unidesk-restart", + "code-queue-restart", + "backend-core-restart", + "heavy-master-e2e", + "force-push" +]; function parseArgs(argv) { const flags = new Set(argv.filter((arg) => arg.startsWith("--"))); @@ -107,6 +121,173 @@ function serviceIdFor(item, container) { ); } +function uniqueStrings(values) { + return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))]; +} + +function replicaPlan(item) { + if (item?.kind === "Job") { + return item?.spec?.suspend === true ? 0 : 1; + } + return item?.spec?.replicas ?? null; +} + +function buildWorkloadPlan(workloads) { + return listItems(workloads).map((item) => { + const containers = containersFor(item).map((container) => ({ + name: container.name, + serviceId: serviceIdFor(item, container), + image: container.image + })); + return { + kind: item?.kind ?? "unknown", + name: item?.metadata?.name ?? "unknown", + namespace: item?.metadata?.namespace ?? namespace, + replicas: replicaPlan(item), + serviceIds: uniqueStrings(containers.map((container) => container.serviceId)), + containers + }; + }); +} + +function buildServicePlan(services) { + return listItems(services).map((item) => ({ + kind: item?.kind ?? "unknown", + name: item?.metadata?.name ?? "unknown", + namespace: item?.metadata?.namespace ?? namespace, + serviceId: item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ?? item?.metadata?.name ?? "unknown", + type: item?.spec?.type ?? "unknown", + ports: (item?.spec?.ports ?? []).map((port) => ({ + name: port.name, + port: port.port, + targetPort: port.targetPort + })) + })); +} + +function buildArtifactPlan(deploy, catalog, sourceCommitId, artifactEvidence) { + const serviceCommitIds = uniqueStrings([ + ...(catalog?.services ?? []).map((service) => service.commitId), + ...(artifactEvidence ?? []).map((service) => service.catalogCommitId) + ]); + const expectedArtifactCommit = deploy?.commitId ?? catalog?.commitId ?? "unknown"; + return { + expectedArtifactCommit, + deployCommitId: deploy?.commitId ?? "unknown", + catalogCommitId: catalog?.commitId ?? "unknown", + sourceCommitId, + serviceCommitIds, + matchesSourceCommit: + deploy?.commitId && catalog?.commitId + ? commitMatchesSource(deploy.commitId, sourceCommitId) && commitMatchesSource(catalog.commitId, sourceCommitId) + : false, + published: catalog?.publish?.ciPublished === true, + registryVerified: catalog?.publish?.registryVerified === true, + imageCount: artifactEvidence.length, + unpublishedServices: (catalog?.services ?? []) + .filter((service) => service.publishState === "skeleton-only" || !service.digest?.startsWith("sha256:")) + .map((service) => service.serviceId) + }; +} + +function blockerHint(blocker) { + if (blocker.scope === "artifact-publish") { + return "Publish DEV images from the intended commit and update the catalog with registry digests and verification evidence."; + } + if (blocker.scope === "artifact-source-commit") { + return "Regenerate deploy/deploy.json and deploy/artifact-catalog.dev.json for the source commit that will be promoted."; + } + if (blocker.scope === "kubectl" || blocker.scope === "kubectl-version") { + return "Provide a kubectl client configured for the D601 DEV k3s context without exposing token or secret values."; + } + if (blocker.scope === "cluster-read") { + return "Restore read-only visibility for pods, services, configmaps, deployments, and jobs in hwlab-dev."; + } + if (blocker.scope === "dev-health" || blocker.scope === "dev-health-identity") { + return "Restore the public DEV health path and confirm it identifies HWLAB dev, not a substitute runtime."; + } + if (blocker.scope === "dev-health-db" || blocker.scope === "cloud-api-db" || blocker.scope === "cloud-api-db-config") { + return "Configure and verify the DEV cloud-api DB connection through health output, without reading secret values."; + } + return blocker.summary; +} + +function buildRemainingBlockers(blockers) { + return blockers.map((blocker) => ({ + type: blocker.type, + scope: blocker.scope, + status: blocker.status, + summary: blocker.summary, + unblockHint: oneLine(blockerHint(blocker)) + })); +} + +function buildRollbackHint(workloads) { + const deploymentNames = buildWorkloadPlan(workloads) + .filter((workload) => workload.kind === "Deployment") + .map((workload) => workload.name); + const jobNames = buildWorkloadPlan(workloads) + .filter((workload) => workload.kind === "Job") + .map((workload) => workload.name); + + return { + namespace, + strategy: "DEV-only Kubernetes rollback using deployment revision history; do not touch PROD or UniDesk services.", + captureBeforeApply: [ + "kubectl -n hwlab-dev get pods,services,configmaps,deployments,jobs -o wide", + "kubectl -n hwlab-dev get deployments -o json" + ], + deploymentRollbackCommands: deploymentNames.map((name) => `kubectl -n hwlab-dev rollout undo deployment/${name}`), + jobCleanupCommands: jobNames.map((name) => `kubectl -n hwlab-dev delete job ${name} --ignore-not-found`), + postRollbackChecks: [ + "kubectl -n hwlab-dev rollout status deployment/hwlab-cloud-api --timeout=120s", + `curl -fsS ${new URL(healthPath, DEV_ENDPOINT).href}` + ] + }; +} + +function buildManualCommands(status) { + if (status === "blocked") { + return { + status: "blocked", + beforeHumanApproval: [blockedDryRunCommand, "node scripts/validate-dev-gate-report.mjs"], + afterHumanApproval: [], + summary: "No apply command is offered while blockers remain open." + }; + } + + return { + status: "ready", + beforeHumanApproval: [dryRunPlanCommand, "node scripts/validate-dev-gate-report.mjs"], + afterHumanApproval: [applyCommand], + summary: "Run the dry-run plan and validator immediately before requesting human approval; live apply still requires the explicit DEV confirmation flags." + }; +} + +function buildApplyBoundary(args, status, applyStep) { + return { + currentMode: args.apply ? "apply-requested" : "dry-run", + defaultNoWrite: !args.apply, + mutationAttempted: applyStep.status === "pass" && args.apply, + mutationAllowed: status === "pass" && args.apply, + applyRequiresFlags: ["--apply", "--confirm-dev", "--confirmed-non-production"], + writeScope: "kubectl apply -k deploy/k8s/dev, namespace hwlab-dev only", + noWriteScope: "source/catalog/k8s validation, optional DEV health probe, kubectl read, and server-side dry-run only", + forbiddenActions + }; +} + +function buildPlanConclusion(status, blockers) { + return { + status: status === "blocked" ? "blocked" : "ready", + reason: + status === "blocked" + ? `${blockers.length} blocker(s) must be cleared before any live DEV apply.` + : "All DEV-only preconditions passed; human approval is still required before apply.", + blockerCount: blockers.length + }; +} + function validateArgs(args, blockers) { for (const forbidden of ["--prod", "--production", "--read-secret", "--force-push", "--heavyweight-e2e"]) { if (args.flags.has(forbidden)) { @@ -380,12 +561,19 @@ export async function runDevDeployApply(argv, io = {}) { const liveProbe = args.skipLiveProbe ? { status: "not_run", reason: "--skip-live-probe" } : await probeDevEndpoint(blockers); const applyStep = await runApplyStep(args, kubectlPath, blockers); const status = blockers.length > 0 ? "blocked" : "pass"; + const artifactPlan = buildArtifactPlan(deploy, catalog, commitId, artifactEvidence); + const workloadPlan = buildWorkloadPlan(workloads); + const servicePlan = buildServicePlan(services); + const planConclusion = buildPlanConclusion(status, blockers); + const manualCommands = buildManualCommands(status); + const remainingBlockers = buildRemainingBlockers(blockers); const report = { $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", $id: "https://hwlab.pikastech.local/reports/dev-gate/dev-deploy-report.json", reportVersion: "v1", issue: "pikasTech/HWLAB#33", + supports: ["pikasTech/HWLAB#50", "pikasTech/HWLAB#7", "pikasTech/HWLAB#33", "pikasTech/HWLAB#17", "pikasTech/HWLAB#32", "pikasTech/HWLAB#30"], taskId: "dev-deploy-apply", commitId, acceptanceLevel: "dev_deploy_apply", @@ -421,6 +609,9 @@ export async function runDevDeployApply(argv, io = {}) { ], evidence: [ `artifact services checked: ${artifactEvidence.length}`, + `expected artifact commit: ${artifactPlan.expectedArtifactCommit}`, + `namespace: ${namespace}`, + `workloads planned: ${workloadPlan.length}`, `kubectl executor: ${kubectlPath ?? "missing"}`, `live health: ${liveProbe.status}` ], @@ -438,8 +629,22 @@ export async function runDevDeployApply(argv, io = {}) { }, devDeployApply: { mode: args.apply ? "apply" : "dry-run", + conclusion: planConclusion, + applyBoundary: buildApplyBoundary(args, status, applyStep), mutationAttempted: applyStep.status === "pass" && args.apply, + target: { + environment: ENVIRONMENT_DEV, + namespace, + endpoint: DEV_ENDPOINT, + prodDisabled: true + }, + artifactPlan, + workloadPlan, + servicePlan, applyStep, + manualCommands, + rollbackHint: buildRollbackHint(workloads), + remainingBlockers, artifactEvidence, k8sManifest, clusterObservation, diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index df96c5b8..df860416 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -30,6 +30,21 @@ const requiredValidationCommands = [ "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs" ]; +const requiredDevDeployApplySupports = [ + "pikasTech/HWLAB#50", + "pikasTech/HWLAB#7", + "pikasTech/HWLAB#33", + "pikasTech/HWLAB#17", + "pikasTech/HWLAB#32", + "pikasTech/HWLAB#30" +]; +const requiredDevDeployApplyValidationCommands = [ + "node --check scripts/validate-dev-gate-report.mjs", + "node scripts/validate-dev-gate-report.mjs", + "node --check scripts/dev-deploy-apply.mjs", + "node --check scripts/src/dev-deploy-apply.mjs", + "node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked" +]; const requiredPreflightValidationCommands = [ "node --check scripts/dev-gate-preflight.mjs", "node --check scripts/src/dev-gate-preflight.mjs", @@ -435,6 +450,10 @@ async function validateReport(relativePath) { }); assertUnique(report.devPreconditions.evidence, `${label}.devPreconditions.evidence`); } + + if (report.taskId === "dev-deploy-apply") { + assertDevDeployApplyReport(report, label); + } } async function assertDocumentSet(documentsValue, label, requiredDocs) { @@ -469,6 +488,145 @@ function assertBlockers(blockers, label) { } } +function assertCommandList(value, label, { minLength = 1 } = {}) { + assertStringArray(value, label, { minLength }); + assertUnique(value, label); + for (const command of value) { + assert.ok(!command.includes("--prod"), `${label} must not include PROD command`); + assert.ok(!command.includes("--production"), `${label} must not include production command`); + } +} + +function assertDevDeployApplyReport(report, label) { + assert.equal(report.issue, issueFamily.DEV_DEPLOY_APPLY, `${label}.issue`); + + assertStringArray(report.supports, `${label}.supports`, { + minLength: requiredDevDeployApplySupports.length + }); + for (const supportedIssue of requiredDevDeployApplySupports) { + assert.ok(report.supports.includes(supportedIssue), `${label}.supports missing ${supportedIssue}`); + } + + for (const requiredCommand of requiredDevDeployApplyValidationCommands) { + assert.ok( + report.validationCommands.includes(requiredCommand), + `${label}.validationCommands missing ${requiredCommand}` + ); + } + + assertObject(report.devDeployApply, `${label}.devDeployApply`); + const plan = report.devDeployApply; + for (const field of [ + "conclusion", + "applyBoundary", + "target", + "artifactPlan", + "workloadPlan", + "servicePlan", + "manualCommands", + "rollbackHint", + "remainingBlockers" + ]) { + assert.ok(Object.hasOwn(plan, field), `${label}.devDeployApply missing ${field}`); + } + + assertObject(plan.conclusion, `${label}.devDeployApply.conclusion`); + assert.ok(["blocked", "ready"].includes(plan.conclusion.status), `${label}.devDeployApply.conclusion.status`); + assertString(plan.conclusion.reason, `${label}.devDeployApply.conclusion.reason`); + assert.equal(plan.conclusion.blockerCount, report.blockers.length, `${label}.devDeployApply.conclusion.blockerCount`); + + assertObject(plan.applyBoundary, `${label}.devDeployApply.applyBoundary`); + assert.equal(plan.applyBoundary.defaultNoWrite, plan.mode !== "apply", `${label}.devDeployApply.applyBoundary.defaultNoWrite`); + assert.equal(plan.applyBoundary.mutationAttempted, report.devDeployApply.mutationAttempted, `${label}.devDeployApply.applyBoundary.mutationAttempted`); + assertStringArray(plan.applyBoundary.applyRequiresFlags, `${label}.devDeployApply.applyBoundary.applyRequiresFlags`, { + minLength: 3 + }); + for (const flag of ["--apply", "--confirm-dev", "--confirmed-non-production"]) { + assert.ok(plan.applyBoundary.applyRequiresFlags.includes(flag), `${label}.devDeployApply.applyBoundary.applyRequiresFlags missing ${flag}`); + } + assertStringArray(plan.applyBoundary.forbiddenActions, `${label}.devDeployApply.applyBoundary.forbiddenActions`, { + minLength: 6 + }); + for (const forbiddenAction of ["prod-deploy", "secret-read", "runtime-substitute", "heavy-master-e2e"]) { + assert.ok(plan.applyBoundary.forbiddenActions.includes(forbiddenAction), `${label}.devDeployApply.applyBoundary.forbiddenActions missing ${forbiddenAction}`); + } + assertString(plan.applyBoundary.writeScope, `${label}.devDeployApply.applyBoundary.writeScope`); + assertString(plan.applyBoundary.noWriteScope, `${label}.devDeployApply.applyBoundary.noWriteScope`); + + assertObject(plan.target, `${label}.devDeployApply.target`); + assert.equal(plan.target.environment, "dev", `${label}.devDeployApply.target.environment`); + assert.equal(plan.target.namespace, "hwlab-dev", `${label}.devDeployApply.target.namespace`); + assert.equal(plan.target.endpoint, "http://74.48.78.17:6667", `${label}.devDeployApply.target.endpoint`); + assert.equal(plan.target.prodDisabled, true, `${label}.devDeployApply.target.prodDisabled`); + + assertObject(plan.artifactPlan, `${label}.devDeployApply.artifactPlan`); + for (const field of ["expectedArtifactCommit", "deployCommitId", "catalogCommitId", "sourceCommitId"]) { + assertString(plan.artifactPlan[field], `${label}.devDeployApply.artifactPlan.${field}`); + } + assertStringArray(plan.artifactPlan.serviceCommitIds, `${label}.devDeployApply.artifactPlan.serviceCommitIds`, { + minLength: 1 + }); + assert.equal(typeof plan.artifactPlan.matchesSourceCommit, "boolean", `${label}.devDeployApply.artifactPlan.matchesSourceCommit`); + assert.equal(typeof plan.artifactPlan.published, "boolean", `${label}.devDeployApply.artifactPlan.published`); + assert.equal(typeof plan.artifactPlan.registryVerified, "boolean", `${label}.devDeployApply.artifactPlan.registryVerified`); + assert.ok(Number.isInteger(plan.artifactPlan.imageCount), `${label}.devDeployApply.artifactPlan.imageCount`); + assertStringArray(plan.artifactPlan.unpublishedServices, `${label}.devDeployApply.artifactPlan.unpublishedServices`); + + assertArray(plan.workloadPlan, `${label}.devDeployApply.workloadPlan`); + assert.ok(plan.workloadPlan.length >= 1, `${label}.devDeployApply.workloadPlan must not be empty`); + assertArray(plan.servicePlan, `${label}.devDeployApply.servicePlan`); + assert.ok(plan.servicePlan.length >= 1, `${label}.devDeployApply.servicePlan must not be empty`); + for (const [index, workload] of plan.workloadPlan.entries()) { + const workloadLabel = `${label}.devDeployApply.workloadPlan[${index}]`; + assertObject(workload, workloadLabel); + for (const field of ["kind", "name", "namespace", "serviceIds", "containers"]) { + assert.ok(Object.hasOwn(workload, field), `${workloadLabel} missing ${field}`); + } + assert.equal(workload.namespace, "hwlab-dev", `${workloadLabel}.namespace`); + assertStringArray(workload.serviceIds, `${workloadLabel}.serviceIds`, { minLength: 1 }); + assertArray(workload.containers, `${workloadLabel}.containers`); + } + + assertObject(plan.manualCommands, `${label}.devDeployApply.manualCommands`); + assert.ok(["blocked", "ready"].includes(plan.manualCommands.status), `${label}.devDeployApply.manualCommands.status`); + assertCommandList(plan.manualCommands.beforeHumanApproval, `${label}.devDeployApply.manualCommands.beforeHumanApproval`); + assertCommandList(plan.manualCommands.afterHumanApproval, `${label}.devDeployApply.manualCommands.afterHumanApproval`, { + minLength: 0 + }); + assertString(plan.manualCommands.summary, `${label}.devDeployApply.manualCommands.summary`); + if (plan.conclusion.status === "blocked") { + assert.equal(plan.manualCommands.status, "blocked", `${label}.devDeployApply.manualCommands.status`); + assert.equal(plan.manualCommands.afterHumanApproval.length, 0, `${label}.devDeployApply.manualCommands.afterHumanApproval`); + } + if (plan.conclusion.status === "ready") { + assert.equal(plan.manualCommands.status, "ready", `${label}.devDeployApply.manualCommands.status`); + assert.ok( + plan.manualCommands.afterHumanApproval.includes("node scripts/dev-deploy-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report"), + `${label}.devDeployApply.manualCommands.afterHumanApproval missing apply command` + ); + } + + assertObject(plan.rollbackHint, `${label}.devDeployApply.rollbackHint`); + assert.equal(plan.rollbackHint.namespace, "hwlab-dev", `${label}.devDeployApply.rollbackHint.namespace`); + assertString(plan.rollbackHint.strategy, `${label}.devDeployApply.rollbackHint.strategy`); + assertCommandList(plan.rollbackHint.captureBeforeApply, `${label}.devDeployApply.rollbackHint.captureBeforeApply`); + assertCommandList(plan.rollbackHint.deploymentRollbackCommands, `${label}.devDeployApply.rollbackHint.deploymentRollbackCommands`); + assertCommandList(plan.rollbackHint.jobCleanupCommands, `${label}.devDeployApply.rollbackHint.jobCleanupCommands`); + assertCommandList(plan.rollbackHint.postRollbackChecks, `${label}.devDeployApply.rollbackHint.postRollbackChecks`); + + assertArray(plan.remainingBlockers, `${label}.devDeployApply.remainingBlockers`); + assert.equal(plan.remainingBlockers.length, report.blockers.length, `${label}.devDeployApply.remainingBlockers.length`); + for (const [index, blocker] of plan.remainingBlockers.entries()) { + const blockerLabel = `${label}.devDeployApply.remainingBlockers[${index}]`; + assertObject(blocker, blockerLabel); + for (const field of ["type", "scope", "status", "summary", "unblockHint"]) { + assert.ok(Object.hasOwn(blocker, field), `${blockerLabel} missing ${field}`); + } + assert.ok(blockerTypes.has(blocker.type), `${blockerLabel}.type must be known`); + assertString(blocker.unblockHint, `${blockerLabel}.unblockHint`); + } +} + async function validateDevEdgeReport(report, label) { for (const field of [ "$schema",