test: harden M3 trusted loop smoke path

This commit is contained in:
Code Queue Review
2026-05-22 18:29:55 +00:00
parent 71d4411f89
commit 0ff408696a
4 changed files with 134 additions and 34 deletions
+56 -33
View File
@@ -33,6 +33,14 @@ const requiredM3Connection = Object.freeze({
const requiredM3GatewayIds = Object.freeze(["gwsimu_1", "gwsimu_2"]);
const runnerK3sKubeconfigPath = "/etc/rancher/k3s/k3s.yaml";
const m3FailureClassifications = Object.freeze({
targetMissing: "target_missing",
identityNotDistinct: "identity_not_distinct",
patchPanelWiringMissing: "patch_panel_wiring_missing",
operationFailed: "operation_failed",
evidenceMissing: "evidence_missing"
});
const serviceTargets = Object.freeze([
{
id: "box-simu-1",
@@ -90,11 +98,31 @@ function parseArgs(argv) {
return {
flags,
values,
dryRun: flags.has("--dry-run") || flags.has("--plan"),
live: flags.has("--live") || flags.has("--allow-live")
dryRun: flags.has("--dry-run") || flags.has("--plan") || flags.has("--source-read-only"),
live: flags.has("--live")
};
}
function classifyDirectTargetBlockerScope(scope) {
if (scope === "m3-patch-panel-wiring") {
return m3FailureClassifications.patchPanelWiringMissing;
}
if (scope === "m3-box-simu-identity" || scope === "m3-gateway-simu-identity") {
return m3FailureClassifications.identityNotDistinct;
}
if (scope === "m3-service-discovery" || scope === "m3-direct-target-missing") {
return m3FailureClassifications.targetMissing;
}
return m3FailureClassifications.operationFailed;
}
function classifyLiveOperationError(error) {
const message = error instanceof Error ? error.message : String(error);
return /\b(auditId|evidenceId)\b/u.test(message)
? m3FailureClassifications.evidenceMissing
: m3FailureClassifications.operationFailed;
}
function requireSafetyGates({ flags, dryRun, live }) {
const forbiddenFlags = [
"--prod",
@@ -112,28 +140,20 @@ function requireSafetyGates({ flags, dryRun, live }) {
assert.ok(!flags.has(flag), `${flag} is forbidden for DEV M3 smoke`);
}
assert.ok(
!(dryRun && live),
"DEV M3 smoke refuses to combine --dry-run/--plan with --live/--allow-live; plan mode never runs the M3 write path"
);
const confirmedLive =
live &&
flags.has("--confirm-dev") &&
flags.has("--confirmed-non-production") &&
!dryRun &&
!flags.has("--allow-live");
if (dryRun) {
if (!confirmedLive) {
return {
mode: "dry-run",
liveWriteAllowed: false
};
}
assert.ok(
live,
"DEV M3 smoke refuses to run by default; use --dry-run for a no-write plan or --live --confirm-dev --expect-non-prod after approval"
);
assert.ok(flags.has("--confirm-dev"), "live DEV smoke requires --confirm-dev");
assert.ok(
flags.has("--expect-non-prod") || flags.has("--confirmed-non-production"),
"live DEV smoke requires --expect-non-prod (legacy --confirmed-non-production is accepted only as compatibility)"
);
return {
mode: "live",
liveWriteAllowed: true
@@ -648,10 +668,10 @@ function createLiveOperationPlan() {
"targetPort=DI1",
"targetState.ports.DI1.propagatedBy=hwlab-patch-panel"
],
failureClassifications: Object.values(m3FailureClassifications),
refusalPolicy: [
"no arguments defaults to refusal",
"--dry-run/--plan cannot be combined with --live/--allow-live",
"live mode refuses without --confirm-dev and --expect-non-prod",
"commands missing any of --live --confirm-dev --confirmed-non-production stay source/read-only",
"a dry-run, source, local, fixture, or read-only blocker report leaves liveOperation.status as not_run or blocked, never pass"
]
};
@@ -963,7 +983,7 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery
type: "observability_blocker",
scope: "m3-service-discovery",
status: "open",
classification: "runner_kubeconfig_readonly_gap",
classification: classifyDirectTargetBlockerScope("m3-service-discovery"),
sourceIssue: "pikasTech/HWLAB#46",
summary: `DEV ingress is reachable on frozen :16667, but direct M3 target discovery via ${kubernetesDiscovery.source} is ${kubernetesDiscovery.status}; environment URLs missing ${environmentTargets.missing.join(", ")}; discovery detail=${kubernetesDiscovery.summary}.`
});
@@ -972,7 +992,7 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery
type: "observability_blocker",
scope: "m3-direct-target-missing",
status: "open",
classification: "direct_target_missing",
classification: classifyDirectTargetBlockerScope("m3-direct-target-missing"),
sourceIssue: "pikasTech/HWLAB#64",
summary: `Read-only service discovery did not expose enough callable DEV M3 direct targets: box-simu=${boxCandidates.length}/2, gateway-simu=${gatewayCandidates.length}/2, patch-panel=${patchCandidates.length}/1.`
});
@@ -983,7 +1003,7 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery
type: "runtime_blocker",
scope: "m3-box-simu-identity",
status: "open",
classification: "direct_target_identity_gap",
classification: classifyDirectTargetBlockerScope("m3-box-simu-identity"),
sourceIssue: "pikasTech/HWLAB#64",
summary: `DEV exposes two box-simu endpoints, but live identities are not the required distinct resources res_boxsimu_1 and res_boxsimu_2; observed resources=${[...distinctBoxResources].join(", ") || "none"}.`
});
@@ -994,7 +1014,7 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery
type: "runtime_blocker",
scope: "m3-gateway-simu-identity",
status: "open",
classification: "direct_target_identity_gap",
classification: classifyDirectTargetBlockerScope("m3-gateway-simu-identity"),
sourceIssue: "pikasTech/HWLAB#64",
summary: `DEV exposes two gateway-simu endpoints, but live gateway identities are not distinct; observed identities=${[...distinctGatewayIdentities].join(", ") || "none"}.`
});
@@ -1007,7 +1027,7 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery
type: "runtime_blocker",
scope: "m3-patch-panel-wiring",
status: "open",
classification: "direct_target_m3_wiring_missing",
classification: classifyDirectTargetBlockerScope("m3-patch-panel-wiring"),
sourceIssue: "pikasTech/HWLAB#64",
summary: `DEV patch-panel is callable, but live wiring does not contain ${requiredM3Connection.fromResourceId}:${requiredM3Connection.fromPort} -> ${requiredM3Connection.toResourceId}:${requiredM3Connection.toPort}; active=${observedActive}; configured=${observedConfigured}.`
});
@@ -1256,8 +1276,9 @@ async function runLiveM3Targets(targets) {
assert.ok(after.ok, "box-simu-2 DI1 read failed");
assert.equal(after.json?.ports?.DI1?.value, true, "box-simu-2 DI1 must read true");
const auditId = route.json?.auditId ?? write.json?.auditId ?? after.json?.auditId;
const evidenceId = route.json?.evidenceId ?? write.json?.evidenceId ?? after.json?.evidenceId;
const deliveryResponse = route.json?.deliveries?.[0]?.response;
const auditId = route.json?.auditId ?? deliveryResponse?.auditId ?? write.json?.auditId ?? after.json?.auditId;
const evidenceId = route.json?.evidenceId ?? deliveryResponse?.evidenceId ?? write.json?.evidenceId ?? after.json?.evidenceId;
assert.equal(typeof auditId, "string", "live M3 result must include auditId");
assert.equal(typeof evidenceId, "string", "live M3 result must include evidenceId");
@@ -1319,6 +1340,7 @@ function baseReport({ commitId, observedAt }) {
confirmDevRequired: true,
expectNonProdRequired: true,
confirmedNonProductionRequired: true,
defaultSourceReadOnly: true,
dryRunPlanSupported: true,
dryRunCallsLiveEndpoints: false,
prodForbidden: true,
@@ -1447,12 +1469,12 @@ async function runDryRunPlan({ values }) {
type: "safety_blocker",
scope: "m3-live-write-authorization",
status: "open",
classification: "dry_run_plan_only",
classification: m3FailureClassifications.targetMissing,
summary: "Plan-only mode is intentionally non-mutating; run the bounded live smoke only after explicit DEV/non-PROD approval and after read-only preconditions identify the exact HWLAB targets."
});
report.summary = {
status: "blocked",
classification: "dry_run_plan_only",
classification: m3FailureClassifications.targetMissing,
observedAt,
result: "DRY-RUN plan emitted live write prerequisites, endpoint plan, and required evidence fields; it did not call DEV endpoints or produce DEV-LIVE evidence."
};
@@ -1537,12 +1559,12 @@ async function main() {
type: "network_blocker",
scope: "frp",
status: "open",
classification: "frp",
classification: m3FailureClassifications.targetMissing,
summary: "Blocked at the #33 DEV runtime readiness condition: public DEV ingress does not accept HWLAB health requests, so #36/M3 hardware-loop checks were not reached."
});
report.summary = {
status: "blocked",
classification: "frp",
classification: m3FailureClassifications.targetMissing,
observedAt,
result: "No live DEV M3 evidence was produced; fixture-backed local evidence was not used as a substitute."
};
@@ -1602,7 +1624,7 @@ async function main() {
report.blockers.push(...directTargets.blockers);
report.summary = {
status: "blocked",
classification: directTargets.blockers[0]?.classification ?? "direct_target_blocked",
classification: directTargets.blockers[0]?.classification ?? m3FailureClassifications.targetMissing,
observedAt,
result: "DEV ingress and direct targets were reachable, but M3 DEV-LIVE was not triggered because required identities or patch-panel wiring were missing."
};
@@ -1672,17 +1694,18 @@ async function main() {
`[dev-m3-smoke] operationId=${live.operationId} auditId=${live.auditId} evidenceId=${live.evidenceId}`
);
} catch (error) {
const classification = classifyLiveOperationError(error);
addNotRunM3Checks(report, "A live DEV M3 runtime contract check failed before all checks passed.");
report.blockers.push({
type: "runtime_blocker",
scope: "m3-hardware-loop-runtime",
status: "open",
classification: "hardware loop",
classification,
summary: error instanceof Error ? error.message : String(error)
});
report.summary = {
status: "blocked",
classification: "hardware loop",
classification,
observedAt,
result: "Live DEV M3 runtime was reachable but did not satisfy the M3 hardware-loop contract."
};
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdir, readFile, rm } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const testOutputDir = "tmp/dev-m3-hardware-loop-smoke-test";
function runSmoke(args) {
return execFileSync("node", ["scripts/dev-m3-hardware-loop-smoke.mjs", ...args], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"]
});
}
test("default M3 smoke is source/read-only and never claims DEV-LIVE", async () => {
await mkdir(path.join(repoRoot, testOutputDir), { recursive: true });
const output = path.join(testOutputDir, "default-source-readonly.json");
try {
const stdout = runSmoke(["--output", output]);
assert.match(stdout, /mode=dry-run/u);
const report = JSON.parse(await readFile(path.join(repoRoot, output), "utf8"));
assert.equal(report.dryRunPlan.mode, "plan-only");
assert.equal(report.dryRunPlan.dryRunCallsLiveEndpoints, false);
assert.equal(report.dryRunPlan.liveWriteWillRun, false);
assert.equal(report.liveOperation.status, "not_run");
assert.equal(report.liveOperation.operationId, "not_observed");
assert.equal(report.summary.status, "blocked");
assert.match(report.summary.result, /did not call DEV endpoints/u);
assert.equal(
report.liveChecks.find((check) => check.id === "direct-call-do-write-di-read")?.status,
"not_run"
);
} finally {
await rm(path.join(repoRoot, output), { force: true });
}
});
test("--dry-run aliases the same source/read-only no-write path", async () => {
await mkdir(path.join(repoRoot, testOutputDir), { recursive: true });
const output = path.join(testOutputDir, "dry-run-source-readonly.json");
try {
runSmoke(["--dry-run", "--output", output]);
const report = JSON.parse(await readFile(path.join(repoRoot, output), "utf8"));
assert.equal(report.dryRunPlan.mode, "plan-only");
assert.equal(report.dryRunPlan.liveWriteWillRun, false);
assert.ok(
report.dryRunPlan.failureClassifications.includes("patch_panel_wiring_missing")
);
assert.equal(report.liveOperation.status, "not_run");
} finally {
await rm(path.join(repoRoot, output), { force: true });
}
});
+18
View File
@@ -1386,6 +1386,9 @@ async function validateDevM3Report(report, label) {
if (Object.hasOwn(report.safetyGates, "dryRunCallsLiveEndpoints")) {
assert.equal(report.safetyGates.dryRunCallsLiveEndpoints, false, `${label}.safetyGates.dryRunCallsLiveEndpoints`);
}
if (Object.hasOwn(report.safetyGates, "defaultSourceReadOnly")) {
assert.equal(report.safetyGates.defaultSourceReadOnly, true, `${label}.safetyGates.defaultSourceReadOnly`);
}
assertArray(report.liveChecks, `${label}.liveChecks`);
assert.ok(report.liveChecks.length >= 8, `${label}.liveChecks must include DEV M3 checks`);
@@ -1499,6 +1502,21 @@ async function validateDevM3Report(report, label) {
assert.equal(plan.route, "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", `${label}.dryRunPlan.route`);
assert.equal(plan.dryRunCallsLiveEndpoints, false, `${label}.dryRunPlan.dryRunCallsLiveEndpoints`);
assert.equal(plan.liveWriteWillRun, false, `${label}.dryRunPlan.liveWriteWillRun`);
if (Object.hasOwn(plan, "failureClassifications")) {
assertStringArray(plan.failureClassifications, `${label}.dryRunPlan.failureClassifications`, { minLength: 5 });
for (const classification of [
"target_missing",
"identity_not_distinct",
"patch_panel_wiring_missing",
"operation_failed",
"evidence_missing"
]) {
assert.ok(
plan.failureClassifications.includes(classification),
`${label}.dryRunPlan.failureClassifications missing ${classification}`
);
}
}
assertStringArray(plan.liveWritePreconditions, `${label}.dryRunPlan.liveWritePreconditions`, { minLength: 4 });
assertStringArray(plan.evidenceFields, `${label}.dryRunPlan.evidenceFields`, { minLength: 4 });
assertArray(plan.endpointPlan, `${label}.dryRunPlan.endpointPlan`);