#!/usr/bin/env node import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs"; import { DEV_ENDPOINT, DEV_FRONTEND_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs"; const execFileAsync = promisify(execFile); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const fixturePath = path.join(repoRoot, "fixtures/dev-deploy-smoke/dev-deploy-smoke.json"); const defaultActiveReportPath = path.join(repoRoot, "reports/dev-gate/dev-m2-deploy-smoke-active.json"); const legacyPublicEndpoint = "http://74.48.78.17:6667"; const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; const digestPattern = /^(sha256:[a-f0-9]{64}|not_applicable)$/; const tagPattern = /^m2-dev-deploy-smoke-[a-f0-9]{7}$/; const allowedFailureClassifications = new Set([ "network_blocker", "runtime_blocker", "agent_blocker", "observability_blocker", "safety_blocker" ]); function parseArgs(argv) { const flags = new Set(); const options = { reportPath: defaultActiveReportPath, timeoutMs: 5000 }; const rest = []; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--report") { flags.add(arg); index += 1; assert.ok(argv[index], "--report requires a path"); options.reportPath = path.resolve(process.cwd(), argv[index]); } else if (arg === "--timeout-ms") { flags.add(arg); index += 1; options.timeoutMs = Number.parseInt(argv[index], 10); assert.ok(Number.isInteger(options.timeoutMs) && options.timeoutMs > 0, "--timeout-ms must be positive"); } else if (arg.startsWith("--")) { flags.add(arg); } else { rest.push(arg); } } return { flags, options, rest }; } function loadFixture() { return readFile(fixturePath, "utf8").then((raw) => JSON.parse(raw)); } function assertTimestamp(value, context) { assert.equal(typeof value, "string", `${context} must be a string`); assert.match(value, timestampPattern, `${context} must be an RFC 3339 UTC timestamp`); } function assertServiceContract(entry) { assert.ok(SERVICE_IDS.includes(entry.serviceId), `unknown serviceId ${entry.serviceId}`); assert.equal(entry.deployEnv, ENVIRONMENT_DEV, `${entry.serviceId} deployEnv must be dev`); assert.ok(!Object.hasOwn(entry, "endpoint"), `${entry.serviceId} service contract must not carry endpoint`); assert.equal(typeof entry.commitId, "string", `${entry.serviceId} commitId must be a string`); assert.match(entry.commitId, /^[a-f0-9]{7}$/u, `${entry.serviceId} commitId must be a short sha`); assert.equal(typeof entry.image, "string", `${entry.serviceId} image must be a string`); assert.ok(entry.image.length > 0, `${entry.serviceId} image must not be empty`); assert.equal(typeof entry.tag, "string", `${entry.serviceId} tag must be a string`); assert.match(entry.tag, tagPattern, `${entry.serviceId} tag must encode the smoke commit`); assert.equal(typeof entry.digest, "string", `${entry.serviceId} digest must be a string`); assert.match(entry.digest, digestPattern, `${entry.serviceId} digest must be sha256 or not_applicable`); if (entry.digest === "not_applicable") { assert.equal(typeof entry.digestReason, "string", `${entry.serviceId} digestReason required when digest is not_applicable`); assert.ok(entry.digestReason.length > 0, `${entry.serviceId} digestReason must not be empty`); } else { assert.equal(entry.digestReason, undefined, `${entry.serviceId} digestReason must be absent when digest is present`); } assert.equal(typeof entry.buildSource, "string", `${entry.serviceId} buildSource must be a string`); assert.ok(entry.buildSource.includes("origin/main"), `${entry.serviceId} buildSource must point at origin/main`); assertTimestamp(entry.healthTimestamp, `${entry.serviceId} healthTimestamp`); assert.equal(typeof entry.routePhase, "number", `${entry.serviceId} routePhase must be numeric`); assert.ok(entry.routePhase > 0, `${entry.serviceId} routePhase must be positive`); assert.ok(entry.phase.length > 0, `${entry.serviceId} phase must not be empty`); assert.equal(typeof entry.health, "object", `${entry.serviceId} health must be an object`); assert.ok(entry.health && !Array.isArray(entry.health), `${entry.serviceId} health must be a plain object`); assert.ok(typeof entry.health.status === "string", `${entry.serviceId} health.status must be a string`); assert.equal(typeof entry.failureClassification, "string", `${entry.serviceId} failureClassification must be a string`); assert.ok(entry.failureClassification.endsWith("_blocker"), `${entry.serviceId} failureClassification must end in _blocker`); assert.ok( allowedFailureClassifications.has(entry.failureClassification), `${entry.serviceId} failureClassification must be one of the frozen blocker classes` ); } function assertRoutePhaseOrder(entries) { const phases = entries.map((entry) => entry.routePhase); assert.deepEqual(phases, [...phases].sort((a, b) => a - b), "route phases must be sorted"); assert.equal(new Set(phases).size, phases.length, "route phases must be unique"); } function assertAllServiceIds(entries) { const serviceIds = entries.map((entry) => entry.serviceId); assert.equal(new Set(serviceIds).size, serviceIds.length, "service ids must be unique in fixture"); for (const serviceId of serviceIds) { assert.ok(SERVICE_IDS.includes(serviceId), `fixture uses unknown serviceId ${serviceId}`); } } function assertCloudSurface(entry) { const healthArtifacts = entry.health.artifacts ?? []; assert.equal(entry.serviceId, "hwlab-cloud-api", "cloud surface entry must start at cloud-api"); assert.equal(entry.health.db?.fixtureOnly, true, "cloud surface DB note must be fixture-only"); assert.equal(entry.health.db?.liveDbEvidence, false, "cloud surface fixture must not claim live DB evidence"); assert.deepEqual( entry.health.db?.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"], "cloud surface fixture must name missing DB env without values" ); assert.equal(healthArtifacts.length, 2, "cloud surface must include cloud-api and cloud-web artifacts"); assert.deepEqual( healthArtifacts.map((artifact) => artifact.serviceId), ["hwlab-cloud-api", "hwlab-cloud-web"], "cloud surface artifacts must list api/web" ); for (const artifact of healthArtifacts) { assert.ok(SERVICE_IDS.includes(artifact.serviceId), `cloud surface artifact ${artifact.serviceId} must be known`); assert.equal(typeof artifact.image, "string", `cloud surface artifact ${artifact.serviceId} image must exist`); assert.equal(typeof artifact.tag, "string", `cloud surface artifact ${artifact.serviceId} tag must exist`); assert.match(artifact.tag, tagPattern, `cloud surface artifact ${artifact.serviceId} tag must encode the smoke commit`); assert.match(artifact.digest, /^sha256:[a-f0-9]{64}$/u, `cloud surface artifact ${artifact.serviceId} digest must be sha256`); assert.equal(typeof artifact.commitId, "string", `cloud surface artifact ${artifact.serviceId} commitId must exist`); assert.equal(typeof artifact.buildSource, "string", `cloud surface artifact ${artifact.serviceId} buildSource must exist`); assert.equal(artifact.deployEnv, ENVIRONMENT_DEV, `cloud surface artifact ${artifact.serviceId} deployEnv must be dev`); assertTimestamp(artifact.healthTimestamp ?? entry.healthTimestamp, `cloud surface artifact ${artifact.serviceId} healthTimestamp`); } } function assertFixture(fixture) { assert.equal(fixture.smokeId, "m2-dev-deploy-smoke"); assert.equal(fixture.issue, "pikasTech/HWLAB#23"); assert.equal(fixture.environment, ENVIRONMENT_DEV); assert.equal(fixture.endpoint, DEV_ENDPOINT); assert.equal(fixture.frontendEndpoint, DEV_FRONTEND_ENDPOINT); assert.equal(fixture.legacyPublicEndpoint?.endpoint, legacyPublicEndpoint); assert.equal(fixture.legacyPublicEndpoint?.activeGreenEligible, false); assert.equal(fixture.defaultMode, "dry-run"); assert.equal(fixture.safetyGates.dryRunOnly, true, "dry-run must be the default"); assert.ok(Array.isArray(fixture.safetyGates.realRequestsRequire), "real request gates must exist"); assert.ok(fixture.safetyGates.realRequestsRequire.includes("explicit --live flag")); const entries = fixture.serviceContracts; assert.ok(Array.isArray(entries) && entries.length >= 8, "serviceContracts must contain route observations"); assertAllServiceIds(entries); assertRoutePhaseOrder(entries); for (const entry of entries) { assertServiceContract(entry); } const cloudSurface = entries.find((entry) => entry.phase === "cloud-surface"); assert.ok(cloudSurface, "cloud-surface entry must exist"); assertCloudSurface(cloudSurface); const agentSkills = entries.find((entry) => entry.serviceId === "hwlab-agent-skills"); assert.equal(agentSkills.digest, "not_applicable"); assert.equal(agentSkills.digestReason, "local dry-run fixture for repo-local skills bundle"); const observations = fixture.observations; const expectedObservationIds = []; for (const entry of entries) { expectedObservationIds.push(entry.serviceId); if (entry.serviceId === "hwlab-cloud-api") { expectedObservationIds.push("hwlab-cloud-web"); } } assert.deepEqual(observations.serviceIds, expectedObservationIds, "observation service ids must track validated entries"); assert.deepEqual( observations.routePhases, ["master-edge-proxy", "frp-tunnel", "d601-router", "cloud-surface", "agent-runtime", "sim-and-patch-panel"], "route phase labels must stay frozen" ); assertTimestamp(observations.healthTimestamp, "observations.healthTimestamp"); } function redactedHeaders(headers) { return Object.fromEntries( ["content-type", "content-length", "cache-control", "date"].flatMap((name) => { const value = headers.get(name); return value ? [[name, value]] : []; }) ); } async function probeHttp({ id, url, expect }) { const startedAt = Date.now(); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), expect.timeoutMs); try { const response = await fetch(url, { signal: controller.signal }); const body = await response.text(); const contentType = response.headers.get("content-type") ?? ""; let json = null; if (contentType.includes("application/json")) { try { json = JSON.parse(body); } catch { json = null; } } return { id, url, ok: response.ok, status: response.status, statusText: response.statusText, durationMs: Date.now() - startedAt, contentType, headers: redactedHeaders(response.headers), json, title: body.match(/([^<]+)<\/title>/iu)?.[1] ?? null, bodyPreview: body.slice(0, 512) }; } catch (error) { return { id, url, ok: false, status: null, statusText: null, durationMs: Date.now() - startedAt, contentType: null, headers: {}, json: null, title: null, bodyPreview: "", error: error?.name === "AbortError" ? `timeout after ${expect.timeoutMs}ms` : String(error?.message ?? error) }; } finally { clearTimeout(timeout); } } function acceptedProbe(probe) { if (!probe.ok) return false; if (probe.id === "frontend-root") { return probe.contentType?.includes("text/html") && probe.title === "HWLAB DEV MVP Gate"; } return Boolean( probe.json && SERVICE_IDS.includes(probe.json.serviceId) && probe.json.environment === ENVIRONMENT_DEV && (probe.json.endpoint === undefined || probe.json.endpoint === DEV_ENDPOINT) ); } function probeEvidenceLine(probe) { const accepted = acceptedProbe(probe) ? "yes" : "no"; const identity = probe.json?.serviceId ?? probe.title ?? "unknown"; return `${probe.id}: HTTP ${probe.status ?? "none"} accepted=${accepted} identity=${identity} url=${probe.url}`; } async function gitCommitId() { try { const result = await execFileAsync("git", ["rev-parse", "--short=12", "HEAD"], { cwd: repoRoot, timeout: 5000 }); return result.stdout.trim(); } catch { return "unknown"; } } function buildActiveReport({ fixture, probes, generatedAt, commitId }) { const passed = probes.every(acceptedProbe); const status = passed ? "pass" : "blocked"; const commands = [ "curl -fsS --max-time 10 http://74.48.78.17:16667/health", "curl -fsS --max-time 10 http://74.48.78.17:16667/health/live", "curl -fsS --max-time 10 http://74.48.78.17:16666/" ]; return { $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", $id: "https://hwlab.pikastech.local/reports/dev-gate/dev-m2-deploy-smoke-active.json", reportVersion: "v1", issue: "pikasTech/HWLAB#23", taskId: "m2-dev-deploy-smoke", commitId, acceptanceLevel: "dev_m2_deploy_smoke", devOnly: true, prodDisabled: true, reportLifecycle: activeReportLifecycle( "Active M2 read-only public endpoint smoke; route reachability is not M5 DEV-LIVE acceptance." ), status, generatedAt, endpoint: DEV_ENDPOINT, frontendEndpoint: DEV_FRONTEND_ENDPOINT, sourceContract: { status: "pass", documents: [ "docs/m2-dev-deploy-smoke.md", "docs/dev-acceptance-matrix.md", "fixtures/dev-deploy-smoke/dev-deploy-smoke.json" ], summary: "M2 DEV deploy smoke is rebaselined to frontend :16666 and API/edge :16667." }, validationCommands: [ "node --check scripts/m2-dev-deploy-smoke.mjs", "node scripts/m2-dev-deploy-smoke.mjs --dry-run", "node scripts/m2-dev-deploy-smoke.mjs --live --confirm-dev --confirmed-non-production --write-report", "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs" ], localSmoke: { status: "pass", commands: ["node scripts/m2-dev-deploy-smoke.mjs --dry-run"], evidence: [ `[m2-smoke] validated ${fixture.serviceContracts.length} service contracts`, `[m2-smoke] mode=dry-run endpoint=${DEV_ENDPOINT}`, "[m2-smoke] no real DEV/PROD request was made" ], summary: "The fixture smoke validates the M2 route and artifact contract without contacting DEV." }, dryRun: { status: "pass", commands: ["node scripts/m2-dev-deploy-smoke.mjs --dry-run"], evidence: [ `fixture endpoint=${DEV_ENDPOINT}`, `fixture frontendEndpoint=${DEV_FRONTEND_ENDPOINT}`, "legacy :6667 public evidence is marked ineligible for active green" ], summary: "Dry-run evidence is contract-only and pinned to the current DEV public endpoints." }, devPreconditions: { status, requirements: [ "GET http://74.48.78.17:16667/health returns HWLAB DEV JSON", "GET http://74.48.78.17:16667/health/live returns HWLAB DEV JSON", "GET http://74.48.78.17:16666/ returns the HWLAB DEV frontend", "No PROD, secret read, runtime restart, deployment, or heavyweight e2e action is performed" ], commands, evidence: probes.map(probeEvidenceLine), summary: passed ? "Read-only DEV public endpoint probes passed on :16666/:16667." : "One or more read-only DEV public endpoint probes failed; no active green is claimed." }, blockers: passed ? [] : [ { type: "network_blocker", scope: "m2-public-endpoints", status: "open", summary: "One or more M2 DEV public endpoint probes failed on :16666/:16667." } ], runtimeSmoke: { mode: "live-read-only", status, generatedAt, apiEndpoint: DEV_ENDPOINT, frontendEndpoint: DEV_FRONTEND_ENDPOINT, safety: { environment: ENVIRONMENT_DEV, prodTouched: false, secretsRead: false, restarts: false, deployAttempted: false, heavyE2E: false, unideskRuntimeSubstitute: false }, probes, legacyPublicEndpoint: { endpoint: legacyPublicEndpoint, status: "deprecated", activeGreenEligible: false, summary: "Legacy public :6667 observations are retained only as historical blockers and are not accepted as active green evidence." } }, notes: "M2 active report uses read-only GET probes only; PROD, service restarts, deployments, and script-driven apply flows were not touched." }; } async function writeActiveReport(report, reportPath) { await mkdir(path.dirname(reportPath), { recursive: true }); await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); } async function runLiveSmoke(fixture, options) { const probes = await Promise.all([ probeHttp({ id: "api-health", url: new URL("/health", DEV_ENDPOINT).href, expect: { timeoutMs: options.timeoutMs } }), probeHttp({ id: "api-live", url: new URL("/health/live", DEV_ENDPOINT).href, expect: { timeoutMs: options.timeoutMs } }), probeHttp({ id: "frontend-root", url: new URL("/", DEV_FRONTEND_ENDPOINT).href, expect: { timeoutMs: options.timeoutMs } }) ]); const report = buildActiveReport({ fixture, probes, generatedAt: new Date().toISOString(), commitId: await gitCommitId() }); return report; } async function main() { const { flags, options } = parseArgs(process.argv.slice(2)); const isLive = flags.has("--live") || flags.has("--allow-live"); if (isLive) { assert.ok(!flags.has("--dry-run"), "do not combine --live with --dry-run"); assert.ok(flags.has("--confirm-dev"), "live mode requires --confirm-dev"); assert.ok(flags.has("--confirmed-non-production"), "live mode requires --confirmed-non-production"); assert.ok(!flags.has("--prod"), "PROD requests are forbidden"); assert.ok(!flags.has("--heavyweight-e2e"), "heavyweight e2e is forbidden"); assert.ok(!flags.has("--read-secret"), "secret reads are forbidden"); assert.ok(!flags.has("--force-push"), "force push is forbidden"); } const fixture = await loadFixture(); assertFixture(fixture); assert.equal(fixture.endpoint, DEV_ENDPOINT, "fixture must stay pinned to the frozen DEV endpoint"); process.stdout.write(`[m2-smoke] validated ${fixture.serviceContracts.length} service contracts\n`); process.stdout.write(`[m2-smoke] mode=${isLive ? "live" : "dry-run"} endpoint=${fixture.endpoint}\n`); if (isLive) { const report = await runLiveSmoke(fixture, options); if (flags.has("--write-report")) { await writeActiveReport(report, options.reportPath); process.stdout.write(`[m2-smoke] wrote ${path.relative(repoRoot, options.reportPath)}\n`); } process.stdout.write(`[m2-smoke] active-report=${report.status} probes=${report.runtimeSmoke.probes.length}\n`); process.stdout.write("[m2-smoke] no DEV/PROD mutation was made\n"); if (report.status !== "pass") { process.exitCode = 2; } } else { process.stdout.write("[m2-smoke] no real DEV/PROD request was made\n"); } } try { await main(); } catch (error) { process.stderr.write(`[m2-smoke] ${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }