984 lines
31 KiB
JavaScript
984 lines
31 KiB
JavaScript
import { randomUUID } from "node:crypto";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import {
|
|
M3_IO_CHAIN,
|
|
M3_IO_CONTROL_CONTRACT_VERSION,
|
|
M3_IO_CONTROL_ROUTE,
|
|
describeM3IoControl
|
|
} from "../../internal/cloud/m3-io-control.mjs";
|
|
|
|
export const M3_IO_E2E_CONTRACT_VERSION = "m3-io-control-e2e-v1";
|
|
export const defaultFrontendUrl = "http://74.48.78.17:16666/";
|
|
export const defaultApiUrl = "http://74.48.78.17:16667/v1/m3/io";
|
|
export const controlPathReachablePersistenceBlocked = "control_path_reachable_trusted_persistence_blocked";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const labels = Object.freeze({
|
|
source: "SOURCE",
|
|
local: "LOCAL",
|
|
dryRun: "DRY-RUN",
|
|
devLive: "DEV-LIVE",
|
|
blocked: "BLOCKED"
|
|
});
|
|
|
|
const forbiddenLiveFlags = Object.freeze([
|
|
"--prod",
|
|
"--production",
|
|
"--real-hardware",
|
|
"--read-secret",
|
|
"--read-token",
|
|
"--deploy",
|
|
"--apply",
|
|
"--restart",
|
|
"--restart-runtime",
|
|
"--restart-unidesk",
|
|
"--restart-code-queue",
|
|
"--restart-backend-core"
|
|
]);
|
|
|
|
const valueFlags = new Set(["--report", "--output", "--url", "--api-url", "--target", "--timeout-ms"]);
|
|
const booleanFlags = new Set([
|
|
"--live",
|
|
"--confirm-dev",
|
|
"--confirmed-non-production",
|
|
"--expect-non-prod",
|
|
"--source",
|
|
"--source-static",
|
|
"--dry-run",
|
|
"--pretty",
|
|
...forbiddenLiveFlags
|
|
]);
|
|
|
|
export function parseM3IoControlE2eArgs(argv = []) {
|
|
const flags = new Set();
|
|
const values = new Map();
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (!arg.startsWith("--")) {
|
|
throw new Error(`unexpected positional argument: ${arg}`);
|
|
}
|
|
|
|
if (valueFlags.has(arg)) {
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith("--")) {
|
|
throw new Error(`${arg} requires a value`);
|
|
}
|
|
values.set(arg.slice(2), value);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (!booleanFlags.has(arg)) {
|
|
throw new Error(`unknown flag: ${arg}`);
|
|
}
|
|
flags.add(arg);
|
|
}
|
|
|
|
const target = values.get("target") ?? (values.has("api-url") ? "api" : "frontend");
|
|
if (!["frontend", "api"].includes(target)) {
|
|
throw new Error("--target must be frontend or api");
|
|
}
|
|
|
|
const timeoutMs = parsePositiveInteger(values.get("timeout-ms"), 8000);
|
|
return {
|
|
flags,
|
|
values,
|
|
live: flags.has("--live"),
|
|
source: flags.has("--source") || flags.has("--source-static"),
|
|
dryRun: flags.has("--dry-run"),
|
|
confirmDev: flags.has("--confirm-dev"),
|
|
confirmedNonProduction: flags.has("--confirmed-non-production") || flags.has("--expect-non-prod"),
|
|
target,
|
|
frontendUrl: values.get("url") ?? defaultFrontendUrl,
|
|
apiUrl: values.get("api-url") ?? defaultApiUrl,
|
|
reportPath: values.get("report") ?? values.get("output") ?? null,
|
|
timeoutMs,
|
|
pretty: flags.has("--pretty")
|
|
};
|
|
}
|
|
|
|
export function requireM3IoControlE2eGates(args) {
|
|
for (const flag of forbiddenLiveFlags) {
|
|
if (args.flags.has(flag)) {
|
|
throw new Error(`${flag} is forbidden for the DEV M3 IO control harness`);
|
|
}
|
|
}
|
|
|
|
if (!args.live) {
|
|
return {
|
|
mode: "source-static",
|
|
liveWriteAllowed: false,
|
|
mutationAllowed: false
|
|
};
|
|
}
|
|
|
|
if (args.dryRun || args.source) {
|
|
throw new Error("--live cannot be combined with --dry-run, --source, or --source-static");
|
|
}
|
|
|
|
if (!args.confirmDev || !args.confirmedNonProduction) {
|
|
throw new Error("live mode requires --live --confirm-dev --confirmed-non-production");
|
|
}
|
|
|
|
return {
|
|
mode: "dev-live",
|
|
liveWriteAllowed: true,
|
|
mutationAllowed: true
|
|
};
|
|
}
|
|
|
|
export function expectedM3IoLiveSequence() {
|
|
return [
|
|
{
|
|
id: "write-do1-true",
|
|
method: "POST",
|
|
path: M3_IO_CONTROL_ROUTE,
|
|
action: "do.write",
|
|
expectedValue: true,
|
|
mutatesDevState: true,
|
|
request: doWriteRequest(true)
|
|
},
|
|
{
|
|
id: "read-di1-true",
|
|
method: "POST",
|
|
path: M3_IO_CONTROL_ROUTE,
|
|
action: "di.read",
|
|
expectedValue: true,
|
|
mutatesDevState: false,
|
|
request: diReadRequest()
|
|
},
|
|
{
|
|
id: "write-do1-false",
|
|
method: "POST",
|
|
path: M3_IO_CONTROL_ROUTE,
|
|
action: "do.write",
|
|
expectedValue: false,
|
|
mutatesDevState: true,
|
|
request: doWriteRequest(false)
|
|
},
|
|
{
|
|
id: "read-di1-false",
|
|
method: "POST",
|
|
path: M3_IO_CONTROL_ROUTE,
|
|
action: "di.read",
|
|
expectedValue: false,
|
|
mutatesDevState: false,
|
|
request: diReadRequest()
|
|
}
|
|
];
|
|
}
|
|
|
|
export async function buildM3IoControlSourceReport(options = {}) {
|
|
const root = options.repoRoot ?? repoRoot;
|
|
const checks = await runM3IoControlSourceChecks({ repoRoot: root });
|
|
const classification = checks.every((check) => check.status === "pass")
|
|
? "source_static_contract_checked"
|
|
: "source_contract_failed";
|
|
const status = classification === "source_static_contract_checked" ? "pass" : "blocked";
|
|
|
|
return {
|
|
tool: "m3-io-control-e2e",
|
|
contractVersion: M3_IO_E2E_CONTRACT_VERSION,
|
|
mode: "source-static",
|
|
sourceKind: labels.source,
|
|
evidenceLabels: evidenceLabelDescriptions(),
|
|
liveMutation: {
|
|
attempted: false,
|
|
allowed: false,
|
|
reason: "Default SOURCE/static mode never POSTs to DEV and never mutates simulator state."
|
|
},
|
|
endpoints: {
|
|
frontendSameOriginBase: defaultFrontendUrl,
|
|
frontendSameOriginPath: M3_IO_CONTROL_ROUTE,
|
|
cloudApiPath: M3_IO_CONTROL_ROUTE,
|
|
directCloudApiEndpoint: defaultApiUrl,
|
|
gatewayBoxPatchPanelDirectCallsAllowed: false
|
|
},
|
|
dryRunPlan: {
|
|
sourceKind: labels.dryRun,
|
|
liveWriteWillRun: false,
|
|
sequence: expectedM3IoLiveSequence().map((step) => ({
|
|
id: step.id,
|
|
method: step.method,
|
|
path: step.path,
|
|
action: step.action,
|
|
expectedValue: step.expectedValue,
|
|
mutatesDevState: step.mutatesDevState
|
|
}))
|
|
},
|
|
checks,
|
|
summary: {
|
|
status,
|
|
classification,
|
|
trustedGreen: false,
|
|
observedAt: isoNow(),
|
|
result: status === "pass"
|
|
? "SOURCE/static M3 IO control contract passed; DEV-LIVE was not run."
|
|
: "SOURCE/static M3 IO control contract failed; DEV-LIVE must not run until fixed."
|
|
}
|
|
};
|
|
}
|
|
|
|
export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } = {}) {
|
|
const [serverSource, jsonRpcSource, m3Source, appSource, htmlSource, artifactPublisherSource] = await Promise.all([
|
|
readRepoText(root, "internal/cloud/server.mjs"),
|
|
readRepoText(root, "internal/cloud/json-rpc.mjs"),
|
|
readRepoText(root, "internal/cloud/m3-io-control.mjs"),
|
|
readRepoText(root, "web/hwlab-cloud-web/app.mjs"),
|
|
readRepoText(root, "web/hwlab-cloud-web/index.html"),
|
|
readRepoText(root, "scripts/dev-artifact-publish.mjs")
|
|
]);
|
|
|
|
const contract = describeM3IoControl({
|
|
env: {
|
|
HWLAB_M3_IO_CONTROL_ENABLED: "true"
|
|
}
|
|
});
|
|
const frontendGuardrails = checkFrontendNoDirectRuntimeCalls({
|
|
appSource,
|
|
htmlSource,
|
|
artifactPublisherSource
|
|
});
|
|
const requestSchema = validateExpectedRequestSchemas(expectedM3IoLiveSequence());
|
|
const responseSchema = validateM3IoSourceResponseContract(m3Source);
|
|
|
|
return [
|
|
checkResult(
|
|
"route-name-contract",
|
|
contract.route === M3_IO_CONTROL_ROUTE &&
|
|
M3_IO_CONTROL_ROUTE === "/v1/m3/io" &&
|
|
contract.contractVersion === M3_IO_CONTROL_CONTRACT_VERSION,
|
|
"cloud-api route is /v1/m3/io with the m3-io-control-v1 contract.",
|
|
{
|
|
route: contract.route,
|
|
contractVersion: contract.contractVersion,
|
|
evidenceLevel: labels.source
|
|
}
|
|
),
|
|
checkResult(
|
|
"m3-chain-contract",
|
|
routeChainMatches(contract.chain),
|
|
"M3 chain is box-simu-1 DO1 -> hwlab-patch-panel -> box-simu-2 DI1.",
|
|
{
|
|
chain: contract.chain,
|
|
evidenceLevel: labels.source
|
|
}
|
|
),
|
|
checkResult(
|
|
"cloud-api-rest-route-contract",
|
|
serverSource.includes("url.pathname === M3_IO_CONTROL_ROUTE") &&
|
|
serverSource.includes("handleM3IoControlHttp") &&
|
|
serverSource.includes("m3IoControl: describeM3IoControl"),
|
|
"cloud-api exposes GET/POST /v1/m3/io and advertises it from /v1.",
|
|
{
|
|
route: M3_IO_CONTROL_ROUTE,
|
|
evidenceLevel: labels.source
|
|
}
|
|
),
|
|
checkResult(
|
|
"cloud-api-json-rpc-method-contract",
|
|
jsonRpcSource.includes("M3_IO_RPC_METHODS.doWrite") &&
|
|
jsonRpcSource.includes("M3_IO_RPC_METHODS.diRead") &&
|
|
jsonRpcSource.includes('handleM3IoRpc("do.write"') &&
|
|
jsonRpcSource.includes('handleM3IoRpc("di.read"'),
|
|
"cloud-api JSON-RPC exposes M3 do.write and di.read through the same controlled handler.",
|
|
{
|
|
methods: ["m3.io.do.write", "m3.io.di.read"],
|
|
evidenceLevel: labels.source
|
|
}
|
|
),
|
|
checkResult(
|
|
"request-schema-contract",
|
|
requestSchema.status === "pass",
|
|
"Expected DEV live sequence uses only do.write on DO1 and di.read on DI1.",
|
|
{
|
|
issues: requestSchema.issues,
|
|
sequence: requestSchema.sequence,
|
|
evidenceLevel: labels.dryRun
|
|
}
|
|
),
|
|
checkResult(
|
|
"response-schema-contract",
|
|
responseSchema.status === "pass",
|
|
"M3 IO responses carry operationId, traceId, auditId, evidenceId, evidenceState, and frontendBypass=false.",
|
|
{
|
|
issues: responseSchema.issues,
|
|
evidenceLevel: labels.source
|
|
}
|
|
),
|
|
checkResult(
|
|
"frontend-same-origin-guardrail",
|
|
frontendGuardrails.status === "pass",
|
|
"Cloud Web only calls same-origin /v1/m3/io for the user control path and has no direct gateway/box/patch-panel fetches.",
|
|
{
|
|
fetchTargets: frontendGuardrails.fetchTargets,
|
|
issues: frontendGuardrails.issues,
|
|
evidenceLevel: labels.source
|
|
}
|
|
)
|
|
];
|
|
}
|
|
|
|
export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "", artifactPublisherSource = "" }) {
|
|
const source = `${appSource}\n${htmlSource}`;
|
|
const fetchTargets = literalFetchTargets(appSource);
|
|
const issues = [];
|
|
const allowedSameOriginTargets = new Set([
|
|
"/help.md",
|
|
"/health/live",
|
|
"/v1",
|
|
"/v1/diagnostics/gate",
|
|
"/v1/live-builds",
|
|
"/v1/agent/chat",
|
|
"/v1/m3/io",
|
|
"/v1/m3/status",
|
|
"/json-rpc"
|
|
]);
|
|
|
|
for (const target of fetchTargets) {
|
|
if (!target.startsWith("/")) {
|
|
issues.push(`frontend fetch target is not same-origin relative: ${target}`);
|
|
}
|
|
if (target.startsWith("/") && !allowedSameOriginTargets.has(target)) {
|
|
issues.push(`frontend fetch target is not in the allowed same-origin set: ${target}`);
|
|
}
|
|
}
|
|
|
|
const directRuntimePatterns = [
|
|
/\bfetch(?:Json)?\(\s*["'`][^"'`]*(?:gateway-simu|box-simu|patch-panel|:7101|:7201|:7301)/iu,
|
|
/\bfetch(?:Json)?\(\s*["'`]https?:\/\//iu,
|
|
/https?:\/\/[^"'`\s]*(?:gateway-simu|box-simu|patch-panel|:7101|:7201|:7301)/iu,
|
|
/(?:\/invoke|\/sync\/tick)/iu
|
|
];
|
|
for (const pattern of directRuntimePatterns) {
|
|
if (pattern.test(source)) {
|
|
issues.push(`frontend source matches forbidden direct runtime pattern: ${pattern.source}`);
|
|
}
|
|
}
|
|
|
|
const actionBody = functionBody(appSource, "runM3IoAction");
|
|
if (!/fetchJson\("\/v1\/m3\/io"/u.test(actionBody)) {
|
|
issues.push("runM3IoAction must POST through same-origin /v1/m3/io");
|
|
}
|
|
if (/fetchJson\(["'`](?!\/v1\/m3\/(?:io|status))/u.test(actionBody) || /\bfetch\(/u.test(actionBody)) {
|
|
issues.push("runM3IoAction must not call any path other than fetchJson(\"/v1/m3/io\") or fetchJson(\"/v1/m3/status\")");
|
|
}
|
|
if (!/if \(!m3ControlCanOperate\(\)\)/u.test(actionBody)) {
|
|
issues.push("runM3IoAction must fail closed before POST when M3 readiness is blocked");
|
|
}
|
|
const readinessBody = functionBody(appSource, "m3ControlCanOperate");
|
|
for (const [pattern, label] of [
|
|
[/readiness\?\.status === "ready"/u, "readiness.status=ready"],
|
|
[/readiness\?\.controlReady === true/u, "readiness.controlReady=true"],
|
|
[/readiness\?\.sourceKind === "DEV-LIVE"/u, "readiness.sourceKind=DEV-LIVE"],
|
|
[/readiness\?\.evidenceLevel === "DEV-LIVE"/u, "readiness.evidenceLevel=DEV-LIVE"]
|
|
]) {
|
|
if (!pattern.test(readinessBody)) {
|
|
issues.push(`m3ControlCanOperate must require ${label}`);
|
|
}
|
|
}
|
|
if (!/url\.pathname === "\/v1\/m3\/io"/u.test(artifactPublisherSource)) {
|
|
issues.push("cloud-web artifact server must proxy same-origin /v1/m3/io to cloud-api");
|
|
}
|
|
if (!/\/v1\/m3\/status/u.test(artifactPublisherSource)) {
|
|
issues.push("cloud-web artifact server must proxy same-origin /v1/m3/status to cloud-api");
|
|
}
|
|
|
|
return {
|
|
status: issues.length === 0 ? "pass" : "fail",
|
|
fetchTargets,
|
|
issues
|
|
};
|
|
}
|
|
|
|
export function validateExpectedRequestSchemas(sequence) {
|
|
const issues = [];
|
|
for (const step of sequence) {
|
|
if (step.path !== M3_IO_CONTROL_ROUTE || step.method !== "POST") {
|
|
issues.push(`${step.id} must POST ${M3_IO_CONTROL_ROUTE}`);
|
|
}
|
|
if (!["do.write", "di.read"].includes(step.request.action)) {
|
|
issues.push(`${step.id} action is invalid`);
|
|
}
|
|
if (step.request.action === "do.write") {
|
|
if (
|
|
step.request.gatewayId !== M3_IO_CHAIN.sourceGatewayId ||
|
|
step.request.resourceId !== M3_IO_CHAIN.sourceResourceId ||
|
|
step.request.boxId !== M3_IO_CHAIN.sourceBoxId ||
|
|
step.request.port !== M3_IO_CHAIN.sourcePort ||
|
|
typeof step.request.value !== "boolean"
|
|
) {
|
|
issues.push(`${step.id} must write ${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} with a boolean value`);
|
|
}
|
|
}
|
|
if (step.request.action === "di.read") {
|
|
if (
|
|
step.request.gatewayId !== M3_IO_CHAIN.targetGatewayId ||
|
|
step.request.resourceId !== M3_IO_CHAIN.targetResourceId ||
|
|
step.request.boxId !== M3_IO_CHAIN.targetBoxId ||
|
|
step.request.port !== M3_IO_CHAIN.targetPort ||
|
|
Object.hasOwn(step.request, "value")
|
|
) {
|
|
issues.push(`${step.id} must read ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort} without a value`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: issues.length === 0 ? "pass" : "fail",
|
|
issues,
|
|
sequence: sequence.map((step) => ({
|
|
id: step.id,
|
|
action: step.request.action,
|
|
gatewayId: step.request.gatewayId,
|
|
resourceId: step.request.resourceId,
|
|
boxId: step.request.boxId,
|
|
port: step.request.port,
|
|
value: Object.hasOwn(step.request, "value") ? step.request.value : undefined
|
|
}))
|
|
};
|
|
}
|
|
|
|
export function validateM3IoSourceResponseContract(m3Source) {
|
|
const requiredPatterns = [
|
|
/operationId:\s*input\.operation\.operationId/u,
|
|
/traceId:\s*input\.meta\.traceId/u,
|
|
/auditId:\s*records\.auditEvent\.auditId/u,
|
|
/evidenceId:\s*records\.evidenceRecord\.evidenceId/u,
|
|
/gatewaySessionId:\s*target\.gatewaySessionId/u,
|
|
/resourceId:\s*target\.resourceId/u,
|
|
/port:\s*target\.port/u,
|
|
/value:\s*target\.value/u,
|
|
/const\s+audit\s*=\s*auditState\(/u,
|
|
/const\s+evidence\s*=\s*evidenceState\(/u,
|
|
/auditState:\s*audit/u,
|
|
/evidenceState:\s*evidence/u,
|
|
/durableStatus:\s*durableStatus\(/u,
|
|
/blockerClassification:\s*redactedBlockerClassification\(/u,
|
|
/persistence:\s*persistenceSummary\(/u,
|
|
/describeM3IoControlLive/u,
|
|
/buildM3IoReadiness/u,
|
|
/controlReady:\s*false/u,
|
|
/frontendBypass:\s*false/u,
|
|
/patchPanelOnlyPropagation:\s*true/u,
|
|
/sourceKind:\s*"DEV-LIVE"/u,
|
|
/sourceKind:\s*"BLOCKED"/u
|
|
];
|
|
const issues = requiredPatterns
|
|
.filter((pattern) => !pattern.test(m3Source))
|
|
.map((pattern) => `missing response contract pattern: ${pattern.source}`);
|
|
|
|
return {
|
|
status: issues.length === 0 ? "pass" : "fail",
|
|
issues
|
|
};
|
|
}
|
|
|
|
export function validateM3IoLiveResponse(payload, step) {
|
|
const issues = [];
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
return {
|
|
status: "fail",
|
|
issues: ["response body must be a JSON object"]
|
|
};
|
|
}
|
|
for (const field of ["status", "accepted", "action", "chain", "controlPath", "traceId", "evidenceState"]) {
|
|
if (!Object.hasOwn(payload, field)) {
|
|
issues.push(`missing ${field}`);
|
|
}
|
|
}
|
|
for (const field of ["operationId", "auditId", "evidenceId"]) {
|
|
if (payload.status === "succeeded" && !stringValue(payload[field])) {
|
|
issues.push(`missing ${field} on succeeded response`);
|
|
}
|
|
}
|
|
if (payload.action !== step.action) {
|
|
issues.push(`expected action ${step.action}, got ${payload.action}`);
|
|
}
|
|
if (payload.controlPath?.frontendBypass !== false) {
|
|
issues.push("controlPath.frontendBypass must be false");
|
|
}
|
|
if (payload.controlPath?.cloudApi !== true || payload.controlPath?.gatewaySimu !== true || payload.controlPath?.boxSimu !== true) {
|
|
issues.push("controlPath must prove cloudApi, gatewaySimu, and boxSimu were used");
|
|
}
|
|
if (step.action === "do.write" && payload.controlPath?.patchPanel !== true) {
|
|
issues.push("do.write must prove patchPanel=true");
|
|
}
|
|
if (payload.chain && !routeChainMatches(payload.chain)) {
|
|
issues.push("response chain does not match the required M3 DO1->DI1 route");
|
|
}
|
|
if (payload.status === "succeeded") {
|
|
const actualValue = payload.result?.value;
|
|
if (actualValue !== step.expectedValue) {
|
|
issues.push(`expected result.value ${String(step.expectedValue)}, got ${String(actualValue)}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: issues.length === 0 ? "pass" : "fail",
|
|
issues
|
|
};
|
|
}
|
|
|
|
export function classifyM3IoControlReport(report) {
|
|
const sourceChecks = report.checks ?? [];
|
|
const operations = report.liveOperations ?? [];
|
|
if (operations.length === 0) {
|
|
if (sourceChecks.some((check) => check.status !== "pass")) {
|
|
return {
|
|
status: "blocked",
|
|
classification: "source_contract_failed",
|
|
trustedGreen: false
|
|
};
|
|
}
|
|
return {
|
|
status: "pass",
|
|
classification: "source_static_contract_checked",
|
|
trustedGreen: false
|
|
};
|
|
}
|
|
|
|
if (operations.some((operation) => operation.status === "unreachable" || operation.status === "http_error")) {
|
|
return {
|
|
status: "blocked",
|
|
classification: "control_path_unreachable",
|
|
trustedGreen: false
|
|
};
|
|
}
|
|
|
|
if (operations.some((operation) => operation.status !== "succeeded" || operation.schema?.status === "fail")) {
|
|
return {
|
|
status: "blocked",
|
|
classification: "control_path_blocked",
|
|
trustedGreen: false
|
|
};
|
|
}
|
|
|
|
const expectedSequence = expectedM3IoLiveSequence();
|
|
if (operations.length !== expectedSequence.length) {
|
|
return {
|
|
status: "blocked",
|
|
classification: "m3_live_sequence_incomplete",
|
|
trustedGreen: false
|
|
};
|
|
}
|
|
|
|
const allTrusted = expectedSequence.every((step, index) => {
|
|
const operation = operations[index] ?? {};
|
|
return operation.id === step.id &&
|
|
operation.action === step.action &&
|
|
operation.sourceKind === labels.devLive &&
|
|
operation.resultValue === step.expectedValue &&
|
|
stringValue(operation.operationId) &&
|
|
stringValue(operation.traceId) &&
|
|
stringValue(operation.auditId) &&
|
|
stringValue(operation.evidenceId) &&
|
|
operation.evidenceState?.status === "green" &&
|
|
operation.evidenceState?.sourceKind === labels.devLive &&
|
|
operation.evidenceState?.durable === true &&
|
|
operation.evidenceState?.writeStatus === "persisted";
|
|
});
|
|
|
|
if (!allTrusted) {
|
|
return {
|
|
status: "blocked",
|
|
classification: controlPathReachablePersistenceBlocked,
|
|
trustedGreen: false
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: "pass",
|
|
classification: "trusted_green",
|
|
trustedGreen: true
|
|
};
|
|
}
|
|
|
|
export async function runM3IoControlLiveReport(args, sourceReport) {
|
|
const endpoint = resolveM3IoEndpoint(args);
|
|
const contractProbe = await requestJson(endpoint.href, {
|
|
method: "GET",
|
|
timeoutMs: args.timeoutMs
|
|
});
|
|
const operations = [];
|
|
const sourceChecks = sourceReport.checks ?? [];
|
|
|
|
if (!contractProbe.ok) {
|
|
operations.push({
|
|
id: "contract-probe",
|
|
status: "unreachable",
|
|
sourceKind: labels.devLive,
|
|
endpoint: endpoint.href,
|
|
httpStatus: contractProbe.status,
|
|
error: contractProbe.error
|
|
});
|
|
} else if (contractProbe.body?.route !== M3_IO_CONTROL_ROUTE || !routeChainMatches(contractProbe.body?.chain)) {
|
|
operations.push({
|
|
id: "contract-probe",
|
|
status: "blocked",
|
|
sourceKind: labels.devLive,
|
|
endpoint: endpoint.href,
|
|
httpStatus: contractProbe.status,
|
|
error: "live /v1/m3/io contract did not match the source M3 route"
|
|
});
|
|
} else {
|
|
for (const step of expectedM3IoLiveSequence()) {
|
|
const operation = await postM3IoLiveStep(endpoint.href, step, args.timeoutMs);
|
|
operations.push(operation);
|
|
if (operation.status !== "succeeded") {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const report = {
|
|
...sourceReport,
|
|
mode: "dev-live",
|
|
sourceKind: labels.devLive,
|
|
liveMutation: {
|
|
attempted: true,
|
|
allowed: true,
|
|
reason: "DEV live mode was explicitly gated by --live --confirm-dev --confirmed-non-production."
|
|
},
|
|
endpoints: {
|
|
...sourceReport.endpoints,
|
|
selectedTarget: args.target,
|
|
selectedEndpoint: endpoint.href,
|
|
selectedEndpointKind: endpoint.kind
|
|
},
|
|
contractProbe: {
|
|
ok: contractProbe.ok,
|
|
status: contractProbe.status,
|
|
route: contractProbe.body?.route ?? null,
|
|
chain: contractProbe.body?.chain ?? null,
|
|
error: contractProbe.error ?? null
|
|
},
|
|
liveOperations: operations,
|
|
summary: {
|
|
...sourceReport.summary,
|
|
observedAt: isoNow()
|
|
}
|
|
};
|
|
report.summary = {
|
|
...report.summary,
|
|
...classifyM3IoControlReport({ checks: sourceChecks, liveOperations: operations })
|
|
};
|
|
report.summary.result = resultTextForClassification(report.summary.classification);
|
|
return report;
|
|
}
|
|
|
|
export function resolveM3IoEndpoint(args) {
|
|
const endpoint = args.target === "api"
|
|
? new URL(args.apiUrl)
|
|
: new URL(M3_IO_CONTROL_ROUTE, args.frontendUrl);
|
|
|
|
const port = endpoint.port || (endpoint.protocol === "http:" ? "80" : "443");
|
|
const allowed =
|
|
endpoint.protocol === "http:" &&
|
|
endpoint.hostname === "74.48.78.17" &&
|
|
endpoint.pathname === M3_IO_CONTROL_ROUTE &&
|
|
((args.target === "frontend" && port === "16666") || (args.target === "api" && port === "16667"));
|
|
|
|
if (!allowed) {
|
|
throw new Error("live endpoint must match target: frontend uses DEV http://74.48.78.17:16666/v1/m3/io; api uses DEV http://74.48.78.17:16667/v1/m3/io");
|
|
}
|
|
|
|
return {
|
|
href: endpoint.href,
|
|
kind: port === "16666" ? "cloud-web-same-origin" : "cloud-api-direct",
|
|
port: Number(port)
|
|
};
|
|
}
|
|
|
|
export async function runM3IoControlE2eCli(argv = []) {
|
|
const args = parseM3IoControlE2eArgs(argv);
|
|
const gates = requireM3IoControlE2eGates(args);
|
|
const sourceReport = await buildM3IoControlSourceReport();
|
|
const report = gates.mode === "dev-live"
|
|
? await runM3IoControlLiveReport(args, sourceReport)
|
|
: sourceReport;
|
|
|
|
if (args.reportPath) {
|
|
await writeReport(args.reportPath, report);
|
|
}
|
|
|
|
process.stdout.write(`${JSON.stringify(report, null, args.pretty ? 2 : 0)}\n`);
|
|
return report.summary.status === "pass" ? 0 : 1;
|
|
}
|
|
|
|
export function formatM3IoControlE2eFailure(error) {
|
|
return {
|
|
tool: "m3-io-control-e2e",
|
|
contractVersion: M3_IO_E2E_CONTRACT_VERSION,
|
|
status: "error",
|
|
sourceKind: labels.blocked,
|
|
error: {
|
|
message: error instanceof Error ? error.message : String(error)
|
|
}
|
|
};
|
|
}
|
|
|
|
async function postM3IoLiveStep(endpoint, step, timeoutMs) {
|
|
const traceId = `trc_m3_io_e2e_${step.id.replaceAll("-", "_")}_${randomUUID()}`;
|
|
const requestId = `req_m3_io_e2e_${step.id.replaceAll("-", "_")}_${randomUUID()}`;
|
|
const body = {
|
|
...step.request,
|
|
projectId: M3_IO_CHAIN.projectId,
|
|
traceId,
|
|
requestId,
|
|
actorId: "usr_hwlab_m3_io_e2e"
|
|
};
|
|
const response = await requestJson(endpoint, {
|
|
method: "POST",
|
|
body,
|
|
timeoutMs,
|
|
headers: {
|
|
"x-trace-id": traceId,
|
|
"x-request-id": requestId,
|
|
"x-actor-id": "usr_hwlab_m3_io_e2e"
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return {
|
|
id: step.id,
|
|
action: step.action,
|
|
status: response.status === 0 ? "unreachable" : "http_error",
|
|
sourceKind: labels.devLive,
|
|
endpoint,
|
|
httpStatus: response.status,
|
|
error: response.error,
|
|
request: redactLiveRequest(body)
|
|
};
|
|
}
|
|
|
|
const schema = validateM3IoLiveResponse(response.body, step);
|
|
return {
|
|
id: step.id,
|
|
action: step.action,
|
|
status: response.body?.status === "succeeded" && schema.status === "pass" ? "succeeded" : "blocked",
|
|
sourceKind: labels.devLive,
|
|
endpoint,
|
|
httpStatus: response.status,
|
|
request: redactLiveRequest(body),
|
|
schema,
|
|
operationId: response.body?.operationId ?? null,
|
|
traceId: response.body?.traceId ?? traceId,
|
|
auditId: response.body?.auditId ?? null,
|
|
evidenceId: response.body?.evidenceId ?? null,
|
|
evidenceState: response.body?.evidenceState ?? null,
|
|
accepted: response.body?.accepted ?? null,
|
|
resultValue: response.body?.result?.value,
|
|
blocker: response.body?.blocker ?? null,
|
|
controlPath: response.body?.controlPath ?? null
|
|
};
|
|
}
|
|
|
|
async function requestJson(url, { method = "GET", body, timeoutMs = 8000, headers = {} } = {}) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers: body === undefined
|
|
? {
|
|
accept: "application/json",
|
|
...headers
|
|
}
|
|
: {
|
|
accept: "application/json",
|
|
"content-type": "application/json",
|
|
...headers
|
|
},
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
signal: controller.signal
|
|
});
|
|
const text = await response.text();
|
|
let parsed = null;
|
|
try {
|
|
parsed = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
parsed = null;
|
|
}
|
|
return {
|
|
ok: response.ok,
|
|
status: response.status,
|
|
body: parsed,
|
|
error: response.ok ? null : parsed?.error?.message ?? parsed?.blocker?.message ?? response.statusText
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
status: 0,
|
|
body: null,
|
|
error: error.name === "AbortError" ? `request timed out after ${timeoutMs}ms` : error.message
|
|
};
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function doWriteRequest(value) {
|
|
return {
|
|
action: "do.write",
|
|
gatewayId: M3_IO_CHAIN.sourceGatewayId,
|
|
resourceId: M3_IO_CHAIN.sourceResourceId,
|
|
boxId: M3_IO_CHAIN.sourceBoxId,
|
|
port: M3_IO_CHAIN.sourcePort,
|
|
value
|
|
};
|
|
}
|
|
|
|
function diReadRequest() {
|
|
return {
|
|
action: "di.read",
|
|
gatewayId: M3_IO_CHAIN.targetGatewayId,
|
|
resourceId: M3_IO_CHAIN.targetResourceId,
|
|
boxId: M3_IO_CHAIN.targetBoxId,
|
|
port: M3_IO_CHAIN.targetPort
|
|
};
|
|
}
|
|
|
|
function routeChainMatches(chain) {
|
|
return chain?.sourceGatewayId === M3_IO_CHAIN.sourceGatewayId &&
|
|
chain?.sourceResourceId === M3_IO_CHAIN.sourceResourceId &&
|
|
chain?.sourceBoxId === M3_IO_CHAIN.sourceBoxId &&
|
|
chain?.sourcePort === M3_IO_CHAIN.sourcePort &&
|
|
chain?.targetGatewayId === M3_IO_CHAIN.targetGatewayId &&
|
|
chain?.targetResourceId === M3_IO_CHAIN.targetResourceId &&
|
|
chain?.targetBoxId === M3_IO_CHAIN.targetBoxId &&
|
|
chain?.targetPort === M3_IO_CHAIN.targetPort &&
|
|
chain?.patchPanelServiceId === M3_IO_CHAIN.patchPanelServiceId;
|
|
}
|
|
|
|
function checkResult(id, ok, summary, details = {}) {
|
|
return {
|
|
id,
|
|
status: ok ? "pass" : "fail",
|
|
sourceKind: details.evidenceLevel ?? labels.source,
|
|
summary,
|
|
...details
|
|
};
|
|
}
|
|
|
|
function literalFetchTargets(source) {
|
|
return [...source.matchAll(/\b(?:fetch|fetchJson)\(\s*(["'`])([^"'`]+)\1/gu)]
|
|
.map((match) => match[2])
|
|
.filter((target) => !target.includes("${"));
|
|
}
|
|
|
|
function functionBody(source, functionName) {
|
|
const match = source.match(new RegExp(`function\\s+${escapeRegExp(functionName)}\\s*\\([^)]*\\)\\s*\\{`, "u"));
|
|
if (!match) return "";
|
|
let depth = 0;
|
|
for (let index = match.index + match[0].length - 1; index < source.length; index += 1) {
|
|
const char = source[index];
|
|
if (char === "{") depth += 1;
|
|
if (char === "}") {
|
|
depth -= 1;
|
|
if (depth === 0) return source.slice(match.index, index + 1);
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function resultTextForClassification(classification) {
|
|
if (classification === "trusted_green") {
|
|
return "DEV-LIVE M3 IO control path and durable trusted records are green.";
|
|
}
|
|
if (classification === controlPathReachablePersistenceBlocked) {
|
|
return "DEV-LIVE control path was reachable, but runtime durable/trusted persistence was blocked; not trusted green.";
|
|
}
|
|
if (classification === "control_path_blocked") {
|
|
return "DEV-LIVE M3 IO control path returned a blocked or schema-invalid operation.";
|
|
}
|
|
if (classification === "control_path_unreachable") {
|
|
return "DEV-LIVE M3 IO control endpoint was unreachable or non-2xx.";
|
|
}
|
|
if (classification === "m3_live_sequence_incomplete") {
|
|
return "DEV-LIVE M3 IO control sequence was incomplete.";
|
|
}
|
|
if (classification === "source_contract_failed") {
|
|
return "SOURCE/static contract failed.";
|
|
}
|
|
return "SOURCE/static M3 IO control contract passed; DEV-LIVE was not run.";
|
|
}
|
|
|
|
function evidenceLabelDescriptions() {
|
|
return [
|
|
{
|
|
label: labels.source,
|
|
meaning: "Checked-in source or static contract; no runtime mutation."
|
|
},
|
|
{
|
|
label: labels.local,
|
|
meaning: "Local/in-process fixture evidence only; not a live DEV claim."
|
|
},
|
|
{
|
|
label: labels.dryRun,
|
|
meaning: "Planned sequence or schema validation; no live DEV POST."
|
|
},
|
|
{
|
|
label: labels.devLive,
|
|
meaning: "Explicitly gated DEV HTTP execution through cloud-web/cloud-api only."
|
|
},
|
|
{
|
|
label: labels.blocked,
|
|
meaning: "Cannot promote to trusted green."
|
|
}
|
|
];
|
|
}
|
|
|
|
function redactLiveRequest(body) {
|
|
return {
|
|
action: body.action,
|
|
gatewayId: body.gatewayId,
|
|
resourceId: body.resourceId,
|
|
boxId: body.boxId,
|
|
port: body.port,
|
|
value: Object.hasOwn(body, "value") ? body.value : undefined,
|
|
projectId: body.projectId,
|
|
traceId: body.traceId,
|
|
requestId: body.requestId,
|
|
actorId: body.actorId
|
|
};
|
|
}
|
|
|
|
function stringValue(value) {
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
}
|
|
|
|
function parsePositiveInteger(value, fallback) {
|
|
if (value === undefined) return fallback;
|
|
const parsed = Number.parseInt(value, 10);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
throw new Error("numeric value must be a positive integer");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
async function readRepoText(root, relativePath) {
|
|
return readFile(path.join(root, relativePath), "utf8");
|
|
}
|
|
|
|
async function writeReport(reportPath, report) {
|
|
const absolute = path.resolve(repoRoot, reportPath);
|
|
if (!absolute.startsWith(`${repoRoot}${path.sep}`)) {
|
|
throw new Error("report path must stay inside the repository");
|
|
}
|
|
await mkdir(path.dirname(absolute), { recursive: true });
|
|
await writeFile(absolute, `${JSON.stringify(report, null, 2)}\n`);
|
|
}
|
|
|
|
function isoNow() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|