feat: add dev deploy apply plan report

This commit is contained in:
HWLAB Code Queue
2026-05-21 17:45:53 +00:00
parent d5bbb512df
commit a8d27d546b
4 changed files with 951 additions and 8 deletions
+49 -5
View File
@@ -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.
+539 -3
View File
@@ -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",
+205
View File
@@ -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,
+158
View File
@@ -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",