chore: add dev deploy apply preflight
This commit is contained in:
@@ -14,6 +14,8 @@ Thumbs.db
|
||||
|
||||
# Build outputs
|
||||
bin/
|
||||
!tools/hwlab-cli/bin/
|
||||
!tools/hwlab-cli/bin/hwlab-cli.mjs
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# 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
|
||||
not be used for PROD.
|
||||
|
||||
## Scope
|
||||
|
||||
- Work from latest `origin/main`.
|
||||
- Read only HWLAB repository deploy inputs: `deploy/deploy.json`,
|
||||
`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.
|
||||
- Stop before mutation when artifact publish evidence, registry digests,
|
||||
`kubectl`, DEV health, or cloud-api DB connectivity are missing.
|
||||
|
||||
## Commands
|
||||
|
||||
Run a preflight that is expected to report blockers on the current skeleton:
|
||||
|
||||
```sh
|
||||
node --check scripts/dev-deploy-apply.mjs
|
||||
node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --write-report
|
||||
node scripts/validate-dev-gate-report.mjs
|
||||
```
|
||||
|
||||
Run the existing CLI dry-run surface:
|
||||
|
||||
```sh
|
||||
node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run
|
||||
```
|
||||
|
||||
Only after CI publish evidence and executor prerequisites exist, a real DEV
|
||||
apply requires explicit non-production confirmation:
|
||||
|
||||
```sh
|
||||
node scripts/dev-deploy-apply.mjs --apply --confirm-dev --confirmed-non-production --write-report
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Current Result
|
||||
|
||||
The current `origin/main` deploy contract is blocked for real DEV apply:
|
||||
|
||||
- `deploy/artifact-catalog.dev.json` says `ciPublished: false`,
|
||||
`registryVerified: false`, `publishState: skeleton-only`, and
|
||||
`digest: not_published`.
|
||||
- The deploy/catalog artifact commit is `24eb3bf`, which does not match the
|
||||
current source commit validated for this apply (`1e8d009e9531`).
|
||||
- The runner does not provide `kubectl`, so it cannot read or apply the D601
|
||||
`hwlab-dev` namespace.
|
||||
- The public DEV health probe at `http://74.48.78.17:6667/health/live` is not
|
||||
reachable from this runner.
|
||||
- `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.
|
||||
@@ -1,7 +1,8 @@
|
||||
# HWLAB DEV Gate Report Contract
|
||||
|
||||
This document defines the canonical DEV gate report format for
|
||||
`pikasTech/HWLAB#31`. It is the handoff surface for the parallel workstreams
|
||||
`pikasTech/HWLAB#31` and the DEV deploy apply report extension for
|
||||
`pikasTech/HWLAB#33`. It is the handoff surface for the parallel workstreams
|
||||
behind `#7/#9/#12/#17/#20/#21/#22/#23/#24/#25/#26`, so source-contract
|
||||
evidence, local smoke, dry-run notes, real DEV prerequisites, and remaining
|
||||
blockers stay in one place.
|
||||
@@ -12,6 +13,7 @@ deployment.
|
||||
## Files
|
||||
|
||||
- `reports/dev-gate/dev-gate-report.example.json`: machine-readable example.
|
||||
- `reports/dev-gate/dev-deploy-report.json`: DEV deploy apply/preflight result.
|
||||
- `scripts/validate-dev-gate-report.mjs`: local validator for the report shape.
|
||||
|
||||
## Required Fields
|
||||
|
||||
@@ -46,7 +46,7 @@ export function buildHealthPayload() {
|
||||
async function routeRequest(request, response, options) {
|
||||
const url = new URL(request.url || "/", "http://hwlab-cloud-api.local");
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/health") {
|
||||
if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) {
|
||||
sendJson(response, 200, buildHealthPayload());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from "node:test";
|
||||
|
||||
import { createCloudApiServer } from "./server.mjs";
|
||||
|
||||
test("cloud api exposes /health and /live probes", async () => {
|
||||
test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
const server = createCloudApiServer();
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
@@ -14,6 +14,10 @@ test("cloud api exposes /health and /live probes", async () => {
|
||||
assert.equal(health.status, 200);
|
||||
assert.equal((await health.json()).serviceId, "hwlab-cloud-api");
|
||||
|
||||
const readiness = await fetch(`http://127.0.0.1:${port}/health/live`);
|
||||
assert.equal(readiness.status, 200);
|
||||
assert.equal((await readiness.json()).status, "ok");
|
||||
|
||||
const live = await fetch(`http://127.0.0.1:${port}/live`);
|
||||
assert.equal(live.status, 200);
|
||||
assert.equal((await live.json()).status, "live");
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
{
|
||||
"$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",
|
||||
"taskId": "dev-deploy-apply",
|
||||
"commitId": "5d974c9f48fc",
|
||||
"acceptanceLevel": "dev_deploy_apply",
|
||||
"devOnly": true,
|
||||
"prodDisabled": true,
|
||||
"status": "blocked",
|
||||
"generatedAt": "2026-05-21T16:41:39.247Z",
|
||||
"namespace": "hwlab-dev",
|
||||
"endpoint": "http://74.48.78.17:6667",
|
||||
"sourceContract": {
|
||||
"status": "pass",
|
||||
"documents": [
|
||||
"docs/dev-acceptance-matrix.md",
|
||||
"docs/m0-contract-audit.md",
|
||||
"docs/dev-deploy-apply.md",
|
||||
"docs/artifact-catalog.md",
|
||||
"deploy/README.md"
|
||||
],
|
||||
"summary": "DEV deploy apply is pinned to hwlab-dev and the frozen HWLAB service IDs"
|
||||
},
|
||||
"validationCommands": [
|
||||
"node --check scripts/validate-dev-gate-report.mjs",
|
||||
"node scripts/validate-dev-gate-report.mjs",
|
||||
"node --check scripts/dev-deploy-apply.mjs",
|
||||
"node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked"
|
||||
],
|
||||
"localSmoke": {
|
||||
"status": "not_run",
|
||||
"commands": [
|
||||
"node scripts/m1-contract-smoke.mjs"
|
||||
],
|
||||
"evidence": [
|
||||
"This deploy apply task did not run local smoke because artifact/executor blockers stop promotion first."
|
||||
],
|
||||
"summary": "Local smoke remains available but is not a substitute for DEV artifact publish evidence."
|
||||
},
|
||||
"dryRun": {
|
||||
"status": "blocked",
|
||||
"commands": [
|
||||
"node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run",
|
||||
"node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked"
|
||||
],
|
||||
"evidence": [
|
||||
"artifact services checked: 13",
|
||||
"kubectl executor: missing",
|
||||
"live health: blocked"
|
||||
],
|
||||
"summary": "DEV apply is blocked before mutation."
|
||||
},
|
||||
"devPreconditions": {
|
||||
"status": "blocked",
|
||||
"requirements": [
|
||||
"DEV artifact catalog must contain CI publish evidence and registry digests",
|
||||
"kubectl must be available for D601 hwlab-dev and must not target PROD",
|
||||
"hwlab-cloud-api /health/live must report serviceId, dev environment, and DB connectivity",
|
||||
"pods, services, configmaps, and image commit tags must be observable in hwlab-dev"
|
||||
],
|
||||
"summary": "One or more required DEV deploy preconditions are blocked."
|
||||
},
|
||||
"devDeployApply": {
|
||||
"mode": "dry-run",
|
||||
"mutationAttempted": false,
|
||||
"applyStep": {
|
||||
"status": "not_run",
|
||||
"summary": "Skipped because preflight blockers are open"
|
||||
},
|
||||
"artifactEvidence": [
|
||||
{
|
||||
"serviceId": "hwlab-cloud-api",
|
||||
"deployCommitId": "24eb3bf",
|
||||
"catalogCommitId": "24eb3bf",
|
||||
"image": "ghcr.io/pikastech/hwlab-cloud-api:24eb3bf",
|
||||
"digest": "not_published",
|
||||
"publishState": "skeleton-only"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-cloud-web",
|
||||
"deployCommitId": "24eb3bf",
|
||||
"catalogCommitId": "24eb3bf",
|
||||
"image": "ghcr.io/pikastech/hwlab-cloud-web:24eb3bf",
|
||||
"digest": "not_published",
|
||||
"publishState": "skeleton-only"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-agent-mgr",
|
||||
"deployCommitId": "24eb3bf",
|
||||
"catalogCommitId": "24eb3bf",
|
||||
"image": "ghcr.io/pikastech/hwlab-agent-mgr:24eb3bf",
|
||||
"digest": "not_published",
|
||||
"publishState": "skeleton-only"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-agent-worker",
|
||||
"deployCommitId": "24eb3bf",
|
||||
"catalogCommitId": "24eb3bf",
|
||||
"image": "ghcr.io/pikastech/hwlab-agent-worker:24eb3bf",
|
||||
"digest": "not_published",
|
||||
"publishState": "skeleton-only"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-gateway",
|
||||
"deployCommitId": "24eb3bf",
|
||||
"catalogCommitId": "24eb3bf",
|
||||
"image": "ghcr.io/pikastech/hwlab-gateway:24eb3bf",
|
||||
"digest": "not_published",
|
||||
"publishState": "skeleton-only"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-gateway-simu",
|
||||
"deployCommitId": "24eb3bf",
|
||||
"catalogCommitId": "24eb3bf",
|
||||
"image": "ghcr.io/pikastech/hwlab-gateway-simu:24eb3bf",
|
||||
"digest": "not_published",
|
||||
"publishState": "skeleton-only"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-box-simu",
|
||||
"deployCommitId": "24eb3bf",
|
||||
"catalogCommitId": "24eb3bf",
|
||||
"image": "ghcr.io/pikastech/hwlab-box-simu:24eb3bf",
|
||||
"digest": "not_published",
|
||||
"publishState": "skeleton-only"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-patch-panel",
|
||||
"deployCommitId": "24eb3bf",
|
||||
"catalogCommitId": "24eb3bf",
|
||||
"image": "ghcr.io/pikastech/hwlab-patch-panel:24eb3bf",
|
||||
"digest": "not_published",
|
||||
"publishState": "skeleton-only"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-router",
|
||||
"deployCommitId": "24eb3bf",
|
||||
"catalogCommitId": "24eb3bf",
|
||||
"image": "ghcr.io/pikastech/hwlab-router:24eb3bf",
|
||||
"digest": "not_published",
|
||||
"publishState": "skeleton-only"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-tunnel-client",
|
||||
"deployCommitId": "24eb3bf",
|
||||
"catalogCommitId": "24eb3bf",
|
||||
"image": "ghcr.io/pikastech/hwlab-tunnel-client:24eb3bf",
|
||||
"digest": "not_published",
|
||||
"publishState": "skeleton-only"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-edge-proxy",
|
||||
"deployCommitId": "24eb3bf",
|
||||
"catalogCommitId": "24eb3bf",
|
||||
"image": "ghcr.io/pikastech/hwlab-edge-proxy:24eb3bf",
|
||||
"digest": "not_published",
|
||||
"publishState": "skeleton-only"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-cli",
|
||||
"deployCommitId": "24eb3bf",
|
||||
"catalogCommitId": "24eb3bf",
|
||||
"image": "ghcr.io/pikastech/hwlab-cli:24eb3bf",
|
||||
"digest": "not_published",
|
||||
"publishState": "skeleton-only"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-agent-skills",
|
||||
"deployCommitId": "24eb3bf",
|
||||
"catalogCommitId": "24eb3bf",
|
||||
"image": "ghcr.io/pikastech/hwlab-agent-skills:24eb3bf",
|
||||
"digest": "not_published",
|
||||
"publishState": "skeleton-only"
|
||||
}
|
||||
],
|
||||
"k8sManifest": {
|
||||
"resourceCounts": {
|
||||
"workloads": 13,
|
||||
"services": 12,
|
||||
"configMaps": 1
|
||||
},
|
||||
"images": [
|
||||
{
|
||||
"serviceId": "hwlab-cloud-api",
|
||||
"image": "ghcr.io/pikastech/hwlab-cloud-api:24eb3bf",
|
||||
"kind": "Deployment",
|
||||
"name": "hwlab-cloud-api"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-cloud-web",
|
||||
"image": "ghcr.io/pikastech/hwlab-cloud-web:24eb3bf",
|
||||
"kind": "Deployment",
|
||||
"name": "hwlab-cloud-web"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-agent-mgr",
|
||||
"image": "ghcr.io/pikastech/hwlab-agent-mgr:24eb3bf",
|
||||
"kind": "Deployment",
|
||||
"name": "hwlab-agent-mgr"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-agent-worker",
|
||||
"image": "ghcr.io/pikastech/hwlab-agent-worker:24eb3bf",
|
||||
"kind": "Job",
|
||||
"name": "hwlab-agent-worker-template"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-gateway",
|
||||
"image": "ghcr.io/pikastech/hwlab-gateway:24eb3bf",
|
||||
"kind": "Deployment",
|
||||
"name": "hwlab-gateway"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-gateway-simu",
|
||||
"image": "ghcr.io/pikastech/hwlab-gateway-simu:24eb3bf",
|
||||
"kind": "Deployment",
|
||||
"name": "hwlab-gateway-simu"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-box-simu",
|
||||
"image": "ghcr.io/pikastech/hwlab-box-simu:24eb3bf",
|
||||
"kind": "Deployment",
|
||||
"name": "hwlab-box-simu"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-patch-panel",
|
||||
"image": "ghcr.io/pikastech/hwlab-patch-panel:24eb3bf",
|
||||
"kind": "Deployment",
|
||||
"name": "hwlab-patch-panel"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-router",
|
||||
"image": "ghcr.io/pikastech/hwlab-router:24eb3bf",
|
||||
"kind": "Deployment",
|
||||
"name": "hwlab-router"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-tunnel-client",
|
||||
"image": "ghcr.io/pikastech/hwlab-tunnel-client:24eb3bf",
|
||||
"kind": "Deployment",
|
||||
"name": "hwlab-tunnel-client"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-edge-proxy",
|
||||
"image": "ghcr.io/pikastech/hwlab-edge-proxy:24eb3bf",
|
||||
"kind": "Deployment",
|
||||
"name": "hwlab-edge-proxy"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-cli",
|
||||
"image": "ghcr.io/pikastech/hwlab-cli:24eb3bf",
|
||||
"kind": "Job",
|
||||
"name": "hwlab-cli-template"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-agent-skills",
|
||||
"image": "ghcr.io/pikastech/hwlab-agent-skills:24eb3bf",
|
||||
"kind": "Deployment",
|
||||
"name": "hwlab-agent-skills"
|
||||
}
|
||||
]
|
||||
},
|
||||
"clusterObservation": {
|
||||
"status": "blocked",
|
||||
"executor": "missing",
|
||||
"resources": []
|
||||
},
|
||||
"liveProbe": {
|
||||
"status": "blocked",
|
||||
"url": "http://74.48.78.17:6667/health/live",
|
||||
"error": "connect ECONNREFUSED 74.48.78.17:6667"
|
||||
}
|
||||
},
|
||||
"blockers": [
|
||||
{
|
||||
"type": "observability_blocker",
|
||||
"scope": "artifact-source-commit",
|
||||
"status": "open",
|
||||
"summary": "deploy/catalog artifact commit 24eb3bf/24eb3bf does not match source 5d974c9f48fc"
|
||||
},
|
||||
{
|
||||
"type": "observability_blocker",
|
||||
"scope": "artifact-publish",
|
||||
"status": "open",
|
||||
"summary": "DEV artifact catalog has no CI publish, registry verification, or registry digests"
|
||||
},
|
||||
{
|
||||
"type": "runtime_blocker",
|
||||
"scope": "cloud-api-db-config",
|
||||
"status": "open",
|
||||
"summary": "cloud-api has no DEV DB connection env configured"
|
||||
},
|
||||
{
|
||||
"type": "runtime_blocker",
|
||||
"scope": "cloud-api-db",
|
||||
"status": "open",
|
||||
"summary": "cloud-api health reports DB not connected in this artifact"
|
||||
},
|
||||
{
|
||||
"type": "environment_blocker",
|
||||
"scope": "kubectl",
|
||||
"status": "open",
|
||||
"summary": "kubectl is not installed in the runner"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"notes": "DEV-only result. No PROD resources, secret values, force push, heavy e2e, browser e2e, or UniDesk runtime substitute were used."
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
import { formatFailure, runDevDeployApply } from "./src/dev-deploy-apply.mjs";
|
||||
|
||||
try {
|
||||
process.exitCode = await runDevDeployApply(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stdout.write(`${JSON.stringify(formatFailure(error), null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Executable
+468
@@ -0,0 +1,468 @@
|
||||
#!/usr/bin/env node
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { access, readFile, writeFile } from "node:fs/promises";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = "reports/dev-gate/dev-deploy-report.json";
|
||||
const namespace = "hwlab-dev";
|
||||
const healthPath = "/health/live";
|
||||
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 scripts/dev-deploy-apply.mjs --dry-run --expect-blocked"
|
||||
];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const flags = new Set(argv.filter((arg) => arg.startsWith("--")));
|
||||
return {
|
||||
apply: flags.has("--apply"),
|
||||
dryRun: !flags.has("--apply"),
|
||||
expectBlocked: flags.has("--expect-blocked"),
|
||||
skipLiveProbe: flags.has("--skip-live-probe"),
|
||||
writeReport: flags.has("--write-report"),
|
||||
flags
|
||||
};
|
||||
}
|
||||
|
||||
function addBlocker(blockers, type, scope, summary) {
|
||||
const key = `${type}::${scope}`;
|
||||
if (!blockers.some((blocker) => `${blocker.type}::${blocker.scope}` === key)) {
|
||||
blockers.push({ type, scope, status: "open", summary: oneLine(summary) });
|
||||
}
|
||||
}
|
||||
|
||||
function oneLine(value) {
|
||||
return String(value).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
async function readJson(relativePath, blockers) {
|
||||
try {
|
||||
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
|
||||
} catch (error) {
|
||||
addBlocker(blockers, "contract_blocker", relativePath, `Cannot parse ${relativePath}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readText(relativePath) {
|
||||
return readFile(path.join(repoRoot, relativePath), "utf8");
|
||||
}
|
||||
|
||||
async function commandResult(command, args, timeoutMs = 15000) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] });
|
||||
const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: false, code: null, stdout, stderr: error.message });
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: code === 0, code, signal, stdout: stdout.trim(), stderr: stderr.trim() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findExecutable(name) {
|
||||
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
||||
if (!dir) continue;
|
||||
const candidate = path.join(dir, name);
|
||||
try {
|
||||
await access(candidate, fsConstants.X_OK);
|
||||
return candidate;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function listItems(document) {
|
||||
if (!document) return [];
|
||||
return document.kind === "List" ? document.items ?? [] : [document];
|
||||
}
|
||||
|
||||
function containersFor(item) {
|
||||
return item?.spec?.template?.spec?.containers ?? [];
|
||||
}
|
||||
|
||||
function serviceIdFor(item, container) {
|
||||
return (
|
||||
item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ||
|
||||
item?.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ||
|
||||
container?.name
|
||||
);
|
||||
}
|
||||
|
||||
function validateArgs(args, blockers) {
|
||||
for (const forbidden of ["--prod", "--production", "--read-secret", "--force-push", "--heavyweight-e2e"]) {
|
||||
if (args.flags.has(forbidden)) {
|
||||
addBlocker(blockers, "safety_blocker", forbidden.slice(2), `${forbidden} is forbidden for DEV deploy apply`);
|
||||
}
|
||||
}
|
||||
if (args.apply && (!args.flags.has("--confirm-dev") || !args.flags.has("--confirmed-non-production"))) {
|
||||
addBlocker(
|
||||
blockers,
|
||||
"safety_blocker",
|
||||
"apply-confirmation",
|
||||
"Live apply requires --confirm-dev and --confirmed-non-production"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function commitMatchesSource(commitId, sourceCommitId) {
|
||||
return sourceCommitId.startsWith(commitId) || commitId.startsWith(sourceCommitId);
|
||||
}
|
||||
|
||||
function validateDeployAndCatalog(deploy, catalog, sourceCommitId, blockers) {
|
||||
const artifactEvidence = [];
|
||||
if (!deploy || !catalog) return artifactEvidence;
|
||||
|
||||
if (deploy.environment !== ENVIRONMENT_DEV || deploy.namespace !== namespace) {
|
||||
addBlocker(blockers, "safety_blocker", "deploy-target", "deploy/deploy.json must target only dev hwlab-dev");
|
||||
}
|
||||
if (deploy.profiles?.prod?.enabled !== false) {
|
||||
addBlocker(blockers, "safety_blocker", "prod-profile", "PROD profile must remain disabled");
|
||||
}
|
||||
if (catalog.environment !== ENVIRONMENT_DEV || catalog.namespace !== namespace) {
|
||||
addBlocker(blockers, "safety_blocker", "catalog-target", "artifact catalog must target only dev hwlab-dev");
|
||||
}
|
||||
if (!commitMatchesSource(deploy.commitId, sourceCommitId) || !commitMatchesSource(catalog.commitId, sourceCommitId)) {
|
||||
addBlocker(
|
||||
blockers,
|
||||
"observability_blocker",
|
||||
"artifact-source-commit",
|
||||
`deploy/catalog artifact commit ${deploy.commitId}/${catalog.commitId} does not match source ${sourceCommitId}`
|
||||
);
|
||||
}
|
||||
|
||||
const deployById = new Map((deploy.services ?? []).map((service) => [service.serviceId, service]));
|
||||
const catalogById = new Map((catalog.services ?? []).map((service) => [service.serviceId, service]));
|
||||
for (const serviceId of SERVICE_IDS) {
|
||||
const deployService = deployById.get(serviceId);
|
||||
const catalogService = catalogById.get(serviceId);
|
||||
if (!deployService || !catalogService) {
|
||||
addBlocker(blockers, "contract_blocker", `service-${serviceId}`, `${serviceId} missing from deploy or catalog`);
|
||||
continue;
|
||||
}
|
||||
if (deployService.namespace !== namespace || deployService.profile !== ENVIRONMENT_DEV) {
|
||||
addBlocker(blockers, "safety_blocker", `service-target-${serviceId}`, `${serviceId} is not DEV-only`);
|
||||
}
|
||||
if (deployService.image !== catalogService.image) {
|
||||
addBlocker(blockers, "contract_blocker", `service-image-${serviceId}`, `${serviceId} image differs in deploy/catalog`);
|
||||
}
|
||||
artifactEvidence.push({
|
||||
serviceId,
|
||||
deployCommitId: deploy.commitId,
|
||||
catalogCommitId: catalogService.commitId,
|
||||
image: deployService.image,
|
||||
digest: catalogService.digest,
|
||||
publishState: catalogService.publishState
|
||||
});
|
||||
}
|
||||
|
||||
const hasPublishedCatalog =
|
||||
catalog.publish?.ciPublished === true &&
|
||||
catalog.publish?.registryVerified === true &&
|
||||
catalog.services?.every(
|
||||
(service) => service.digest?.startsWith("sha256:") && service.publishState !== "skeleton-only"
|
||||
);
|
||||
if (!hasPublishedCatalog) {
|
||||
addBlocker(
|
||||
blockers,
|
||||
"observability_blocker",
|
||||
"artifact-publish",
|
||||
"DEV artifact catalog has no CI publish, registry verification, or registry digests"
|
||||
);
|
||||
}
|
||||
return artifactEvidence;
|
||||
}
|
||||
|
||||
function validateK8s(devKustomization, namespaceDoc, workloads, services, healthContract, deploy, blockers) {
|
||||
const items = [...listItems(namespaceDoc), ...listItems(workloads), ...listItems(services), ...listItems(healthContract)];
|
||||
if (devKustomization?.namespace !== namespace) {
|
||||
addBlocker(blockers, "safety_blocker", "dev-kustomization", "DEV kustomization must pin namespace hwlab-dev");
|
||||
}
|
||||
if ((devKustomization?.resources ?? []).some((resource) => resource.includes("prod"))) {
|
||||
addBlocker(blockers, "safety_blocker", "dev-kustomization-prod", "DEV kustomization must not reference PROD resources");
|
||||
}
|
||||
for (const item of items) {
|
||||
const itemNamespace = item?.metadata?.namespace ?? (item?.kind === "Namespace" ? item?.metadata?.name : namespace);
|
||||
if (itemNamespace !== namespace) {
|
||||
addBlocker(blockers, "safety_blocker", `k8s-${item?.kind}-${item?.metadata?.name}`, "Kubernetes resource is not in hwlab-dev");
|
||||
}
|
||||
}
|
||||
|
||||
const expectedImages = new Map((deploy?.services ?? []).map((service) => [service.serviceId, service.image]));
|
||||
const workloadItems = listItems(workloads);
|
||||
const images = [];
|
||||
for (const item of workloadItems) {
|
||||
for (const container of containersFor(item)) {
|
||||
const serviceId = serviceIdFor(item, container);
|
||||
const expectedImage = expectedImages.get(serviceId);
|
||||
images.push({ serviceId, image: container.image, kind: item.kind, name: item.metadata?.name });
|
||||
if (expectedImage && container.image !== expectedImage) {
|
||||
addBlocker(blockers, "contract_blocker", `k8s-image-${serviceId}`, `${serviceId} image differs in k8s workload`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
resourceCounts: {
|
||||
workloads: workloadItems.length,
|
||||
services: listItems(services).length,
|
||||
configMaps: listItems(healthContract).filter((item) => item.kind === "ConfigMap").length
|
||||
},
|
||||
images
|
||||
};
|
||||
}
|
||||
|
||||
async function checkCloudApiDb(deploy, workloads, blockers) {
|
||||
const envNames = new Set(Object.keys(deploy?.services?.find((service) => service.serviceId === "hwlab-cloud-api")?.env ?? {}));
|
||||
for (const item of listItems(workloads)) {
|
||||
for (const container of containersFor(item)) {
|
||||
if (serviceIdFor(item, container) === "hwlab-cloud-api") {
|
||||
for (const env of container.env ?? []) envNames.add(env.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
const dbEnvNames = [...envNames].filter((name) => /DATABASE|POSTGRES|MYSQL|SQLITE|MONGO|_DB(_|$)/u.test(name));
|
||||
if (dbEnvNames.length === 0) {
|
||||
addBlocker(blockers, "runtime_blocker", "cloud-api-db-config", "cloud-api has no DEV DB connection env configured");
|
||||
}
|
||||
|
||||
try {
|
||||
const modulePath = pathToFileURL(path.join(repoRoot, "internal/cloud/server.mjs")).href;
|
||||
const { buildHealthPayload } = await import(modulePath);
|
||||
const health = buildHealthPayload();
|
||||
if (health.db?.connected !== true) {
|
||||
addBlocker(blockers, "runtime_blocker", "cloud-api-db", "cloud-api health reports DB not connected in this artifact");
|
||||
}
|
||||
} catch (error) {
|
||||
addBlocker(blockers, "observability_blocker", "cloud-api-health", `Cannot inspect cloud-api health payload: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function probeDevEndpoint(blockers) {
|
||||
const url = new URL(healthPath, DEV_ENDPOINT).href;
|
||||
try {
|
||||
const response = await httpGet(url, 8000);
|
||||
const text = response.body;
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {}
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
addBlocker(blockers, "network_blocker", "dev-health", `DEV health returned HTTP ${response.statusCode}`);
|
||||
}
|
||||
if (json?.serviceId !== "hwlab-cloud-api" || json?.environment !== ENVIRONMENT_DEV) {
|
||||
addBlocker(blockers, "runtime_blocker", "dev-health-identity", "DEV health did not identify hwlab-cloud-api in dev");
|
||||
}
|
||||
if (json?.db?.connected !== true) {
|
||||
addBlocker(blockers, "runtime_blocker", "dev-health-db", "DEV health did not confirm cloud-api DB connectivity");
|
||||
}
|
||||
return {
|
||||
status: response.statusCode >= 200 && response.statusCode < 300 ? "pass" : "blocked",
|
||||
url,
|
||||
httpStatus: response.statusCode,
|
||||
body: text.slice(0, 500)
|
||||
};
|
||||
} catch (error) {
|
||||
const reason = oneLine(error.cause?.message ?? error.message);
|
||||
addBlocker(blockers, "network_blocker", "dev-health", `Cannot reach ${url}: ${reason}`);
|
||||
return { status: "blocked", url, error: reason };
|
||||
}
|
||||
}
|
||||
|
||||
function httpGet(url, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = httpRequest(url, { method: "GET", timeout: timeoutMs }, (response) => {
|
||||
response.setEncoding("utf8");
|
||||
let body = "";
|
||||
response.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
response.on("end", () => resolve({ statusCode: response.statusCode ?? 0, body }));
|
||||
});
|
||||
request.on("timeout", () => {
|
||||
request.destroy(new Error(`timeout after ${timeoutMs}ms`));
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function observeCluster(kubectlPath, blockers) {
|
||||
if (!kubectlPath) {
|
||||
addBlocker(blockers, "environment_blocker", "kubectl", "kubectl is not installed in the runner");
|
||||
return { status: "blocked", executor: "missing", resources: [] };
|
||||
}
|
||||
const version = await commandResult(kubectlPath, ["version", "--client=true", "--output=yaml"]);
|
||||
if (!version.ok) {
|
||||
addBlocker(blockers, "environment_blocker", "kubectl-version", `kubectl client failed: ${version.stderr || version.stdout}`);
|
||||
return { status: "blocked", executor: kubectlPath, resources: [] };
|
||||
}
|
||||
const get = await commandResult(kubectlPath, ["get", "pods,services,configmaps,deployments,jobs", "-n", namespace, "-o", "json"], 20000);
|
||||
if (!get.ok) {
|
||||
addBlocker(blockers, "environment_blocker", "cluster-read", `Cannot read hwlab-dev resources: ${get.stderr || get.stdout}`);
|
||||
return { status: "blocked", executor: kubectlPath, version: version.stdout, resources: [] };
|
||||
}
|
||||
const parsed = JSON.parse(get.stdout);
|
||||
return {
|
||||
status: "pass",
|
||||
executor: kubectlPath,
|
||||
version: version.stdout,
|
||||
resources: (parsed.items ?? []).map((item) => ({
|
||||
kind: item.kind,
|
||||
name: item.metadata?.name,
|
||||
phase: item.status?.phase,
|
||||
readyReplicas: item.status?.readyReplicas,
|
||||
replicas: item.status?.replicas
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
async function runApplyStep(args, kubectlPath, blockers) {
|
||||
if (blockers.length > 0) {
|
||||
return { status: "not_run", summary: "Skipped because preflight blockers are open" };
|
||||
}
|
||||
const commandArgs = args.apply ? ["apply", "-k", "deploy/k8s/dev"] : ["apply", "--dry-run=server", "-k", "deploy/k8s/dev"];
|
||||
const result = await commandResult(kubectlPath, commandArgs, 30000);
|
||||
if (!result.ok) {
|
||||
addBlocker(blockers, "environment_blocker", args.apply ? "kubectl-apply" : "kubectl-dry-run", result.stderr || result.stdout);
|
||||
}
|
||||
return { status: result.ok ? "pass" : "blocked", command: `kubectl ${commandArgs.join(" ")}`, stdout: result.stdout, stderr: result.stderr };
|
||||
}
|
||||
|
||||
async function sourceCommit() {
|
||||
const result = await commandResult("git", ["rev-parse", "--short=12", "HEAD"]);
|
||||
return result.ok ? result.stdout : "unknown";
|
||||
}
|
||||
|
||||
export async function runDevDeployApply(argv, io = {}) {
|
||||
const stdout = io.stdout ?? process.stdout;
|
||||
const args = parseArgs(argv);
|
||||
const blockers = [];
|
||||
validateArgs(args, blockers);
|
||||
const commitId = await sourceCommit();
|
||||
|
||||
const [deploy, catalog, devKustomization, namespaceDoc, workloads, services, healthContract] = await Promise.all([
|
||||
readJson("deploy/deploy.json", blockers),
|
||||
readJson("deploy/artifact-catalog.dev.json", blockers),
|
||||
readJson("deploy/k8s/dev/kustomization.yaml", blockers),
|
||||
readJson("deploy/k8s/base/namespace.yaml", blockers),
|
||||
readJson("deploy/k8s/base/workloads.yaml", blockers),
|
||||
readJson("deploy/k8s/base/services.yaml", blockers),
|
||||
readJson("deploy/k8s/dev/health-contract.yaml", blockers)
|
||||
]);
|
||||
|
||||
const artifactEvidence = validateDeployAndCatalog(deploy, catalog, commitId, blockers);
|
||||
const k8sManifest = validateK8s(devKustomization, namespaceDoc, workloads, services, healthContract, deploy, blockers);
|
||||
await checkCloudApiDb(deploy, workloads, blockers);
|
||||
if (!(await readText("internal/cloud/server.mjs")).includes('url.pathname === "/health/live"')) {
|
||||
addBlocker(blockers, "contract_blocker", "cloud-api-health-path", "cloud-api does not implement the k8s /health/live path");
|
||||
}
|
||||
|
||||
const kubectlPath = await findExecutable("kubectl");
|
||||
const clusterObservation = await observeCluster(kubectlPath, blockers);
|
||||
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 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",
|
||||
taskId: "dev-deploy-apply",
|
||||
commitId,
|
||||
acceptanceLevel: "dev_deploy_apply",
|
||||
devOnly: true,
|
||||
prodDisabled: true,
|
||||
status,
|
||||
generatedAt: new Date().toISOString(),
|
||||
namespace,
|
||||
endpoint: DEV_ENDPOINT,
|
||||
sourceContract: {
|
||||
status: "pass",
|
||||
documents: [
|
||||
"docs/dev-acceptance-matrix.md",
|
||||
"docs/m0-contract-audit.md",
|
||||
"docs/dev-deploy-apply.md",
|
||||
"docs/artifact-catalog.md",
|
||||
"deploy/README.md"
|
||||
],
|
||||
summary: "DEV deploy apply is pinned to hwlab-dev and the frozen HWLAB service IDs"
|
||||
},
|
||||
validationCommands: requiredValidationCommands,
|
||||
localSmoke: {
|
||||
status: "not_run",
|
||||
commands: ["node scripts/m1-contract-smoke.mjs"],
|
||||
evidence: ["This deploy apply task did not run local smoke because artifact/executor blockers stop promotion first."],
|
||||
summary: "Local smoke remains available but is not a substitute for DEV artifact publish evidence."
|
||||
},
|
||||
dryRun: {
|
||||
status,
|
||||
commands: [
|
||||
"node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run",
|
||||
"node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked"
|
||||
],
|
||||
evidence: [
|
||||
`artifact services checked: ${artifactEvidence.length}`,
|
||||
`kubectl executor: ${kubectlPath ?? "missing"}`,
|
||||
`live health: ${liveProbe.status}`
|
||||
],
|
||||
summary: status === "blocked" ? "DEV apply is blocked before mutation." : "DEV dry-run preflight passed."
|
||||
},
|
||||
devPreconditions: {
|
||||
status,
|
||||
requirements: [
|
||||
"DEV artifact catalog must contain CI publish evidence and registry digests",
|
||||
"kubectl must be available for D601 hwlab-dev and must not target PROD",
|
||||
"hwlab-cloud-api /health/live must report serviceId, dev environment, and DB connectivity",
|
||||
"pods, services, configmaps, and image commit tags must be observable in hwlab-dev"
|
||||
],
|
||||
summary: status === "blocked" ? "One or more required DEV deploy preconditions are blocked." : "DEV deploy preconditions are satisfied."
|
||||
},
|
||||
devDeployApply: {
|
||||
mode: args.apply ? "apply" : "dry-run",
|
||||
mutationAttempted: applyStep.status === "pass" && args.apply,
|
||||
applyStep,
|
||||
artifactEvidence,
|
||||
k8sManifest,
|
||||
clusterObservation,
|
||||
liveProbe
|
||||
},
|
||||
blockers,
|
||||
notes: "DEV-only result. No PROD resources, secret values, force push, heavy e2e, browser e2e, or UniDesk runtime substitute were used."
|
||||
};
|
||||
|
||||
if (args.writeReport) {
|
||||
await writeFile(path.join(repoRoot, reportPath), `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
|
||||
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
return status === "blocked" && !args.expectBlocked ? 2 : 0;
|
||||
}
|
||||
|
||||
export function formatFailure(error) {
|
||||
return {
|
||||
reportVersion: "v1",
|
||||
issue: "pikasTech/HWLAB#33",
|
||||
taskId: "dev-deploy-apply",
|
||||
status: "failed",
|
||||
blockers: [{ type: "observability_blocker", scope: "script", status: "open", summary: oneLine(error.message) }]
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const reportsDir = path.join(repoRoot, "reports/dev-gate");
|
||||
|
||||
const requiredIssue = "pikasTech/HWLAB#31";
|
||||
const allowedIssues = new Set(["pikasTech/HWLAB#31", "pikasTech/HWLAB#33"]);
|
||||
const requiredValidationCommands = [
|
||||
"node --check scripts/validate-dev-gate-report.mjs",
|
||||
"node scripts/validate-dev-gate-report.mjs"
|
||||
@@ -138,7 +138,7 @@ async function validateReport(relativePath) {
|
||||
assertString(report.$schema, `${label}.$schema`);
|
||||
assertString(report.$id, `${label}.$id`);
|
||||
assert.equal(report.reportVersion, "v1", `${label}.reportVersion`);
|
||||
assert.equal(report.issue, requiredIssue, `${label}.issue`);
|
||||
assert.ok(allowedIssues.has(report.issue), `${label}.issue must be a known DEV gate issue`);
|
||||
assert.match(report.taskId, /^[a-z][a-z0-9-]*$/, `${label}.taskId`);
|
||||
assert.match(report.commitId, /^[a-f0-9]{7,40}$/, `${label}.commitId`);
|
||||
assert.match(report.acceptanceLevel, /^[a-z][a-z0-9_-]*$/, `${label}.acceptanceLevel`);
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
import { runCli } from "../lib/cli.mjs";
|
||||
|
||||
const exitCode = await runCli(process.argv.slice(2), {
|
||||
cwd: process.cwd(),
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr
|
||||
});
|
||||
|
||||
process.exitCode = exitCode;
|
||||
Reference in New Issue
Block a user