From c1c9e72538576184297c215907be73cc540e57d3 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Fri, 22 May 2026 13:56:16 +0800 Subject: [PATCH] fix: rebaseline M2 DEV smoke endpoints (#77) * feat: add hwlab dev console homepage * fix: rebaseline M2 DEV smoke endpoints --------- Co-authored-by: HWLAB Code Queue --- docs/m2-dev-deploy-smoke.md | 27 +- .../dev-deploy-smoke/dev-deploy-smoke.json | 6 + internal/protocol/index.mjs | 1 + .../dev-gate/dev-m2-deploy-smoke-active.json | 256 ++++++++++++++++ scripts/m2-dev-deploy-smoke.mjs | 278 +++++++++++++++++- scripts/validate-dev-gate-report.mjs | 139 +++++++++ scripts/validate-m2-deploy-smoke-active.mjs | 76 +++++ 7 files changed, 773 insertions(+), 10 deletions(-) create mode 100644 reports/dev-gate/dev-m2-deploy-smoke-active.json create mode 100644 scripts/validate-m2-deploy-smoke-active.mjs diff --git a/docs/m2-dev-deploy-smoke.md b/docs/m2-dev-deploy-smoke.md index a465feea..2a2c842b 100644 --- a/docs/m2-dev-deploy-smoke.md +++ b/docs/m2-dev-deploy-smoke.md @@ -1,4 +1,4 @@ -# M2 DEV Deployment Smoke Dry-Run +# M2 DEV Deployment Smoke `m2-dev-deploy-smoke` is a local dry-run smoke harness for the HWLAB DEV deployment shape. It validates the frozen route phases, service identities, @@ -32,18 +32,24 @@ node scripts/m2-dev-deploy-smoke.mjs --dry-run ## Live Gate The live gate is intentionally strict. It is only for a future real DEV smoke -observation, not for deployment, and this harness stays fixture-only by -default. +observation, not for deployment, and this harness stays fixture-only unless the +operator supplies the live flags. The public DEV endpoint split is: + +- browser/frontend: `http://74.48.78.17:16666/` +- API/edge health: `http://74.48.78.17:16667/health` and + `http://74.48.78.17:16667/health/live` +- legacy public `:6667`: deprecated and never accepted as active green evidence Required flags: ```sh -node scripts/m2-dev-deploy-smoke.mjs --live --confirm-dev --confirmed-non-production +node scripts/m2-dev-deploy-smoke.mjs --live --confirm-dev --confirmed-non-production --write-report ``` Before using `--live`, confirm all of the following: -- the target remains `http://74.48.78.17:16667`; +- the API/edge target remains `http://74.48.78.17:16667`; +- the frontend target remains `http://74.48.78.17:16666`; - the workspace is not a PROD or shared deployment context; - no prohibited action is being attempted; - the request is observational only and does not mutate runtime state. @@ -57,6 +63,17 @@ The script rejects live execution if any of these are missing: - `--read-secret` - `--force-push` +When `--write-report` is provided, the script writes +`reports/dev-gate/dev-m2-deploy-smoke-active.json`. The report contains only +read-only GET evidence and marks legacy `:6667` observations as ineligible for +active green. + +Validate the written report with: + +```sh +node scripts/validate-m2-deploy-smoke-active.mjs +``` + ## Boundary This harness does not: diff --git a/fixtures/dev-deploy-smoke/dev-deploy-smoke.json b/fixtures/dev-deploy-smoke/dev-deploy-smoke.json index 4f0f8ab3..57a54928 100644 --- a/fixtures/dev-deploy-smoke/dev-deploy-smoke.json +++ b/fixtures/dev-deploy-smoke/dev-deploy-smoke.json @@ -3,6 +3,12 @@ "issue": "pikasTech/HWLAB#23", "environment": "dev", "endpoint": "http://74.48.78.17:16667", + "frontendEndpoint": "http://74.48.78.17:16666", + "legacyPublicEndpoint": { + "endpoint": "http://74.48.78.17:6667", + "status": "deprecated", + "activeGreenEligible": false + }, "defaultMode": "dry-run", "safetyGates": { "dryRunOnly": true, diff --git a/internal/protocol/index.mjs b/internal/protocol/index.mjs index 4b25cb64..3c68799c 100644 --- a/internal/protocol/index.mjs +++ b/internal/protocol/index.mjs @@ -1,5 +1,6 @@ export const ENVIRONMENT_DEV = "dev"; export const DEV_ENDPOINT = "http://74.48.78.17:16667"; +export const DEV_FRONTEND_ENDPOINT = "http://74.48.78.17:16666"; export const JSON_RPC_VERSION = "2.0"; export const SERVICE_IDS = Object.freeze([ diff --git a/reports/dev-gate/dev-m2-deploy-smoke-active.json b/reports/dev-gate/dev-m2-deploy-smoke-active.json new file mode 100644 index 00000000..1156c3fa --- /dev/null +++ b/reports/dev-gate/dev-m2-deploy-smoke-active.json @@ -0,0 +1,256 @@ +{ + "$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": "54f927b02bc1", + "acceptanceLevel": "dev_m2_deploy_smoke", + "devOnly": true, + "prodDisabled": true, + "status": "pass", + "generatedAt": "2026-05-22T04:53:00.375Z", + "endpoint": "http://74.48.78.17:16667", + "frontendEndpoint": "http://74.48.78.17:16666", + "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 11 service contracts", + "[m2-smoke] mode=dry-run endpoint=http://74.48.78.17:16667", + "[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=http://74.48.78.17:16667", + "fixture frontendEndpoint=http://74.48.78.17:16666", + "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": "pass", + "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": [ + "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/" + ], + "evidence": [ + "api-health: HTTP 200 accepted=yes identity=hwlab-edge-proxy url=http://74.48.78.17:16667/health", + "api-live: HTTP 200 accepted=yes identity=hwlab-cloud-api url=http://74.48.78.17:16667/health/live", + "frontend-root: HTTP 200 accepted=yes identity=HWLAB DEV MVP Gate url=http://74.48.78.17:16666/" + ], + "summary": "Read-only DEV public endpoint probes passed on :16666/:16667." + }, + "blockers": [], + "runtimeSmoke": { + "mode": "live-read-only", + "status": "pass", + "generatedAt": "2026-05-22T04:53:00.375Z", + "apiEndpoint": "http://74.48.78.17:16667", + "frontendEndpoint": "http://74.48.78.17:16666", + "safety": { + "environment": "dev", + "prodTouched": false, + "secretsRead": false, + "restarts": false, + "deployAttempted": false, + "heavyE2E": false, + "unideskRuntimeSubstitute": false + }, + "probes": [ + { + "id": "api-health", + "url": "http://74.48.78.17:16667/health", + "ok": true, + "status": 200, + "statusText": "OK", + "durationMs": 939, + "contentType": "application/json; charset=utf-8", + "headers": { + "content-type": "application/json; charset=utf-8", + "content-length": "661", + "date": "Fri, 22 May 2026 04:53:00 GMT" + }, + "json": { + "serviceId": "hwlab-edge-proxy", + "environment": "dev", + "status": "ok", + "service": { + "id": "hwlab-edge-proxy", + "role": "public-dev-ingress", + "healthPath": "/health", + "livePath": "/health/live" + }, + "commit": { + "id": "cb35ada6860653b27269b0a57991184118cbf4b1", + "source": "runtime-env" + }, + "image": { + "reference": "ghcr.io/pikastech/hwlab-edge-proxy:cb35ada", + "tag": "cb35ada", + "digest": "unknown" + }, + "endpoint": "http://74.48.78.17:16667", + "observedAt": "2026-05-22T04:53:00.143Z", + "details": { + "upstream": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", + "mode": "dev-edge-proxy" + } + }, + "title": null, + "bodyPreview": "{\n \"serviceId\": \"hwlab-edge-proxy\",\n \"environment\": \"dev\",\n \"status\": \"ok\",\n \"service\": {\n \"id\": \"hwlab-edge-proxy\",\n \"role\": \"public-dev-ingress\",\n \"healthPath\": \"/health\",\n \"livePath\": \"/health/live\"\n },\n \"commit\": {\n \"id\": \"cb35ada6860653b27269b0a57991184118cbf4b1\",\n \"source\": \"runtime-env\"\n },\n \"image\": {\n \"reference\": \"ghcr.io/pikastech/hwlab-edge-proxy:cb35ada\",\n \"tag\": \"cb35ada\",\n \"digest\": \"unknown\"\n },\n \"endpoint\": \"http://74.48.78.17:16667\",\n \"observedAt\": \"20" + }, + { + "id": "api-live", + "url": "http://74.48.78.17:16667/health/live", + "ok": true, + "status": 200, + "statusText": "OK", + "durationMs": 936, + "contentType": "application/json; charset=utf-8", + "headers": { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "date": "Fri, 22 May 2026 04:53:00 GMT" + }, + "json": { + "serviceId": "hwlab-cloud-api", + "environment": "dev", + "status": "degraded", + "service": { + "id": "hwlab-cloud-api", + "role": "cloud-api", + "healthPath": "/health", + "livePath": "/health/live" + }, + "commit": { + "id": "cb35ada", + "source": "runtime-env" + }, + "image": { + "reference": "127.0.0.1:5000/hwlab/hwlab-cloud-api:cb35ada", + "tag": "cb35ada", + "digest": "unknown" + }, + "endpoint": "http://74.48.78.17:16667", + "observedAt": "2026-05-22T04:53:00.145Z", + "db": { + "contractVersion": "v1", + "environment": "dev", + "connected": false, + "connectionChecked": false, + "configReady": true, + "ready": false, + "status": "degraded", + "mode": "configured_without_live_connection", + "fields": [ + { + "name": "HWLAB_CLOUD_DB_URL", + "present": true, + "redacted": true, + "source": "k8s-secret-ref", + "required": true + }, + { + "name": "HWLAB_CLOUD_DB_SSL_MODE", + "present": true, + "redacted": false, + "source": "runtime-env", + "required": true + } + ], + "missingEnv": [], + "secretRefs": [ + { + "env": "HWLAB_CLOUD_DB_URL", + "secretName": "hwlab-cloud-api-dev-db", + "secretKey": "database-url", + "present": true, + "redacted": true + } + ], + "safety": { + "devOnly": true, + "prodAllowed": false, + "secretsRead": false, + "valuesRedacted": true, + "liveDbEvidence": false + }, + "evidence": "env_presence_only_no_live_db" + }, + "runtime": { + "adapter": "memory", + "durable": false, + "status": "degraded", + "reason": "live DB persistence is not connected; L1 runtime writes are process-local only", + "counts": { + "gatewaySessions": 0, + "boxResources": 0, + "boxCapabilities": 0, + "hardwareOperations": 0, + "auditEvents": 0, + "evidenceRecords": 0 + } + } + }, + "title": null, + "bodyPreview": "{\"serviceId\":\"hwlab-cloud-api\",\"environment\":\"dev\",\"status\":\"degraded\",\"service\":{\"id\":\"hwlab-cloud-api\",\"role\":\"cloud-api\",\"healthPath\":\"/health\",\"livePath\":\"/health/live\"},\"commit\":{\"id\":\"cb35ada\",\"source\":\"runtime-env\"},\"image\":{\"reference\":\"127.0.0.1:5000/hwlab/hwlab-cloud-api:cb35ada\",\"tag\":\"cb35ada\",\"digest\":\"unknown\"},\"endpoint\":\"http://74.48.78.17:16667\",\"observedAt\":\"2026-05-22T04:53:00.145Z\",\"db\":{\"contractVersion\":\"v1\",\"environment\":\"dev\",\"connected\":false,\"connectionChecked\":false,\"configReady\":" + }, + { + "id": "frontend-root", + "url": "http://74.48.78.17:16666/", + "ok": true, + "status": 200, + "statusText": "OK", + "durationMs": 923, + "contentType": "text/html; charset=utf-8", + "headers": { + "content-type": "text/html; charset=utf-8", + "content-length": "4230", + "date": "Fri, 22 May 2026 04:53:00 GMT" + }, + "json": null, + "title": "HWLAB DEV MVP Gate", + "bodyPreview": "\n\n \n \n \n HWLAB DEV MVP Gate\n \n \n \n \n
\n
\n
\n

HWLAB L6 Frontend / CLI MVP

\n

DEV MVP Gate

\n
\n
0, "--timeout-ms must be positive"); + } else if (arg.startsWith("--")) { flags.add(arg); } else { rest.push(arg); } } - return { flags, rest }; + return { flags, options, rest }; } function loadFixture() { @@ -129,6 +149,9 @@ function assertFixture(fixture) { 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"); @@ -168,8 +191,240 @@ function assertFixture(fixture) { 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, + 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 } = parseArgs(process.argv.slice(2)); + const { flags, options } = parseArgs(process.argv.slice(2)); const isLive = flags.has("--live") || flags.has("--allow-live"); if (isLive) { @@ -188,7 +443,20 @@ async function main() { 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`); - process.stdout.write("[m2-smoke] no real DEV/PROD request was made\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 { diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 13689ade..feb1a5f1 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -91,6 +91,19 @@ const requiredDevEdgeDocs = [ "docs/m0-contract-audit.md", "docs/dev-edge-health.md" ]; +const requiredDevM2Issue = "pikasTech/HWLAB#23"; +const requiredDevM2ValidationCommands = [ + "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" +]; +const requiredDevM2Docs = [ + "docs/m2-dev-deploy-smoke.md", + "docs/dev-acceptance-matrix.md", + "fixtures/dev-deploy-smoke/dev-deploy-smoke.json" +]; const reportFamilyTemplates = new Map([ [ "dev-gate-report-contract", @@ -353,6 +366,10 @@ async function validateReport(relativePath) { await validateDevEdgeReport(report, label); return; } + if (report.issue === requiredDevM2Issue || report.taskId === "m2-dev-deploy-smoke") { + await validateDevM2Report(report, label); + return; + } const template = reportFamilyTemplates.get(report.taskId); assert.ok( @@ -949,6 +966,128 @@ async function validateDevEdgeReport(report, label) { assertObject(report.edgeHealth.kubernetes ?? {}, `${label}.edgeHealth.kubernetes`); } +async function validateDevM2Report(report, label) { + for (const field of [ + "$schema", + "$id", + "reportVersion", + "issue", + "taskId", + "commitId", + "acceptanceLevel", + "devOnly", + "prodDisabled", + "status", + "generatedAt", + "endpoint", + "frontendEndpoint", + "sourceContract", + "validationCommands", + "localSmoke", + "dryRun", + "devPreconditions", + "blockers", + "runtimeSmoke" + ]) { + assert.ok(Object.hasOwn(report, field), `${label} missing ${field}`); + } + + assertString(report.$schema, `${label}.$schema`); + assertString(report.$id, `${label}.$id`); + assert.equal(report.reportVersion, "v1", `${label}.reportVersion`); + assert.equal(report.issue, requiredDevM2Issue, `${label}.issue`); + assert.equal(report.taskId, "m2-dev-deploy-smoke", `${label}.taskId`); + assert.match(report.commitId, /^([a-f0-9]{7,40}|unknown)$/, `${label}.commitId`); + assert.equal(report.acceptanceLevel, "dev_m2_deploy_smoke", `${label}.acceptanceLevel`); + assert.equal(report.devOnly, true, `${label}.devOnly`); + assert.equal(report.prodDisabled, true, `${label}.prodDisabled`); + assert.equal(report.status, "pass", `${label}.status`); + assertTimestamp(report.generatedAt, `${label}.generatedAt`); + assert.equal(report.endpoint, "http://74.48.78.17:16667", `${label}.endpoint`); + assert.equal(report.frontendEndpoint, "http://74.48.78.17:16666", `${label}.frontendEndpoint`); + + assertObject(report.sourceContract, `${label}.sourceContract`); + assertStatus(report.sourceContract.status, `${label}.sourceContract.status`); + await assertDocumentSet( + report.sourceContract.documents, + `${label}.sourceContract.documents`, + requiredDevM2Docs + ); + assertString(report.sourceContract.summary, `${label}.sourceContract.summary`); + + assertStringArray(report.validationCommands, `${label}.validationCommands`, { + minLength: requiredDevM2ValidationCommands.length + }); + assertUnique(report.validationCommands, `${label}.validationCommands`); + for (const requiredCommand of requiredDevM2ValidationCommands) { + assert.ok( + report.validationCommands.includes(requiredCommand), + `${label}.validationCommands missing ${requiredCommand}` + ); + } + + for (const section of ["localSmoke", "dryRun", "devPreconditions"]) { + assertObject(report[section], `${label}.${section}`); + assertStatus(report[section].status, `${label}.${section}.status`); + assertStringArray(report[section].commands, `${label}.${section}.commands`, { minLength: 1 }); + assertStringArray(report[section].evidence, `${label}.${section}.evidence`, { minLength: 1 }); + assertString(report[section].summary, `${label}.${section}.summary`); + } + assertStringArray(report.devPreconditions.requirements, `${label}.devPreconditions.requirements`, { + minLength: 1 + }); + for (const command of [ + "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/" + ]) { + assert.ok( + report.devPreconditions.commands.includes(command), + `${label}.devPreconditions.commands missing ${command}` + ); + } + assertBlockers(report.blockers, `${label}.blockers`); + + assertObject(report.runtimeSmoke, `${label}.runtimeSmoke`); + assert.equal(report.runtimeSmoke.mode, "live-read-only", `${label}.runtimeSmoke.mode`); + assert.equal(report.runtimeSmoke.status, "pass", `${label}.runtimeSmoke.status`); + assertTimestamp(report.runtimeSmoke.generatedAt, `${label}.runtimeSmoke.generatedAt`); + assert.equal(report.runtimeSmoke.apiEndpoint, "http://74.48.78.17:16667", `${label}.runtimeSmoke.apiEndpoint`); + assert.equal(report.runtimeSmoke.frontendEndpoint, "http://74.48.78.17:16666", `${label}.runtimeSmoke.frontendEndpoint`); + + assertObject(report.runtimeSmoke.safety, `${label}.runtimeSmoke.safety`); + assert.equal(report.runtimeSmoke.safety.environment, "dev", `${label}.runtimeSmoke.safety.environment`); + for (const field of [ + "prodTouched", + "secretsRead", + "restarts", + "deployAttempted", + "heavyE2E", + "unideskRuntimeSubstitute" + ]) { + assert.equal(report.runtimeSmoke.safety[field], false, `${label}.runtimeSmoke.safety.${field}`); + } + + assertArray(report.runtimeSmoke.probes, `${label}.runtimeSmoke.probes`); + assert.ok(report.runtimeSmoke.probes.length >= 3, `${label}.runtimeSmoke.probes must include api and frontend probes`); + assertObject(report.runtimeSmoke.legacyPublicEndpoint, `${label}.runtimeSmoke.legacyPublicEndpoint`); + assert.equal( + report.runtimeSmoke.legacyPublicEndpoint.endpoint, + "http://74.48.78.17:6667", + `${label}.runtimeSmoke.legacyPublicEndpoint.endpoint` + ); + assert.equal( + report.runtimeSmoke.legacyPublicEndpoint.status, + "deprecated", + `${label}.runtimeSmoke.legacyPublicEndpoint.status` + ); + assert.equal( + report.runtimeSmoke.legacyPublicEndpoint.activeGreenEligible, + false, + `${label}.runtimeSmoke.legacyPublicEndpoint.activeGreenEligible` + ); +} + async function validateDevM3Report(report, label) { for (const field of [ "$schema", diff --git a/scripts/validate-m2-deploy-smoke-active.mjs b/scripts/validate-m2-deploy-smoke-active.mjs new file mode 100644 index 00000000..d4ac4dab --- /dev/null +++ b/scripts/validate-m2-deploy-smoke-active.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const reportPath = path.join(repoRoot, "reports/dev-gate/dev-m2-deploy-smoke-active.json"); + +function assertString(value, label) { + assert.equal(typeof value, "string", `${label} must be a string`); + assert.ok(value.trim().length > 0, `${label} must not be empty`); +} + +function assertObject(value, label) { + assert.ok(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); +} + +function assertArray(value, label) { + assert.ok(Array.isArray(value), `${label} must be an array`); +} + +function assertTimestamp(value, label) { + assertString(value, label); + assert.ok(!Number.isNaN(Date.parse(value)), `${label} must be a timestamp`); +} + +async function main() { + const report = JSON.parse(await readFile(reportPath, "utf8")); + assertObject(report, "report"); + assert.equal(report.issue, "pikasTech/HWLAB#23"); + assert.equal(report.taskId, "m2-dev-deploy-smoke"); + assert.equal(report.status, "pass"); + assert.equal(report.endpoint, "http://74.48.78.17:16667"); + assert.equal(report.frontendEndpoint, "http://74.48.78.17:16666"); + assertTimestamp(report.generatedAt, "generatedAt"); + + assertObject(report.runtimeSmoke, "runtimeSmoke"); + assert.equal(report.runtimeSmoke.mode, "live-read-only"); + assert.equal(report.runtimeSmoke.status, "pass"); + assert.equal(report.runtimeSmoke.apiEndpoint, "http://74.48.78.17:16667"); + assert.equal(report.runtimeSmoke.frontendEndpoint, "http://74.48.78.17:16666"); + assertObject(report.runtimeSmoke.safety, "runtimeSmoke.safety"); + for (const field of ["prodTouched", "secretsRead", "restarts", "deployAttempted", "heavyE2E", "unideskRuntimeSubstitute"]) { + assert.equal(report.runtimeSmoke.safety[field], false, `runtimeSmoke.safety.${field}`); + } + assertObject(report.runtimeSmoke.legacyPublicEndpoint, "runtimeSmoke.legacyPublicEndpoint"); + assert.equal(report.runtimeSmoke.legacyPublicEndpoint.endpoint, "http://74.48.78.17:6667"); + assert.equal(report.runtimeSmoke.legacyPublicEndpoint.activeGreenEligible, false); + + assertObject(report.devPreconditions, "devPreconditions"); + assert.equal(report.devPreconditions.status, "pass"); + assertArray(report.devPreconditions.commands, "devPreconditions.commands"); + assertArray(report.devPreconditions.evidence, "devPreconditions.evidence"); + assert.ok( + report.devPreconditions.commands.includes("curl -fsS --max-time 10 http://74.48.78.17:16667/health"), + "devPreconditions.commands missing /health probe" + ); + assert.ok( + report.devPreconditions.commands.includes("curl -fsS --max-time 10 http://74.48.78.17:16667/health/live"), + "devPreconditions.commands missing /health/live probe" + ); + assert.ok( + report.devPreconditions.commands.includes("curl -fsS --max-time 10 http://74.48.78.17:16666/"), + "devPreconditions.commands missing frontend probe" + ); + + console.log("validated m2-dev-deploy-smoke-active.json"); +} + +try { + await main(); +} catch (error) { + process.stderr.write(`[validate-m2-dev-deploy-smoke-active] ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}