test: add M3 source contract dry-run plan

Host commander merge: clean PR, no live write, preserves DRY-RUN vs DEV-LIVE boundary for #78/#7/#164.
This commit is contained in:
Lyon
2026-05-23 02:20:11 +08:00
committed by GitHub
8 changed files with 663 additions and 24 deletions
+284 -9
View File
@@ -19,6 +19,9 @@ const issue = "pikasTech/HWLAB#38";
const deployWorkloadsPath = "deploy/k8s/base/workloads.yaml";
const deployServicesPath = "deploy/k8s/base/services.yaml";
const requiredManifestCardinalityCommand = "node scripts/validate-dev-m3-cardinality.mjs";
const dryRunPlanCommand = "node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run";
const liveSmokeCommand = "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod";
const legacyLiveSmokeCommand = "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production";
const requiredM3BoxIds = Object.freeze(["boxsimu_1", "boxsimu_2"]);
const requiredM3BoxResources = Object.freeze(["res_boxsimu_1", "res_boxsimu_2"]);
const requiredM3Connection = Object.freeze({
@@ -84,10 +87,15 @@ function parseArgs(argv) {
flags.add(arg);
}
return { flags, values };
return {
flags,
values,
dryRun: flags.has("--dry-run") || flags.has("--plan"),
live: flags.has("--live") || flags.has("--allow-live")
};
}
function requireSafetyGates(flags) {
function requireSafetyGates({ flags, dryRun, live }) {
const forbiddenFlags = [
"--prod",
"--real-hardware",
@@ -104,13 +112,32 @@ function requireSafetyGates(flags) {
assert.ok(!flags.has(flag), `${flag} is forbidden for DEV M3 smoke`);
}
const live = flags.has("--live") || flags.has("--allow-live");
assert.ok(live, "DEV M3 smoke requires --live or --allow-live");
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"
);
if (dryRun) {
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("--confirmed-non-production"),
"live DEV smoke requires --confirmed-non-production"
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
};
}
function isoNow() {
@@ -484,6 +511,152 @@ function joinUrl(baseUrl, routePath) {
return new URL(routePath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
}
function endpointReference(target, routePath) {
const baseUrl = process.env[target.urlEnv];
if (baseUrl) {
return {
url: joinUrl(baseUrl, routePath),
urlSource: `env:${target.urlEnv}`
};
}
return {
url: `$${target.urlEnv}${routePath}`,
urlSource: `required-env:${target.urlEnv}`
};
}
function plannedServiceEndpoint(target, routePath, { method = "GET", phase, mutatesDevState = false }) {
const endpoint = endpointReference(target, routePath);
return {
phase,
targetId: target.id,
serviceId: target.serviceId,
method,
path: routePath,
...endpoint,
mutatesDevState,
calledInDryRun: false
};
}
function createLiveOperationPlan() {
const box1 = serviceTargets.find((target) => target.id === "box-simu-1");
const box2 = serviceTargets.find((target) => target.id === "box-simu-2");
const patchPanel = serviceTargets.find((target) => target.id === "patch-panel");
const endpointPlan = [
{
phase: "dev-ingress-precondition",
targetId: "dev-api-edge",
serviceId: "hwlab-cloud-api",
method: "GET",
path: "/health/live",
url: joinUrl(DEV_ENDPOINT, "/health/live"),
urlSource: "frozen-dev-endpoint",
mutatesDevState: false,
calledInDryRun: false
},
{
phase: "dev-ingress-precondition",
targetId: "dev-api-edge-json-rpc",
serviceId: "hwlab-cloud-api",
method: "POST",
path: "/json-rpc",
url: joinUrl(DEV_ENDPOINT, "/json-rpc"),
urlSource: "frozen-dev-endpoint",
mutatesDevState: false,
calledInDryRun: false,
requestShape: "system.health"
},
{
phase: "dev-ingress-precondition",
targetId: "dev-frontend",
serviceId: "hwlab-cloud-web",
method: "GET",
path: "/health/live",
url: joinUrl(DEV_FRONTEND_ENDPOINT, "/health/live"),
urlSource: "frozen-dev-frontend-endpoint",
mutatesDevState: false,
calledInDryRun: false
}
];
for (const target of serviceTargets) {
endpointPlan.push(
plannedServiceEndpoint(target, "/health/live", {
phase: "direct-target-precondition"
}),
plannedServiceEndpoint(target, target.statusPath, {
phase: "direct-target-precondition"
})
);
if (target.wiringPath) {
endpointPlan.push(
plannedServiceEndpoint(target, target.wiringPath, {
phase: "patch-panel-route-precondition"
})
);
}
}
endpointPlan.push(
plannedServiceEndpoint(box1, "/ports/write", {
method: "POST",
phase: "live-write-source-do1",
mutatesDevState: true
}),
plannedServiceEndpoint(patchPanel, patchPanel.routePath, {
method: "POST",
phase: "live-write-patch-panel-route",
mutatesDevState: true
}),
plannedServiceEndpoint(box2, "/status", {
phase: "live-read-target-di1"
})
);
return {
mode: "plan-only",
evidenceLevel: "DRY-RUN",
route: "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1",
dryRunCallsLiveEndpoints: false,
liveWriteWillRun: false,
liveWriteRequiresHumanApproval: true,
liveCommand: liveSmokeCommand,
legacyLiveCommand: legacyLiveSmokeCommand,
liveWritePreconditions: [
"operator has authorized a bounded DEV M3 live smoke window",
"command includes --live, --confirm-dev, and --expect-non-prod",
"PROD, real hardware, secret reads, service restarts, and force pushes remain forbidden",
"DEV ingress identifies HWLAB DEV on the frozen 16666/16667 boundary",
"two distinct hwlab-box-simu targets report res_boxsimu_1 and res_boxsimu_2",
"two distinct hwlab-gateway-simu targets report distinct DEV gateway sessions",
"one hwlab-patch-panel reports active wiring for res_boxsimu_1:DO1 -> res_boxsimu_2:DI1",
"cross-device propagation is owned by hwlab-patch-panel; box loopback, SOURCE, LOCAL, fixture, and DRY-RUN output cannot be marked DEV-LIVE"
],
endpointPlan,
evidenceFields: [
"operationId",
"traceId",
"auditId",
"evidenceId",
"sourceResourceId=res_boxsimu_1",
"sourcePort=DO1",
"routeOwner=hwlab-patch-panel",
"routeResponse.propagatedBy=hwlab-patch-panel",
"targetResourceId=res_boxsimu_2",
"targetPort=DI1",
"targetState.ports.DI1.propagatedBy=hwlab-patch-panel"
],
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",
"a dry-run, source, local, fixture, or read-only blocker report leaves liveOperation.status as not_run or blocked, never pass"
]
};
}
function isHwlabDevIdentity(probe) {
const body = probe.json;
if (!probe.ok || !body || typeof body !== "object" || Array.isArray(body)) {
@@ -1075,6 +1248,7 @@ async function runLiveM3Targets(targets) {
probes.push({ id: "patch-panel", kind: "signals.route", probe: route });
assert.ok(route.ok, "patch-panel signal route failed");
assert.equal(route.json?.accepted, true, "patch-panel signal route accepted");
assert.equal(route.json?.propagatedBy, "hwlab-patch-panel", "patch-panel signal route owner");
assert.equal(route.json?.deliveryCount, 1, "patch-panel signal route delivery count");
const after = await probeJson(joinUrl(box2Target.baseUrl, "/status"));
@@ -1122,7 +1296,9 @@ function baseReport({ commitId, observedAt }) {
"node --check scripts/dev-m3-hardware-loop-smoke.mjs",
"node --check scripts/validate-dev-m3-cardinality.mjs",
requiredManifestCardinalityCommand,
"node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production",
dryRunPlanCommand,
liveSmokeCommand,
legacyLiveSmokeCommand,
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs"
],
@@ -1133,13 +1309,18 @@ function baseReport({ commitId, observedAt }) {
environment: ENVIRONMENT_DEV,
requiredBoxSimulators: 2,
requiredGatewaySimulators: 2,
requiredPatchPanels: 1,
requiredRoute: "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1",
realHardwareAllowed: false,
prodAllowed: false
},
safetyGates: {
liveFlagRequired: true,
confirmDevRequired: true,
expectNonProdRequired: true,
confirmedNonProductionRequired: true,
dryRunPlanSupported: true,
dryRunCallsLiveEndpoints: false,
prodForbidden: true,
realHardwareForbidden: true,
secretReadForbidden: true,
@@ -1193,6 +1374,94 @@ function baseReport({ commitId, observedAt }) {
};
}
async function runDryRunPlan({ values }) {
await ensureContractFiles();
const reportPath = resolveReportPath(values.get("output"));
const observedAt = isoNow();
const report = baseReport({
commitId: currentCommit(),
observedAt
});
const plan = createLiveOperationPlan();
report.dryRunPlan = plan;
report.readOnlySupplementalEvidence = await collectReadOnlySupplementalEvidence();
report.liveChecks.push(
{
id: "dry-run-live-write-plan",
status: "not_run",
summary: "Plan-only mode enumerated live write prerequisites and endpoints without calling DEV endpoints or mutating DEV state.",
evidence: [
`endpointCount=${plan.endpointPlan.length}`,
`mutatingEndpointCount=${plan.endpointPlan.filter((endpoint) => endpoint.mutatesDevState).length}`,
"dryRunCallsLiveEndpoints=false"
]
},
{
id: "two-box-simu-online",
status: "not_run",
summary: "Plan-only mode requires two distinct live DEV box-simu identities before any write; no DEV target was probed.",
evidence: ["required=res_boxsimu_1,res_boxsimu_2"]
},
{
id: "two-gateway-simu-online",
status: "not_run",
summary: "Plan-only mode requires two distinct live DEV gateway-simu identities before any write; no DEV target was probed.",
evidence: ["required=gwsimu_1,gwsimu_2"]
},
{
id: "patch-panel-healthy",
status: "not_run",
summary: "Plan-only mode requires a callable DEV hwlab-patch-panel before any write; no DEV target was probed.",
evidence: ["required=hwlab-patch-panel"]
},
{
id: "wiring-do1-di1-applied",
status: "not_run",
summary: "Plan-only mode requires patch-panel-owned res_boxsimu_1:DO1 -> res_boxsimu_2:DI1 wiring before any write.",
evidence: [`route=${plan.route}`]
},
{
id: "direct-call-do-write-di-read",
status: "not_run",
summary: "Plan-only mode did not send /ports/write or /signals/route; this output is DRY-RUN only and cannot be labeled DEV-LIVE.",
evidence: ["No live DEV write was sent."]
},
{
id: "audit-evidence-traceable",
status: "not_run",
summary: "Plan-only mode listed the required operation, trace, audit, and evidence fields but did not create them.",
evidence: plan.evidenceFields
}
);
report.liveOperation = {
status: "not_run",
operationId: "not_observed",
traceId: "not_observed",
auditId: "not_observed",
evidenceId: "not_observed",
summary: "Dry-run plan only; no DEV-LIVE operation was attempted or claimed."
};
report.blockers.push({
type: "safety_blocker",
scope: "m3-live-write-authorization",
status: "open",
classification: "dry_run_plan_only",
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",
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."
};
await writeReport(report, reportPath);
console.log(`[dev-m3-smoke] mode=dry-run status=blocked report=${relativePath(reportPath)}`);
console.log("[dev-m3-smoke] dry-run: no DEV endpoint calls and no live write were sent");
}
function addNotRunM3Checks(report, reason) {
for (const id of [
"two-box-simu-online",
@@ -1223,8 +1492,14 @@ async function writeReport(report, reportPath) {
}
async function main() {
const { flags, values } = parseArgs(process.argv.slice(2));
requireSafetyGates(flags);
const args = parseArgs(process.argv.slice(2));
const safety = requireSafetyGates(args);
if (safety.mode === "dry-run") {
await runDryRunPlan(args);
return;
}
const { values } = args;
await ensureContractFiles();
const reportPath = resolveReportPath(values.get("output"));
@@ -522,7 +522,10 @@ function collectM3Evidence(reports) {
`auditId=${m3.liveOperation?.auditId ?? "not_observed"}`,
`evidenceId=${m3.liveOperation?.evidenceId ?? "not_observed"}`
],
commands: ["node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"],
commands: [
"node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run",
"node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod"
],
summary: liveSummary
})
];
+67 -2
View File
@@ -85,8 +85,11 @@ const requiredDevM3ValidationCommands = [
"node --check scripts/dev-m3-hardware-loop-smoke.mjs",
"node --check scripts/validate-dev-m3-cardinality.mjs",
"node scripts/validate-dev-m3-cardinality.mjs",
"node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"
"node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run",
"node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod"
];
const legacyDevM3LiveCommand =
"node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production";
const requiredDevM3Docs = [
"docs/dev-acceptance-matrix.md",
"docs/m3-hardware-loop.md"
@@ -1334,6 +1337,10 @@ async function validateDevM3Report(report, label) {
`${label}.validationCommands missing ${requiredCommand}`
);
}
assert.ok(
report.validationCommands.includes(legacyDevM3LiveCommand),
`${label}.validationCommands missing legacy compatibility command ${legacyDevM3LiveCommand}`
);
assertObject(report.runtimeTarget, `${label}.runtimeTarget`);
assert.equal(report.runtimeTarget.endpoint, "http://74.48.78.17:16667", `${label}.runtimeTarget.endpoint`);
@@ -1348,6 +1355,16 @@ async function validateDevM3Report(report, label) {
assert.equal(report.runtimeTarget.environment, "dev", `${label}.runtimeTarget.environment`);
assert.equal(report.runtimeTarget.requiredBoxSimulators, 2, `${label}.runtimeTarget.requiredBoxSimulators`);
assert.equal(report.runtimeTarget.requiredGatewaySimulators, 2, `${label}.runtimeTarget.requiredGatewaySimulators`);
if (Object.hasOwn(report.runtimeTarget, "requiredPatchPanels")) {
assert.equal(report.runtimeTarget.requiredPatchPanels, 1, `${label}.runtimeTarget.requiredPatchPanels`);
}
if (Object.hasOwn(report.runtimeTarget, "requiredRoute")) {
assert.equal(
report.runtimeTarget.requiredRoute,
"res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1",
`${label}.runtimeTarget.requiredRoute`
);
}
assert.equal(report.runtimeTarget.realHardwareAllowed, false, `${label}.runtimeTarget.realHardwareAllowed`);
assert.equal(report.runtimeTarget.prodAllowed, false, `${label}.runtimeTarget.prodAllowed`);
@@ -1355,7 +1372,9 @@ async function validateDevM3Report(report, label) {
for (const field of [
"liveFlagRequired",
"confirmDevRequired",
"expectNonProdRequired",
"confirmedNonProductionRequired",
"dryRunPlanSupported",
"prodForbidden",
"realHardwareForbidden",
"secretReadForbidden",
@@ -1364,6 +1383,9 @@ async function validateDevM3Report(report, label) {
]) {
assert.equal(report.safetyGates[field], true, `${label}.safetyGates.${field}`);
}
if (Object.hasOwn(report.safetyGates, "dryRunCallsLiveEndpoints")) {
assert.equal(report.safetyGates.dryRunCallsLiveEndpoints, false, `${label}.safetyGates.dryRunCallsLiveEndpoints`);
}
assertArray(report.liveChecks, `${label}.liveChecks`);
assert.ok(report.liveChecks.length >= 8, `${label}.liveChecks must include DEV M3 checks`);
@@ -1449,7 +1471,14 @@ async function validateDevM3Report(report, label) {
["pass", "gap", "source-ready", "manifest-ready"].includes(evidence.status),
`${evidenceLabel}.status must be pass, gap, source-ready, or manifest-ready`
);
assertRepoRelativePath(evidence.source, `${evidenceLabel}.source`);
if (Array.isArray(evidence.source)) {
assertStringArray(evidence.source, `${evidenceLabel}.source`, { minLength: 1 });
for (const [sourceIndex, sourcePath] of evidence.source.entries()) {
assertRepoRelativePath(sourcePath, `${evidenceLabel}.source[${sourceIndex}]`);
}
} else {
assertRepoRelativePath(evidence.source, `${evidenceLabel}.source`);
}
assertString(evidence.summary, `${evidenceLabel}.summary`);
assertObject(evidence.evidence, `${evidenceLabel}.evidence`);
assertString(evidence.requiredFollowUp, `${evidenceLabel}.requiredFollowUp`);
@@ -1462,6 +1491,42 @@ async function validateDevM3Report(report, label) {
}
}
if (Object.hasOwn(report, "dryRunPlan")) {
const plan = report.dryRunPlan;
assertObject(plan, `${label}.dryRunPlan`);
assert.equal(plan.mode, "plan-only", `${label}.dryRunPlan.mode`);
assert.equal(plan.evidenceLevel, "DRY-RUN", `${label}.dryRunPlan.evidenceLevel`);
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`);
assertStringArray(plan.liveWritePreconditions, `${label}.dryRunPlan.liveWritePreconditions`, { minLength: 4 });
assertStringArray(plan.evidenceFields, `${label}.dryRunPlan.evidenceFields`, { minLength: 4 });
assertArray(plan.endpointPlan, `${label}.dryRunPlan.endpointPlan`);
assert.ok(plan.endpointPlan.length >= 8, `${label}.dryRunPlan.endpointPlan must enumerate precondition and live endpoints`);
assert.ok(
plan.endpointPlan.some((endpoint) => endpoint.serviceId === "hwlab-patch-panel" && endpoint.path === "/signals/route" && endpoint.mutatesDevState === true),
`${label}.dryRunPlan.endpointPlan must include the patch-panel route write endpoint`
);
assert.ok(
plan.endpointPlan.some((endpoint) => endpoint.serviceId === "hwlab-box-simu" && endpoint.path === "/ports/write" && endpoint.mutatesDevState === true),
`${label}.dryRunPlan.endpointPlan must include the source box DO1 write endpoint`
);
assert.ok(
plan.endpointPlan.every((endpoint) => endpoint.calledInDryRun === false),
`${label}.dryRunPlan.endpointPlan endpoints must not be called in dry-run`
);
for (const requiredField of [
"operationId",
"traceId",
"auditId",
"evidenceId",
"routeResponse.propagatedBy=hwlab-patch-panel",
"targetState.ports.DI1.propagatedBy=hwlab-patch-panel"
]) {
assert.ok(plan.evidenceFields.includes(requiredField), `${label}.dryRunPlan.evidenceFields missing ${requiredField}`);
}
}
assertObject(report.liveOperation, `${label}.liveOperation`);
assertStatus(report.liveOperation.status, `${label}.liveOperation.status`);
for (const field of ["operationId", "traceId", "auditId", "evidenceId", "summary"]) {
+3
View File
@@ -41,6 +41,9 @@ async function main() {
assertIncludes(doc, "deploy/artifact-catalog.dev.json", "m3 runbook");
assertIncludes(doc, "pikasTech/HWLAB#63", "m3 runbook");
assertIncludes(doc, "Do not promote SOURCE / LOCAL / DRY-RUN / fixture output to `DEV-LIVE`.", "m3 runbook");
assertIncludes(doc, "node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run", "m3 runbook");
assertIncludes(doc, "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod", "m3 runbook");
assertIncludes(doc, "liveOperation.status: \"not_run\"", "m3 runbook");
assertNotIncludes(boundary, "http://74.48.78.17:6666/", "m3 current boundary");
assertNotIncludes(boundary, "http://74.48.78.17:6667/", "m3 current boundary");
assertIncludes(boundary, "http://74.48.78.17:16666/", "m3 current boundary");