test: add m3 source contract dry-run plan
This commit is contained in:
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user