test: add m2 dev deploy smoke dry-run

This commit is contained in:
HWLAB Code Queue
2026-05-21 15:29:21 +00:00
parent 3f6451ae91
commit 619fbc830e
3 changed files with 543 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
# M2 DEV Deployment Smoke Dry-Run
`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,
artifact metadata, and safety gates without contacting the real DEV ingress by
default.
Run the dry-run from the repository root:
```sh
node scripts/m2-dev-deploy-smoke.mjs --dry-run
```
## What It Checks
- Parses `fixtures/dev-deploy-smoke/dev-deploy-smoke.json`.
- Verifies the route phases in order:
`master-edge-proxy`, `frp-tunnel`, `d601-router`, `cloud-surface`,
`agent-runtime`, and `sim-and-patch-panel`.
- Validates the frozen HWLAB service IDs for master edge proxy, frp tunnel,
D601 `hwlab-dev/hwlab-router`, cloud-api/web, agent, sim, and patch-panel.
- Checks `commitId`, `image`, `digest` or `not_applicable`, `tag`,
`buildSource`, `deployEnv`, `healthTimestamp`, and failure classification
for every recorded service contract.
- Confirms the cloud surface bundles `hwlab-cloud-api` and `hwlab-cloud-web`
artifacts together under the same route phase.
- Keeps the default mode in dry-run and refuses real requests unless an
explicit live gate is supplied.
## 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.
Required flags:
```sh
node scripts/m2-dev-deploy-smoke.mjs --live --confirm-dev --confirmed-non-production
```
Before using `--live`, confirm all of the following:
- the target remains `http://74.48.78.17:6667`;
- 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.
The script rejects live execution if any of these are missing:
- `--confirm-dev`
- `--confirmed-non-production`
- `--prod`
- `--heavyweight-e2e`
- `--read-secret`
- `--force-push`
## Boundary
This harness does not:
- deploy to DEV or PROD;
- call the real DEV endpoint in dry-run mode;
- run heavy e2e coverage;
- read secrets or tokens;
- replace HWLAB runtime services with UniDesk substitutes;
- touch L5 deploy manifests.
The fixture is a dry-run record only. It exists to prove that the M2 smoke
entry point understands the frozen route and artifact contract before any live
DEV observation is attempted.
@@ -0,0 +1,280 @@
{
"smokeId": "m2-dev-deploy-smoke",
"issue": "pikasTech/HWLAB#23",
"environment": "dev",
"endpoint": "http://74.48.78.17:6667",
"defaultMode": "dry-run",
"safetyGates": {
"dryRunOnly": true,
"realRequestsRequire": [
"explicit --live flag",
"confirmed DEV endpoint",
"confirmed non-production workspace",
"no forbidden actions"
],
"forbiddenActions": [
"real-dev-deploy",
"prod-deploy",
"heavyweight-e2e",
"secret-read",
"force-push",
"unidesk-runtime-substitution"
]
},
"serviceContracts": [
{
"routePhase": 1,
"phase": "master-edge-proxy",
"serviceId": "hwlab-edge-proxy",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-edge-proxy",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:40b2218b7a1ede3d215eaf99c56caf85cd61318da4e4348122e4e2da4c072cd8",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "ok",
"route": "public-dev-ingress",
"forwardTarget": "frp",
"artifactIdentity": "master edge proxy"
},
"failureClassification": "network_blocker"
},
{
"routePhase": 2,
"phase": "frp-tunnel",
"serviceId": "hwlab-tunnel-client",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-tunnel-client",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:53bdcb1f16d8a761d836e09e9bb0fe37d3f726b85769be8658d1e2702ff3d448",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "connected",
"route": "master-edge-proxy",
"forwardTarget": "D601 hwlab-dev/hwlab-router",
"artifactIdentity": "frp tunnel"
},
"failureClassification": "network_blocker"
},
{
"routePhase": 3,
"phase": "d601-router",
"serviceId": "hwlab-router",
"namespace": "hwlab-dev",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-router",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:be7e87979bbe78fc040d52d64b264bb8494f873c14b455dbf14681e2b4fd3dfb",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "live",
"route": "hwlab-dev/hwlab-router",
"forwardTarget": "cloud-api/web",
"artifactIdentity": "D601 router"
},
"failureClassification": "runtime_blocker"
},
{
"routePhase": 4,
"phase": "cloud-surface",
"serviceId": "hwlab-cloud-api",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-cloud-api",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:a147c7dbb6df1d30b0ac5e747f2f1fc42d455a55410b82578573b9b3ae2d6074",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "ok",
"live": true,
"route": "cloud-api/web",
"artifacts": [
{
"serviceId": "hwlab-cloud-api",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-cloud-api",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:a147c7dbb6df1d30b0ac5e747f2f1fc42d455a55410b82578573b9b3ae2d6074"
,
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
},
{
"serviceId": "hwlab-cloud-web",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-cloud-web",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:4a546582e6318b77530b9141dfd764c36b974442d6c7916a1294c54d0b02a14e",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z"
}
]
},
"failureClassification": "runtime_blocker"
},
{
"routePhase": 5,
"phase": "agent-runtime",
"serviceId": "hwlab-agent-mgr",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-agent-mgr",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:cf2dab7d0aa49ef6e77dd8e1bacebd588bf2dbd65e86a4b8c2dc23cd248c026b",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "live",
"trace": "session-scoped",
"cleanup": "no worker leak"
},
"failureClassification": "agent_blocker"
},
{
"routePhase": 6,
"phase": "agent-runtime",
"serviceId": "hwlab-agent-worker",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-agent-worker",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:be2941f18376e8e39e0aa27857bb2db4300e861f2c759a7bc447e96e1f0e8bf1",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "scoped",
"projectId": "proj_mvp-l6",
"trace": "session-scoped",
"cleanup": "worker session closed"
},
"failureClassification": "agent_blocker"
},
{
"routePhase": 7,
"phase": "agent-runtime",
"serviceId": "hwlab-agent-skills",
"commitId": "5583e4b",
"image": "skills/hwlab-agent-runtime",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "not_applicable",
"digestReason": "local dry-run fixture for repo-local skills bundle",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "traceable",
"bundle": "repo-local skills artifact",
"cleanup": "explicit commitId required"
},
"failureClassification": "agent_blocker"
},
{
"routePhase": 8,
"phase": "sim-and-patch-panel",
"serviceId": "hwlab-gateway",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-gateway",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:dcf57c4a0fab873ab571c1f6314d352d38853ff2cf125356a05a06ae5cfdb444",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "live",
"route": "hardware boundary",
"artifactIdentity": "gateway boundary"
},
"failureClassification": "runtime_blocker"
},
{
"routePhase": 9,
"phase": "sim-and-patch-panel",
"serviceId": "hwlab-gateway-simu",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-gateway-simu",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:0bc140f52733255c937d08cea4d51639c83a71df8ac7a78bb107f60401bc392e",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "live",
"route": "simulator",
"artifactIdentity": "gateway simulator",
"observedAt": "2026-05-21T00:00:00.000Z"
},
"failureClassification": "runtime_blocker"
},
{
"routePhase": 10,
"phase": "sim-and-patch-panel",
"serviceId": "hwlab-box-simu",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-box-simu",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:0506757987fb7955ec0089fd9c0d895c33eb714a940fc7b8d58153368f96e8c8",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "live",
"route": "patch-panel-owned",
"artifactIdentity": "box simulator"
},
"failureClassification": "runtime_blocker"
},
{
"routePhase": 11,
"phase": "sim-and-patch-panel",
"serviceId": "hwlab-patch-panel",
"commitId": "5583e4b",
"image": "ghcr.io/pikastech/hwlab-patch-panel",
"tag": "m2-dev-deploy-smoke-5583e4b",
"digest": "sha256:bd3045c3235353e2437fbb11308ffe6f1ea3e52ca1e481dc80d4317a4b1600d1",
"buildSource": "git+https://github.com/pikasTech/HWLAB.git#origin/main:scripts/m2-dev-deploy-smoke.mjs",
"deployEnv": "dev",
"healthTimestamp": "2026-05-21T00:00:00.000Z",
"health": {
"status": "active",
"route": "patch-panel-owned",
"artifactIdentity": "patch panel",
"observedAt": "2026-05-21T00:00:00.000Z"
},
"failureClassification": "runtime_blocker"
}
],
"observations": {
"serviceIds": [
"hwlab-edge-proxy",
"hwlab-tunnel-client",
"hwlab-router",
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-agent-skills",
"hwlab-gateway",
"hwlab-gateway-simu",
"hwlab-box-simu",
"hwlab-patch-panel"
],
"routePhases": [
"master-edge-proxy",
"frp-tunnel",
"d601-router",
"cloud-surface",
"agent-runtime",
"sim-and-patch-panel"
],
"healthTimestamp": "2026-05-21T00:00:00.000Z"
}
}
+192
View File
@@ -0,0 +1,192 @@
#!/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";
import {
DEV_ENDPOINT,
ENVIRONMENT_DEV,
SERVICE_IDS
} from "../internal/protocol/index.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const fixturePath = path.join(repoRoot, "fixtures/dev-deploy-smoke/dev-deploy-smoke.json");
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 rest = [];
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg.startsWith("--")) {
flags.add(arg);
} else {
rest.push(arg);
}
}
return { flags, 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(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.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");
}
async function main() {
const { flags } = 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`);
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;
}