diff --git a/.gitignore b/.gitignore index afadf021..44450299 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ Thumbs.db # Build outputs bin/ +!tools/hwlab-cli/bin/ +!tools/hwlab-cli/bin/hwlab-cli.mjs dist/ build/ coverage/ diff --git a/docs/dev-deploy-apply.md b/docs/dev-deploy-apply.md new file mode 100644 index 00000000..8b2e7f7e --- /dev/null +++ b/docs/dev-deploy-apply.md @@ -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. diff --git a/docs/dev-gate-report.md b/docs/dev-gate-report.md index 39b361d3..2c23f166 100644 --- a/docs/dev-gate-report.md +++ b/docs/dev-gate-report.md @@ -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 diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 73aade84..8114b8c7 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -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; } diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index a2746e9c..c563d584 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -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"); diff --git a/reports/dev-gate/dev-deploy-report.json b/reports/dev-gate/dev-deploy-report.json new file mode 100644 index 00000000..89ae4b92 --- /dev/null +++ b/reports/dev-gate/dev-deploy-report.json @@ -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." +} diff --git a/scripts/dev-deploy-apply.mjs b/scripts/dev-deploy-apply.mjs new file mode 100755 index 00000000..7bc2f5d4 --- /dev/null +++ b/scripts/dev-deploy-apply.mjs @@ -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; +} diff --git a/scripts/src/dev-deploy-apply.mjs b/scripts/src/dev-deploy-apply.mjs new file mode 100755 index 00000000..ae92cc01 --- /dev/null +++ b/scripts/src/dev-deploy-apply.mjs @@ -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) }] + }; +} diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 2b26866d..9c993ee2 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -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`); diff --git a/tools/hwlab-cli/bin/hwlab-cli.mjs b/tools/hwlab-cli/bin/hwlab-cli.mjs new file mode 100755 index 00000000..cb0ce648 --- /dev/null +++ b/tools/hwlab-cli/bin/hwlab-cli.mjs @@ -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;