Merge pull request #182 from pikasTech/fix/m3-trusted-loop-execution-path-64-clean
test: harden M3 patch-panel preflight
This commit is contained in:
@@ -92,8 +92,9 @@ node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-pr
|
||||
```
|
||||
|
||||
The legacy `--confirmed-non-production` flag is accepted as compatibility, but
|
||||
new handoffs should use `--expect-non-prod`. The script refuses the default
|
||||
mode and refuses `--dry-run` combined with `--live`.
|
||||
new handoffs should use `--expect-non-prod`. Missing or incomplete live
|
||||
authorization stays on the source/read-only plan path, and `--dry-run` combined
|
||||
with `--live` remains no-write.
|
||||
|
||||
The live smoke writes `reports/dev-gate/dev-m3-hardware-loop.json`. It first
|
||||
checks the frozen DEV endpoint, `http://74.48.78.17:16667`, and stops at the
|
||||
@@ -113,9 +114,14 @@ HWLAB_DEV_PATCH_PANEL_URL
|
||||
```
|
||||
|
||||
Those targets must be two distinct HWLAB DEV simulator services for each
|
||||
simulator kind, plus one HWLAB DEV patch panel, not UniDesk substitutes. The
|
||||
script then checks two box simulators, two gateway simulators, active
|
||||
patch-panel wiring for
|
||||
box/gateway role, plus one callable HWLAB DEV patch panel, not UniDesk
|
||||
substitutes. Missing direct targets are classified as `target_missing`,
|
||||
duplicated box or gateway identities as `identity_not_distinct`, and missing
|
||||
`res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1` wiring as
|
||||
`patch_panel_wiring_missing`. Each of those stop conditions keeps
|
||||
`liveOperation.status` at `not_run` and prevents the DEV write/read smoke from
|
||||
starting. The script then checks two box simulators, two gateway simulators,
|
||||
active patch-panel wiring for
|
||||
`res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1`, a live direct
|
||||
call that reads `DI1=true`, and returned operation, trace, audit, and evidence
|
||||
identifiers. Fixture-backed local M3 output is never accepted as live DEV
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check",
|
||||
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
|
||||
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
|
||||
"dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
|
||||
"cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs",
|
||||
"m1:smoke": "node scripts/m1-contract-smoke.mjs",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs";
|
||||
import { DEV_ENDPOINT, DEV_FRONTEND_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs";
|
||||
@@ -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("--expect-non-prod") || 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,11 @@ 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 --live, --confirm-dev, or non-production confirmation stay source/read-only",
|
||||
"non-production confirmation is --expect-non-prod; legacy --confirmed-non-production remains accepted",
|
||||
"a dry-run, source, local, fixture, or read-only blocker report leaves liveOperation.status as not_run or blocked, never pass"
|
||||
]
|
||||
};
|
||||
@@ -963,7 +984,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 +993,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 +1004,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 +1015,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 +1028,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 +1277,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 +1341,7 @@ function baseReport({ commitId, observedAt }) {
|
||||
confirmDevRequired: true,
|
||||
expectNonProdRequired: true,
|
||||
confirmedNonProductionRequired: true,
|
||||
defaultSourceReadOnly: true,
|
||||
dryRunPlanSupported: true,
|
||||
dryRunCallsLiveEndpoints: false,
|
||||
prodForbidden: true,
|
||||
@@ -1447,12 +1470,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 +1560,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 +1625,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 +1695,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."
|
||||
};
|
||||
@@ -1692,9 +1716,21 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error(`[dev-m3-smoke] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
export {
|
||||
classifyDirectTargetBlockerScope,
|
||||
classifyLiveOperationError,
|
||||
createLiveOperationPlan,
|
||||
hasRequiredM3Connection,
|
||||
m3FailureClassifications,
|
||||
parseArgs,
|
||||
requireSafetyGates
|
||||
};
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error(`[dev-m3-smoke] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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";
|
||||
|
||||
import {
|
||||
classifyDirectTargetBlockerScope,
|
||||
createLiveOperationPlan,
|
||||
hasRequiredM3Connection,
|
||||
parseArgs,
|
||||
requireSafetyGates
|
||||
} from "./dev-m3-hardware-loop-smoke.mjs";
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
test("M3 safety gates keep incomplete live commands source/read-only", () => {
|
||||
assert.deepEqual(requireSafetyGates(parseArgs([])), {
|
||||
mode: "dry-run",
|
||||
liveWriteAllowed: false
|
||||
});
|
||||
assert.deepEqual(requireSafetyGates(parseArgs(["--live", "--confirm-dev"])), {
|
||||
mode: "dry-run",
|
||||
liveWriteAllowed: false
|
||||
});
|
||||
assert.deepEqual(requireSafetyGates(parseArgs(["--dry-run", "--live", "--confirm-dev", "--expect-non-prod"])), {
|
||||
mode: "dry-run",
|
||||
liveWriteAllowed: false
|
||||
});
|
||||
assert.deepEqual(requireSafetyGates(parseArgs(["--live", "--confirm-dev", "--expect-non-prod"])), {
|
||||
mode: "live",
|
||||
liveWriteAllowed: true
|
||||
});
|
||||
assert.deepEqual(requireSafetyGates(parseArgs(["--live", "--confirm-dev", "--confirmed-non-production"])), {
|
||||
mode: "live",
|
||||
liveWriteAllowed: true
|
||||
});
|
||||
});
|
||||
|
||||
test("M3 blocker scopes classify missing identities and patch-panel route gaps", () => {
|
||||
assert.equal(classifyDirectTargetBlockerScope("m3-service-discovery"), "target_missing");
|
||||
assert.equal(classifyDirectTargetBlockerScope("m3-direct-target-missing"), "target_missing");
|
||||
assert.equal(classifyDirectTargetBlockerScope("m3-box-simu-identity"), "identity_not_distinct");
|
||||
assert.equal(classifyDirectTargetBlockerScope("m3-gateway-simu-identity"), "identity_not_distinct");
|
||||
assert.equal(classifyDirectTargetBlockerScope("m3-patch-panel-wiring"), "patch_panel_wiring_missing");
|
||||
|
||||
const plan = createLiveOperationPlan();
|
||||
assert.equal(plan.evidenceLevel, "DRY-RUN");
|
||||
assert.equal(plan.liveWriteWillRun, false);
|
||||
assert.ok(plan.failureClassifications.includes("identity_not_distinct"));
|
||||
assert.ok(plan.failureClassifications.includes("patch_panel_wiring_missing"));
|
||||
assert.match(plan.refusalPolicy.join("\n"), /source\/read-only/u);
|
||||
});
|
||||
|
||||
test("patch-panel route preflight rejects missing res_boxsimu_1 DO1 to res_boxsimu_2 DI1 wiring", () => {
|
||||
const wrongRoute = hasRequiredM3Connection({
|
||||
status: {
|
||||
activeConnections: [
|
||||
{
|
||||
fromResourceId: "res_boxsimu_1",
|
||||
fromPort: "DO1",
|
||||
toResourceId: "res_boxsimu_1",
|
||||
toPort: "DI1"
|
||||
}
|
||||
]
|
||||
},
|
||||
wiring: {
|
||||
connections: [
|
||||
{
|
||||
from: { resourceId: "res_boxsimu_1", port: "DO1" },
|
||||
to: { resourceId: "res_boxsimu_1", port: "DI1" },
|
||||
mode: "exclusive"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
assert.equal(Boolean(wrongRoute.active), false);
|
||||
assert.equal(Boolean(wrongRoute.configured), false);
|
||||
|
||||
const requiredRoute = hasRequiredM3Connection({
|
||||
status: {
|
||||
activeConnections: [
|
||||
{
|
||||
fromResourceId: "res_boxsimu_1",
|
||||
fromPort: "DO1",
|
||||
toResourceId: "res_boxsimu_2",
|
||||
toPort: "DI1"
|
||||
}
|
||||
]
|
||||
},
|
||||
wiring: {
|
||||
connections: [
|
||||
{
|
||||
from: { resourceId: "res_boxsimu_1", port: "DO1" },
|
||||
to: { resourceId: "res_boxsimu_2", port: "DI1" },
|
||||
mode: "exclusive"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
assert.equal(Boolean(requiredRoute.active), true);
|
||||
assert.equal(Boolean(requiredRoute.configured), true);
|
||||
});
|
||||
@@ -1722,6 +1722,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`);
|
||||
@@ -1835,6 +1838,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`);
|
||||
|
||||
Reference in New Issue
Block a user