Merge remote-tracking branch 'origin/main' into fix/durable-runtime-auth-blocker-003
This commit is contained in:
@@ -18,10 +18,13 @@ import { buildDbRuntimeReadiness } from "./db-contract.mjs";
|
||||
import { describeCodeAgentAvailability } from "./code-agent-chat.mjs";
|
||||
import { buildCloudApiReadiness } from "./health-contract.mjs";
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
import { M3_IO_RPC_METHODS, handleM3IoRpc } from "./m3-io-control.mjs";
|
||||
|
||||
export const SUPPORTED_RPC_METHODS = Object.freeze([
|
||||
"system.health",
|
||||
"cloud.adapter.describe",
|
||||
M3_IO_RPC_METHODS.doWrite,
|
||||
M3_IO_RPC_METHODS.diRead,
|
||||
"gateway.session.register",
|
||||
"box.resource.register",
|
||||
"box.capability.report",
|
||||
@@ -36,6 +39,8 @@ export const SUPPORTED_RPC_METHODS = Object.freeze([
|
||||
const rpcHandlers = new Map([
|
||||
["system.health", handleSystemHealth],
|
||||
["cloud.adapter.describe", handleAdapterDescribe],
|
||||
[M3_IO_RPC_METHODS.doWrite, async (params, envelope, context) => handleM3IoRpc("do.write", params, envelope, context)],
|
||||
[M3_IO_RPC_METHODS.diRead, async (params, envelope, context) => handleM3IoRpc("di.read", params, envelope, context)],
|
||||
["gateway.session.register", async (params, envelope, context) => getRuntimeStore(context).registerGatewaySession(params, envelope.meta)],
|
||||
["box.resource.register", async (params, envelope, context) => getRuntimeStore(context).registerBoxResource(params, envelope.meta)],
|
||||
["box.capability.report", async (params, envelope, context) => getRuntimeStore(context).reportBoxCapabilities(params, envelope.meta)],
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
|
||||
export const M3_IO_CONTROL_CONTRACT_VERSION = "m3-io-control-v1";
|
||||
export const M3_IO_CONTROL_ROUTE = "/v1/m3/io";
|
||||
export const M3_IO_RPC_METHODS = Object.freeze({
|
||||
doWrite: "m3.io.do.write",
|
||||
diRead: "m3.io.di.read"
|
||||
});
|
||||
|
||||
export const M3_IO_CHAIN = Object.freeze({
|
||||
projectId: "prj_m3_hardware_loop",
|
||||
@@ -235,6 +239,25 @@ export async function handleM3IoControl(params = {}, context = {}) {
|
||||
}));
|
||||
}
|
||||
|
||||
export async function handleM3IoRpc(action, params = {}, envelope = {}, context = {}) {
|
||||
return handleM3IoControl(
|
||||
{
|
||||
...params,
|
||||
action,
|
||||
traceId: envelope.meta?.traceId ?? params.traceId,
|
||||
requestId: envelope.id ?? params.requestId,
|
||||
actorId: envelope.meta?.actorId ?? params.actorId
|
||||
},
|
||||
{
|
||||
...context,
|
||||
requestJson: context.requestJson ?? context.m3IoRequestJson,
|
||||
traceId: envelope.meta?.traceId ?? context.traceId,
|
||||
requestId: envelope.id ?? context.requestId,
|
||||
actorId: envelope.meta?.actorId ?? context.actorId
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function buildM3IoConfig(env = process.env) {
|
||||
const enabledValue = String(env.HWLAB_M3_IO_CONTROL_ENABLED ?? env.HWLAB_M3_CONTROL_ENABLED ?? "true").toLowerCase();
|
||||
return {
|
||||
@@ -927,6 +950,7 @@ async function successControlResult(input) {
|
||||
outcome: "succeeded",
|
||||
reason: null
|
||||
});
|
||||
const target = controlTarget(input.command, input.operation.gatewaySessionId);
|
||||
return {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||||
@@ -935,10 +959,22 @@ async function successControlResult(input) {
|
||||
action: input.action,
|
||||
chain: M3_IO_CHAIN,
|
||||
command: input.command,
|
||||
target,
|
||||
gatewayId: target.gatewayId,
|
||||
gatewaySessionId: target.gatewaySessionId,
|
||||
resourceId: target.resourceId,
|
||||
boxId: target.boxId,
|
||||
port: target.port,
|
||||
value: target.value,
|
||||
operationId: input.operation.operationId,
|
||||
traceId: input.meta.traceId,
|
||||
auditId: records.auditEvent.auditId,
|
||||
evidenceId: records.evidenceRecord.evidenceId,
|
||||
operationState: {
|
||||
status: "succeeded",
|
||||
reachable: true
|
||||
},
|
||||
auditState: auditState(records.auditWrite, records.runtimeAfter),
|
||||
operation: {
|
||||
...input.operation,
|
||||
status: "succeeded",
|
||||
@@ -964,6 +1000,12 @@ async function successControlResult(input) {
|
||||
auditEvent: records.auditEvent,
|
||||
evidenceRecord: records.evidenceRecord,
|
||||
evidenceState: evidenceState(records.runtimeAfter, records),
|
||||
durableStatus: durableStatus(records.runtimeAfter),
|
||||
blockerClassification: redactedBlockerClassification({
|
||||
code: records.runtimeAfter?.blocker,
|
||||
reason: records.runtimeAfter?.reason,
|
||||
runtime: records.runtimeAfter
|
||||
}),
|
||||
persistence: persistenceSummary(records.runtimeAfter, input.registration, input.operationWrite, records),
|
||||
observedAt: input.now
|
||||
};
|
||||
@@ -975,6 +1017,7 @@ async function blockedControlResult(input) {
|
||||
outcome: "failed",
|
||||
reason: input.reason
|
||||
});
|
||||
const target = controlTarget(input.command, input.operation.gatewaySessionId);
|
||||
return {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||||
@@ -983,6 +1026,13 @@ async function blockedControlResult(input) {
|
||||
action: input.action,
|
||||
chain: M3_IO_CHAIN,
|
||||
command: input.command,
|
||||
target,
|
||||
gatewayId: target.gatewayId,
|
||||
gatewaySessionId: target.gatewaySessionId,
|
||||
resourceId: target.resourceId,
|
||||
boxId: target.boxId,
|
||||
port: target.port,
|
||||
value: target.value,
|
||||
operationId: input.operation.operationId,
|
||||
traceId: input.meta.traceId,
|
||||
auditId: records.auditEvent.auditId,
|
||||
@@ -992,6 +1042,17 @@ async function blockedControlResult(input) {
|
||||
message: input.reason,
|
||||
zh: input.reason
|
||||
},
|
||||
blockerClassification: redactedBlockerClassification({
|
||||
code: input.code,
|
||||
reason: input.reason,
|
||||
runtime: records.runtimeAfter
|
||||
}),
|
||||
operationState: {
|
||||
status: "blocked",
|
||||
reachable: false,
|
||||
blocker: input.code
|
||||
},
|
||||
auditState: auditState(records.auditWrite, records.runtimeAfter),
|
||||
operation: {
|
||||
...input.operation,
|
||||
status: "failed",
|
||||
@@ -1018,12 +1079,14 @@ async function blockedControlResult(input) {
|
||||
auditEvent: records.auditEvent,
|
||||
evidenceRecord: records.evidenceRecord,
|
||||
evidenceState: evidenceState(records.runtimeAfter, records),
|
||||
durableStatus: durableStatus(records.runtimeAfter),
|
||||
persistence: persistenceSummary(records.runtimeAfter, input.registration, input.operationWrite, records),
|
||||
observedAt: input.now
|
||||
};
|
||||
}
|
||||
|
||||
function blockedResult({ action, command = null, meta, runtime, code, reason, httpStatus, now, details = {} }) {
|
||||
const target = controlTarget(command, command?.gatewaySessionId);
|
||||
return {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||||
@@ -1032,6 +1095,13 @@ function blockedResult({ action, command = null, meta, runtime, code, reason, ht
|
||||
action: action || "unknown",
|
||||
chain: M3_IO_CHAIN,
|
||||
command,
|
||||
target,
|
||||
gatewayId: target.gatewayId,
|
||||
gatewaySessionId: target.gatewaySessionId,
|
||||
resourceId: target.resourceId,
|
||||
boxId: target.boxId,
|
||||
port: target.port,
|
||||
value: target.value,
|
||||
operationId: null,
|
||||
traceId: meta.traceId,
|
||||
auditId: null,
|
||||
@@ -1041,6 +1111,21 @@ function blockedResult({ action, command = null, meta, runtime, code, reason, ht
|
||||
message: reason,
|
||||
zh: reason
|
||||
},
|
||||
blockerClassification: redactedBlockerClassification({
|
||||
code,
|
||||
reason,
|
||||
runtime
|
||||
}),
|
||||
operationState: {
|
||||
status: "blocked",
|
||||
reachable: false,
|
||||
blocker: code
|
||||
},
|
||||
auditState: {
|
||||
status: "not_written",
|
||||
durableStatus: durableStatus(runtime),
|
||||
reason: "operation was blocked before audit write"
|
||||
},
|
||||
controlPath: {
|
||||
status: "blocked",
|
||||
cloudApi: true,
|
||||
@@ -1053,6 +1138,7 @@ function blockedResult({ action, command = null, meta, runtime, code, reason, ht
|
||||
auditWrite: { written: false, reason: "operation was blocked before audit write" },
|
||||
evidenceWrite: { written: false, reason: "operation was blocked before evidence write" }
|
||||
}),
|
||||
durableStatus: durableStatus(runtime),
|
||||
persistence: {
|
||||
runtime,
|
||||
writes: []
|
||||
@@ -1166,11 +1252,14 @@ function createEvidenceRecord({ evidenceId, projectId, operationId, traceId, pay
|
||||
function evidenceState(runtime, records) {
|
||||
const durableReady = isDurableRuntimeReady(runtime);
|
||||
const recordsWritten = records.auditWrite?.written === true && records.evidenceWrite?.written === true;
|
||||
const evidenceWriteState = recordWriteState(records.evidenceWrite, runtime);
|
||||
if (durableReady && recordsWritten) {
|
||||
return {
|
||||
status: "green",
|
||||
sourceKind: "DEV-LIVE",
|
||||
durable: true,
|
||||
writeStatus: evidenceWriteState.status,
|
||||
durableStatus: durableStatus(runtime),
|
||||
reason: "operation/audit/evidence 已通过 durable runtime 写入;仍需 DEV 浏览器 E2E 才能宣称验收通过。"
|
||||
};
|
||||
}
|
||||
@@ -1180,10 +1269,95 @@ function evidenceState(runtime, records) {
|
||||
sourceKind: "BLOCKED",
|
||||
durable: runtime?.durable === true,
|
||||
blocker,
|
||||
writeStatus: evidenceWriteState.status,
|
||||
durableStatus: durableStatus(runtime),
|
||||
reason: `操作链路可达性与可信持久化分离:runtime durable 未 green(status=${runtime?.status ?? "unknown"},blocker=${blocker}),不能宣称可信闭环完全 green。`
|
||||
};
|
||||
}
|
||||
|
||||
function auditState(auditWrite, runtime) {
|
||||
return {
|
||||
...recordWriteState(auditWrite, runtime),
|
||||
durableStatus: durableStatus(runtime)
|
||||
};
|
||||
}
|
||||
|
||||
function recordWriteState(write, runtime) {
|
||||
if (write?.written === true && isDurableRuntimeReady(runtime)) {
|
||||
return {
|
||||
status: "persisted",
|
||||
durable: true,
|
||||
sourceKind: "DEV-LIVE"
|
||||
};
|
||||
}
|
||||
if (write?.written === true) {
|
||||
return {
|
||||
status: "written_non_durable",
|
||||
durable: false,
|
||||
sourceKind: "BLOCKED",
|
||||
blocker: runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "not_written",
|
||||
durable: false,
|
||||
sourceKind: "BLOCKED",
|
||||
blocker: runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked,
|
||||
reason: write?.reason ?? "runtime write did not complete"
|
||||
};
|
||||
}
|
||||
|
||||
function durableStatus(runtime) {
|
||||
return {
|
||||
status: runtime?.status ?? "unknown",
|
||||
durable: runtime?.durable === true,
|
||||
ready: runtime?.ready === true,
|
||||
liveRuntimeEvidence: runtime?.liveRuntimeEvidence === true,
|
||||
blocker: runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked,
|
||||
redacted: true
|
||||
};
|
||||
}
|
||||
|
||||
function redactedBlockerClassification({ code, reason, runtime } = {}) {
|
||||
const blockerCode = code ?? runtime?.blocker ?? null;
|
||||
if (!blockerCode) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
code: blockerCode,
|
||||
category: blockerCategory(blockerCode),
|
||||
runtimeStatus: runtime?.status ?? "unknown",
|
||||
redacted: true,
|
||||
message: reason ? redactBlockerMessage(reason) : null
|
||||
};
|
||||
}
|
||||
|
||||
function blockerCategory(code) {
|
||||
if (String(code).startsWith("runtime_durable")) return "runtime_durable";
|
||||
if (String(code).includes("gateway")) return "gateway_session";
|
||||
if (String(code).includes("wiring")) return "patch_panel_wiring";
|
||||
if (String(code).includes("box")) return "box_resource";
|
||||
if (String(code).includes("port")) return "port_direction";
|
||||
return "m3_io_control";
|
||||
}
|
||||
|
||||
function redactBlockerMessage(message) {
|
||||
return String(message)
|
||||
.replace(/https?:\/\/[^\s,,;]+/giu, "[redacted-url]")
|
||||
.replace(/postgres(?:ql)?:\/\/[^\s,,;]+/giu, "[redacted-db-url]");
|
||||
}
|
||||
|
||||
function controlTarget(command, gatewaySessionId) {
|
||||
return {
|
||||
gatewayId: command?.gatewayId ?? null,
|
||||
gatewaySessionId: gatewaySessionId ?? command?.gatewaySessionId ?? null,
|
||||
resourceId: command?.resourceId ?? null,
|
||||
boxId: command?.boxId ?? null,
|
||||
port: command?.port ?? null,
|
||||
value: Object.hasOwn(command ?? {}, "value") ? command.value : null
|
||||
};
|
||||
}
|
||||
|
||||
function persistenceSummary(runtime, registration, operationWrite, records) {
|
||||
return {
|
||||
runtime,
|
||||
|
||||
@@ -4,9 +4,12 @@ import test from "node:test";
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
import { createBoxState, createGatewayState } from "../sim/model.mjs";
|
||||
import { writeBoxPort } from "../sim/l2-runtime.mjs";
|
||||
import { validateResponse } from "../protocol/index.mjs";
|
||||
import { handleJsonRpcRequest, SUPPORTED_RPC_METHODS } from "./json-rpc.mjs";
|
||||
import {
|
||||
M3_IO_BLOCKER_CODES,
|
||||
M3_IO_CHAIN,
|
||||
M3_IO_RPC_METHODS,
|
||||
describeM3IoControl,
|
||||
handleM3IoControl
|
||||
} from "./m3-io-control.mjs";
|
||||
@@ -65,10 +68,31 @@ test("M3 DO write dispatches through gateway-simu, ticks patch-panel, reads DI,
|
||||
assert.equal(result.action, "do.write");
|
||||
assert.equal(result.command.port, "DO1");
|
||||
assert.equal(result.command.value, true);
|
||||
assert.equal(result.gatewayId, "gwsimu_1");
|
||||
assert.equal(result.gatewaySessionId, "gws_gwsimu_1");
|
||||
assert.equal(result.resourceId, "res_boxsimu_1");
|
||||
assert.equal(result.boxId, "boxsimu_1");
|
||||
assert.equal(result.port, "DO1");
|
||||
assert.equal(result.value, true);
|
||||
assert.deepEqual(result.target, {
|
||||
gatewayId: "gwsimu_1",
|
||||
gatewaySessionId: "gws_gwsimu_1",
|
||||
resourceId: "res_boxsimu_1",
|
||||
boxId: "boxsimu_1",
|
||||
port: "DO1",
|
||||
value: true
|
||||
});
|
||||
assert.match(result.operationId, /^op_m3_do_write_/u);
|
||||
assert.equal(result.traceId, "trc_m3_control_write_true");
|
||||
assert.match(result.auditId, /^aud_m3_do_write_/u);
|
||||
assert.match(result.evidenceId, /^evd_m3_do_write_/u);
|
||||
assert.equal(result.operationState.status, "succeeded");
|
||||
assert.equal(result.auditState.status, "written_non_durable");
|
||||
assert.equal(result.auditState.durableStatus.durable, false);
|
||||
assert.equal(result.durableStatus.status, "degraded");
|
||||
assert.equal(result.durableStatus.redacted, true);
|
||||
assert.equal(result.blockerClassification.category, "runtime_durable");
|
||||
assert.equal(result.blockerClassification.redacted, true);
|
||||
assert.equal(result.controlPath.cloudApi, true);
|
||||
assert.equal(result.controlPath.gatewaySimu, true);
|
||||
assert.equal(result.controlPath.boxSimu, true);
|
||||
@@ -78,6 +102,7 @@ test("M3 DO write dispatches through gateway-simu, ticks patch-panel, reads DI,
|
||||
assert.equal(result.result.targetReadback.value, true);
|
||||
assert.equal(result.evidenceState.status, "blocked");
|
||||
assert.equal(result.evidenceState.sourceKind, "BLOCKED");
|
||||
assert.equal(result.evidenceState.writeStatus, "written_non_durable");
|
||||
assert.match(result.evidenceState.reason, /runtime durable 未 green/u);
|
||||
assert.equal(fixture.box1.ports.DO1.value, true);
|
||||
assert.equal(fixture.box2.ports.DI1.value, true);
|
||||
@@ -89,6 +114,15 @@ test("M3 DO write dispatches through gateway-simu, ticks patch-panel, reads DI,
|
||||
"/invoke"
|
||||
]);
|
||||
assert.equal(fixture.calls.every((call) => call.url.startsWith("http://gateway-") || call.url.startsWith("http://patch-panel")), true);
|
||||
assert.equal(fixture.calls[1].body.operation, "hardware.port.write");
|
||||
assert.equal(fixture.calls[1].body.gatewaySessionId, "gws_gwsimu_1");
|
||||
assert.equal(fixture.calls[1].body.resourceId, "res_boxsimu_1");
|
||||
assert.equal(fixture.calls[1].body.port, "DO1");
|
||||
assert.equal(fixture.calls[1].body.value, true);
|
||||
assert.equal(fixture.calls[2].body.signals[0].fromResourceId, "res_boxsimu_1");
|
||||
assert.equal(fixture.calls[4].body.operation, "hardware.port.read");
|
||||
assert.equal(fixture.calls[4].body.resourceId, "res_boxsimu_2");
|
||||
assert.equal(fixture.calls[4].body.port, "DI1");
|
||||
|
||||
const audit = runtimeStore.queryAuditEvents({ projectId: M3_IO_CHAIN.projectId });
|
||||
assert.ok(audit.events.some((event) => event.action === "m3.io.do.write"));
|
||||
@@ -133,6 +167,9 @@ test("M3 DO write can prove control reachability while durable trusted persisten
|
||||
assert.equal(result.evidenceState.sourceKind, "BLOCKED");
|
||||
assert.equal(result.evidenceState.durable, false);
|
||||
assert.equal(result.evidenceState.blocker, "runtime_durable_adapter_query_blocked");
|
||||
assert.equal(result.durableStatus.blocker, "runtime_durable_adapter_query_blocked");
|
||||
assert.equal(result.blockerClassification.code, "runtime_durable_adapter_query_blocked");
|
||||
assert.equal(result.blockerClassification.category, "runtime_durable");
|
||||
assert.equal(result.persistence.runtime.status, "blocked");
|
||||
assert.equal(result.persistence.runtime.liveRuntimeEvidence, false);
|
||||
assert.equal(result.persistence.writes.every((write) => write.written !== true), true);
|
||||
@@ -175,6 +212,14 @@ test("M3 DI read returns structured value through gateway-simu without patch-pan
|
||||
assert.equal(result.accepted, true);
|
||||
assert.equal(result.action, "di.read");
|
||||
assert.equal(result.command.port, "DI1");
|
||||
assert.equal(result.gatewayId, "gwsimu_2");
|
||||
assert.equal(result.gatewaySessionId, "gws_gwsimu_2");
|
||||
assert.equal(result.resourceId, "res_boxsimu_2");
|
||||
assert.equal(result.boxId, "boxsimu_2");
|
||||
assert.equal(result.port, "DI1");
|
||||
assert.equal(result.value, null);
|
||||
assert.equal(result.operationState.status, "succeeded");
|
||||
assert.equal(result.auditState.status, "written_non_durable");
|
||||
assert.equal(result.result.value, true);
|
||||
assert.equal(result.controlPath.patchPanel, false);
|
||||
assert.deepEqual(fixture.calls.map((call) => call.path), ["/status", "/invoke"]);
|
||||
@@ -212,6 +257,9 @@ test("M3 IO control blocks with Chinese reason when gateway session is unavailab
|
||||
assert.equal(result.status, "blocked");
|
||||
assert.equal(result.accepted, false);
|
||||
assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.gatewayUnavailable);
|
||||
assert.equal(result.blockerClassification.category, "gateway_session");
|
||||
assert.equal(result.auditState.status, "not_written");
|
||||
assert.equal(result.durableStatus.redacted, true);
|
||||
assert.match(result.blocker.zh, /gateway 未注册\/不可用/u);
|
||||
assert.equal(result.controlPath.cloudApi, true);
|
||||
assert.equal(result.controlPath.frontendBypass, false);
|
||||
@@ -245,6 +293,7 @@ test("M3 IO control blocks invalid port direction instead of allowing generic wr
|
||||
|
||||
assert.equal(result.status, "blocked");
|
||||
assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.portDirectionInvalid);
|
||||
assert.equal(result.blockerClassification.category, "port_direction");
|
||||
assert.match(result.blocker.zh, /端口方向|资源越界/u);
|
||||
});
|
||||
|
||||
@@ -354,9 +403,173 @@ test("M3 IO control blocks when patch-panel wiring does not deliver DO1 to DI1",
|
||||
assert.equal(result.status, "blocked");
|
||||
assert.equal(result.accepted, false);
|
||||
assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.wiringMissing);
|
||||
assert.equal(result.blockerClassification.category, "patch_panel_wiring");
|
||||
assert.match(result.blocker.zh, /wiring 不存在|未路由/u);
|
||||
});
|
||||
|
||||
test("M3 IO control blocks when patch-panel reports missing target box", async () => {
|
||||
const fixture = createM3ControlFixture({
|
||||
patchPanelBody: {
|
||||
accepted: false,
|
||||
diagnostics: [
|
||||
{
|
||||
code: "target_endpoint_missing",
|
||||
message: "target endpoint missing for res_boxsimu_2"
|
||||
}
|
||||
],
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
const result = await handleM3IoControl(
|
||||
{
|
||||
action: "do.write",
|
||||
value: true,
|
||||
traceId: "trc_m3_target_box_missing",
|
||||
requestId: "req_m3_target_box_missing",
|
||||
actorId: "usr_m3_operator"
|
||||
},
|
||||
{
|
||||
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
|
||||
now: () => fixedNow,
|
||||
env: {
|
||||
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
||||
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
||||
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
||||
},
|
||||
requestJson: fixture.requestJson
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.status, "blocked");
|
||||
assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.boxUnavailable);
|
||||
assert.equal(result.blockerClassification.category, "box_resource");
|
||||
assert.match(result.blocker.zh, /target endpoint missing/u);
|
||||
assert.equal(result.controlPath.cloudApi, true);
|
||||
assert.equal(result.controlPath.frontendBypass, false);
|
||||
});
|
||||
|
||||
test("M3 IO JSON-RPC methods route do.write and di.read through the cloud-api handler", async () => {
|
||||
const fixture = createM3ControlFixture();
|
||||
const runtimeStore = createCloudRuntimeStore({
|
||||
now: () => fixedNow
|
||||
});
|
||||
const context = {
|
||||
runtimeStore,
|
||||
now: () => fixedNow,
|
||||
env: {
|
||||
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
||||
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
||||
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
||||
},
|
||||
m3IoRequestJson: fixture.requestJson
|
||||
};
|
||||
|
||||
assert.ok(SUPPORTED_RPC_METHODS.includes(M3_IO_RPC_METHODS.doWrite));
|
||||
assert.ok(SUPPORTED_RPC_METHODS.includes(M3_IO_RPC_METHODS.diRead));
|
||||
|
||||
const write = await handleJsonRpcRequest(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: "req_m3_rpc_write_true",
|
||||
method: M3_IO_RPC_METHODS.doWrite,
|
||||
params: {
|
||||
resourceId: "res_boxsimu_1",
|
||||
boxId: "boxsimu_1",
|
||||
port: "DO1",
|
||||
value: true
|
||||
},
|
||||
meta: {
|
||||
traceId: "trc_m3_rpc_write_true",
|
||||
actorId: "usr_m3_rpc",
|
||||
serviceId: "hwlab-agent-worker",
|
||||
environment: "dev"
|
||||
}
|
||||
},
|
||||
context
|
||||
);
|
||||
|
||||
validateResponse(write);
|
||||
assert.equal(Object.hasOwn(write, "error"), false, write.error?.message);
|
||||
assert.equal(write.result.status, "succeeded");
|
||||
assert.equal(write.result.action, "do.write");
|
||||
assert.equal(write.result.traceId, "trc_m3_rpc_write_true");
|
||||
assert.equal(write.result.resourceId, "res_boxsimu_1");
|
||||
assert.equal(write.result.gatewaySessionId, "gws_gwsimu_1");
|
||||
assert.equal(write.result.port, "DO1");
|
||||
assert.equal(write.result.value, true);
|
||||
assert.match(write.result.operationId, /^op_m3_do_write_/u);
|
||||
assert.match(write.result.auditId, /^aud_m3_do_write_/u);
|
||||
assert.match(write.result.evidenceId, /^evd_m3_do_write_/u);
|
||||
assert.equal(write.result.controlPath.frontendBypass, false);
|
||||
assert.equal(write.result.evidenceState.sourceKind, "BLOCKED");
|
||||
|
||||
const read = await handleJsonRpcRequest(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: "req_m3_rpc_read_di",
|
||||
method: M3_IO_RPC_METHODS.diRead,
|
||||
params: {
|
||||
resourceId: "res_boxsimu_2",
|
||||
boxId: "boxsimu_2",
|
||||
port: "DI1"
|
||||
},
|
||||
meta: {
|
||||
traceId: "trc_m3_rpc_read_di",
|
||||
actorId: "usr_m3_rpc",
|
||||
serviceId: "hwlab-agent-worker",
|
||||
environment: "dev"
|
||||
}
|
||||
},
|
||||
context
|
||||
);
|
||||
|
||||
validateResponse(read);
|
||||
assert.equal(Object.hasOwn(read, "error"), false, read.error?.message);
|
||||
assert.equal(read.result.status, "succeeded");
|
||||
assert.equal(read.result.action, "di.read");
|
||||
assert.equal(read.result.traceId, "trc_m3_rpc_read_di");
|
||||
assert.equal(read.result.gatewayId, "gwsimu_2");
|
||||
assert.equal(read.result.gatewaySessionId, "gws_gwsimu_2");
|
||||
assert.equal(read.result.resourceId, "res_boxsimu_2");
|
||||
assert.equal(read.result.port, "DI1");
|
||||
assert.equal(read.result.result.value, true);
|
||||
assert.equal(read.result.controlPath.patchPanel, false);
|
||||
assert.equal(read.result.evidenceState.sourceKind, "BLOCKED");
|
||||
assert.equal(read.result.durableStatus.durable, false);
|
||||
});
|
||||
|
||||
test("M3 IO fixture success cannot be promoted to DEV-LIVE without durable evidence", async () => {
|
||||
const fixture = createM3ControlFixture();
|
||||
const result = await handleM3IoControl(
|
||||
{
|
||||
action: "do.write",
|
||||
value: true,
|
||||
traceId: "trc_m3_fixture_not_dev_live",
|
||||
requestId: "req_m3_fixture_not_dev_live",
|
||||
actorId: "usr_m3_operator"
|
||||
},
|
||||
{
|
||||
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
|
||||
now: () => fixedNow,
|
||||
env: {
|
||||
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
||||
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
||||
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
||||
},
|
||||
requestJson: fixture.requestJson
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.status, "succeeded");
|
||||
assert.equal(result.result.targetReadback.value, true);
|
||||
assert.equal(result.evidenceState.status, "blocked");
|
||||
assert.equal(result.evidenceState.sourceKind, "BLOCKED");
|
||||
assert.equal(result.durableStatus.durable, false);
|
||||
assert.notEqual(result.evidenceState.sourceKind, "DEV-LIVE");
|
||||
assert.notEqual(result.auditState.sourceKind, "DEV-LIVE");
|
||||
});
|
||||
|
||||
function createBlockedDurableRuntimeStore() {
|
||||
return {
|
||||
async readiness() {
|
||||
|
||||
@@ -157,7 +157,8 @@ async function handleRpcHttpRequest(request, response, options) {
|
||||
adapter: "json-rpc",
|
||||
runtimeStore: options.runtimeStore,
|
||||
env: options.env,
|
||||
dbProbe: options.dbProbe
|
||||
dbProbe: options.dbProbe,
|
||||
m3IoRequestJson: options.m3IoRequestJson
|
||||
});
|
||||
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
|
||||
}
|
||||
@@ -262,7 +263,8 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
adapter: "rest",
|
||||
runtimeStore: options.runtimeStore,
|
||||
env: options.env,
|
||||
dbProbe: options.dbProbe
|
||||
dbProbe: options.dbProbe,
|
||||
m3IoRequestJson: options.m3IoRequestJson
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1165,6 +1165,8 @@ test("cloud api /v1 exposes M3 IO control contract without generic frontend hard
|
||||
const indexPayload = await index.json();
|
||||
assert.equal(indexPayload.m3IoControl.route, "/v1/m3/io");
|
||||
assert.equal(indexPayload.m3IoControl.boundaries.directFrontendGatewayOrBoxAccess, false);
|
||||
assert.ok(indexPayload.methods.includes("m3.io.do.write"));
|
||||
assert.ok(indexPayload.methods.includes("m3.io.di.read"));
|
||||
|
||||
const contract = await fetch(`http://127.0.0.1:${port}/v1/m3/io`);
|
||||
assert.equal(contract.status, 200);
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -37,6 +37,7 @@ const m3FailureClassifications = Object.freeze({
|
||||
targetMissing: "target_missing",
|
||||
identityNotDistinct: "identity_not_distinct",
|
||||
patchPanelWiringMissing: "patch_panel_wiring_missing",
|
||||
patchPanelRouteOwnerInvalid: "patch_panel_route_owner_invalid",
|
||||
operationFailed: "operation_failed",
|
||||
evidenceMissing: "evidence_missing"
|
||||
});
|
||||
@@ -380,7 +381,8 @@ function serviceByName(services) {
|
||||
}
|
||||
|
||||
async function collectReadOnlySupplementalEvidence() {
|
||||
const [workloads, services] = await Promise.all([
|
||||
const [deploy, workloads, services] = await Promise.all([
|
||||
readFile(path.join(repoRoot, "deploy/deploy.json"), "utf8").then(JSON.parse),
|
||||
readFile(path.join(repoRoot, deployWorkloadsPath), "utf8").then(JSON.parse),
|
||||
readFile(path.join(repoRoot, deployServicesPath), "utf8").then(JSON.parse)
|
||||
]);
|
||||
@@ -403,6 +405,7 @@ async function collectReadOnlySupplementalEvidence() {
|
||||
const box = observed.find((item) => item.name === "hwlab-box-simu");
|
||||
const gateway = observed.find((item) => item.name === "hwlab-gateway-simu");
|
||||
const patchPanel = observed.find((item) => item.name === "hwlab-patch-panel");
|
||||
const patchPanelManifest = (deploy.services ?? []).find((service) => service.serviceId === "hwlab-patch-panel");
|
||||
const boxIds = splitList(box?.env.HWLAB_BOX_ID);
|
||||
const boxResourceIds = splitList(box?.env.HWLAB_BOX_RESOURCE_ID);
|
||||
const boxGatewaySessionIds = splitList(box?.env.HWLAB_GATEWAY_SESSION_ID);
|
||||
@@ -451,11 +454,11 @@ async function collectReadOnlySupplementalEvidence() {
|
||||
]);
|
||||
const patchPanelReadyForM3 =
|
||||
patchPanel?.replicas === 1 &&
|
||||
patchPanel?.m3Route?.fromResourceId === requiredM3Connection.fromResourceId &&
|
||||
patchPanel?.m3Route?.fromPort === requiredM3Connection.fromPort &&
|
||||
patchPanel?.m3Route?.patchPanelServiceId === "hwlab-patch-panel" &&
|
||||
patchPanel?.m3Route?.toResourceId === requiredM3Connection.toResourceId &&
|
||||
patchPanel?.m3Route?.toPort === requiredM3Connection.toPort &&
|
||||
patchPanelManifest?.m3Route?.fromResourceId === requiredM3Connection.fromResourceId &&
|
||||
patchPanelManifest?.m3Route?.fromPort === requiredM3Connection.fromPort &&
|
||||
patchPanelManifest?.m3Route?.patchPanelServiceId === "hwlab-patch-panel" &&
|
||||
patchPanelManifest?.m3Route?.toResourceId === requiredM3Connection.toResourceId &&
|
||||
patchPanelManifest?.m3Route?.toPort === requiredM3Connection.toPort &&
|
||||
patchPanelEndpointMap.res_boxsimu_2 === "http://hwlab-box-simu-2.hwlab-dev.svc.cluster.local:7201" &&
|
||||
patchPanelHasManifestRoute;
|
||||
const manifestReadyForM3 = boxReadyForM3 && gatewayReadyForM3 && patchPanelReadyForM3 && hasInstanceServices;
|
||||
@@ -465,7 +468,7 @@ async function collectReadOnlySupplementalEvidence() {
|
||||
id: "deploy-skeleton-m3-cardinality",
|
||||
status: manifestReadyForM3 ? "manifest-ready" : "gap",
|
||||
blockerClass: manifestReadyForM3 ? null : "runtime_blocker",
|
||||
source: [deployWorkloadsPath, deployServicesPath],
|
||||
source: ["deploy/deploy.json", deployWorkloadsPath, deployServicesPath],
|
||||
summary: manifestReadyForM3
|
||||
? "Checked-in DEV deploy skeleton declares distinct indexed M3 identities for two box simulators, two gateway simulators, one patch panel, and the M3 DO1 -> DI1 endpoint map."
|
||||
: "Read-only static comparison found the checked-in DEV deploy skeleton does not declare distinct indexed M3 simulator identities and patch-panel M3 wiring; proving live DEV still requires a separate deploy/runtime task or authorized runtime observation.",
|
||||
@@ -487,6 +490,7 @@ async function collectReadOnlySupplementalEvidence() {
|
||||
},
|
||||
"hwlab-patch-panel": {
|
||||
replicas: patchPanel?.replicas ?? null,
|
||||
firstClassRoute: patchPanelManifest?.m3Route ?? null,
|
||||
env: patchPanel?.env ?? null,
|
||||
firstClassWiringConfig: patchPanelWiringConfig
|
||||
},
|
||||
@@ -991,6 +995,12 @@ function formatConnection(connection) {
|
||||
function hasRequiredM3Connection({ status, wiring }) {
|
||||
const activeConnections = status?.activeConnections ?? [];
|
||||
const wiringConnections = wiring?.connections ?? [];
|
||||
const owner = Boolean(
|
||||
status?.serviceId === "hwlab-patch-panel" &&
|
||||
status?.metadata?.propagation === "patch-panel-only" &&
|
||||
wiring?.constraints?.propagation === "patch-panel-only" &&
|
||||
wiring?.constraints?.localLoopbackSubstituteAllowed === false
|
||||
);
|
||||
const active = activeConnections.find(
|
||||
(connection) =>
|
||||
connection.fromResourceId === requiredM3Connection.fromResourceId &&
|
||||
@@ -1006,7 +1016,7 @@ function hasRequiredM3Connection({ status, wiring }) {
|
||||
connection.to?.port === requiredM3Connection.toPort &&
|
||||
connection.mode === "exclusive"
|
||||
);
|
||||
return { active, configured };
|
||||
return { active, configured, owner };
|
||||
}
|
||||
|
||||
async function probeDirectCandidate(candidate) {
|
||||
@@ -1105,16 +1115,21 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery
|
||||
});
|
||||
}
|
||||
|
||||
if (patch && (!m3Connection.active || !m3Connection.configured)) {
|
||||
if (patch && (!m3Connection.active || !m3Connection.configured || !m3Connection.owner)) {
|
||||
const observedActive = (patch.status.json?.activeConnections ?? []).map(formatConnection).join(", ") || "none";
|
||||
const observedConfigured = (patchWiring?.json?.connections ?? []).map(formatConnection).join(", ") || "none";
|
||||
const ownerSummary = `serviceId=${patch.status.json?.serviceId ?? "unknown"} propagation=${patch.status.json?.metadata?.propagation ?? "unknown"} wiringPropagation=${patchWiring?.json?.constraints?.propagation ?? "unknown"} loopbackAllowed=${String(patchWiring?.json?.constraints?.localLoopbackSubstituteAllowed ?? "unknown")}`;
|
||||
blockers.push({
|
||||
type: "runtime_blocker",
|
||||
scope: "m3-patch-panel-wiring",
|
||||
status: "open",
|
||||
classification: classifyDirectTargetBlockerScope("m3-patch-panel-wiring"),
|
||||
classification: m3Connection.owner
|
||||
? classifyDirectTargetBlockerScope("m3-patch-panel-wiring")
|
||||
: m3FailureClassifications.patchPanelRouteOwnerInvalid,
|
||||
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}.`
|
||||
summary: m3Connection.owner
|
||||
? `DEV patch-panel is callable, but live wiring does not contain ${requiredM3Connection.fromResourceId}:${requiredM3Connection.fromPort} -> ${requiredM3Connection.toResourceId}:${requiredM3Connection.toPort}; active=${observedActive}; configured=${observedConfigured}.`
|
||||
: `DEV patch-panel route owner is not the required hwlab-patch-panel patch-panel-only owner; ${ownerSummary}; active=${observedActive}; configured=${observedConfigured}.`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1125,7 +1140,8 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery
|
||||
selectedGateways.length >= 2 &&
|
||||
patch &&
|
||||
m3Connection.active &&
|
||||
m3Connection.configured;
|
||||
m3Connection.configured &&
|
||||
m3Connection.owner;
|
||||
|
||||
const targets = new Map();
|
||||
if (ok) {
|
||||
@@ -1163,6 +1179,13 @@ async function resolveDirectM3Targets({ environmentTargets, kubernetesDiscovery
|
||||
configuredObserved: (patchWiring?.json?.connections ?? []).map(formatConnection),
|
||||
hasRequiredActiveConnection: Boolean(m3Connection.active),
|
||||
hasRequiredConfiguredConnection: Boolean(m3Connection.configured),
|
||||
hasRequiredRouteOwner: Boolean(m3Connection.owner),
|
||||
routeOwnerObserved: {
|
||||
serviceId: patch.status.json?.serviceId ?? null,
|
||||
propagation: patch.status.json?.metadata?.propagation ?? null,
|
||||
wiringPropagation: patchWiring?.json?.constraints?.propagation ?? null,
|
||||
localLoopbackSubstituteAllowed: patchWiring?.json?.constraints?.localLoopbackSubstituteAllowed ?? null
|
||||
},
|
||||
configSource: patch.status.json?.metadata?.configSource ?? "unknown",
|
||||
syncableConnectionCount: patch.status.json?.metadata?.syncableConnectionCount ?? null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -68,6 +68,44 @@ test("--dry-run aliases the same source/read-only no-write path", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("M3 report validator rejects DRY-RUN evidence promoted to live pass", async () => {
|
||||
await mkdir(path.join(repoRoot, testOutputDir), { recursive: true });
|
||||
const output = path.join(testOutputDir, "dry-run-promoted-live.json");
|
||||
const promoted = path.join(testOutputDir, "dry-run-promoted-live-tampered.json");
|
||||
try {
|
||||
runSmoke(["--dry-run", "--output", output]);
|
||||
const report = JSON.parse(await readFile(path.join(repoRoot, output), "utf8"));
|
||||
report.liveOperation = {
|
||||
status: "pass",
|
||||
operationId: "op_fake_dry_run",
|
||||
traceId: "trc_fake_dry_run",
|
||||
auditId: "aud_fake_dry_run",
|
||||
evidenceId: "evd_fake_dry_run",
|
||||
summary: "Incorrectly promoted DRY-RUN output to DEV-LIVE."
|
||||
};
|
||||
await writeFile(path.join(repoRoot, promoted), JSON.stringify(report, null, 2));
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
execFileSync("node", ["scripts/validate-dev-gate-report.mjs", promoted], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(
|
||||
`${error.message}\n${error.stderr ?? ""}`,
|
||||
/liveOperation\.status cannot be pass when only a DRY-RUN plan is present/u
|
||||
);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
await rm(path.join(repoRoot, output), { force: true });
|
||||
await rm(path.join(repoRoot, promoted), { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("M3 safety gates keep incomplete live commands source/read-only", () => {
|
||||
assert.deepEqual(requireSafetyGates(parseArgs([])), {
|
||||
mode: "dry-run",
|
||||
@@ -103,12 +141,17 @@ test("M3 blocker scopes classify missing identities and patch-panel route gaps",
|
||||
assert.equal(plan.liveWriteWillRun, false);
|
||||
assert.ok(plan.failureClassifications.includes("identity_not_distinct"));
|
||||
assert.ok(plan.failureClassifications.includes("patch_panel_wiring_missing"));
|
||||
assert.ok(plan.failureClassifications.includes("patch_panel_route_owner_invalid"));
|
||||
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", () => {
|
||||
test("patch-panel route preflight rejects missing res_boxsimu_1 DO1 to res_boxsimu_2 DI1 wiring or wrong owner", () => {
|
||||
const wrongRoute = hasRequiredM3Connection({
|
||||
status: {
|
||||
serviceId: "hwlab-patch-panel",
|
||||
metadata: {
|
||||
propagation: "patch-panel-only"
|
||||
},
|
||||
activeConnections: [
|
||||
{
|
||||
fromResourceId: "res_boxsimu_1",
|
||||
@@ -119,6 +162,10 @@ test("patch-panel route preflight rejects missing res_boxsimu_1 DO1 to res_boxsi
|
||||
]
|
||||
},
|
||||
wiring: {
|
||||
constraints: {
|
||||
propagation: "patch-panel-only",
|
||||
localLoopbackSubstituteAllowed: false
|
||||
},
|
||||
connections: [
|
||||
{
|
||||
from: { resourceId: "res_boxsimu_1", port: "DO1" },
|
||||
@@ -130,9 +177,14 @@ test("patch-panel route preflight rejects missing res_boxsimu_1 DO1 to res_boxsi
|
||||
});
|
||||
assert.equal(Boolean(wrongRoute.active), false);
|
||||
assert.equal(Boolean(wrongRoute.configured), false);
|
||||
assert.equal(Boolean(wrongRoute.owner), true);
|
||||
|
||||
const requiredRoute = hasRequiredM3Connection({
|
||||
status: {
|
||||
serviceId: "hwlab-patch-panel",
|
||||
metadata: {
|
||||
propagation: "patch-panel-only"
|
||||
},
|
||||
activeConnections: [
|
||||
{
|
||||
fromResourceId: "res_boxsimu_1",
|
||||
@@ -143,6 +195,10 @@ test("patch-panel route preflight rejects missing res_boxsimu_1 DO1 to res_boxsi
|
||||
]
|
||||
},
|
||||
wiring: {
|
||||
constraints: {
|
||||
propagation: "patch-panel-only",
|
||||
localLoopbackSubstituteAllowed: false
|
||||
},
|
||||
connections: [
|
||||
{
|
||||
from: { resourceId: "res_boxsimu_1", port: "DO1" },
|
||||
@@ -154,6 +210,33 @@ test("patch-panel route preflight rejects missing res_boxsimu_1 DO1 to res_boxsi
|
||||
});
|
||||
assert.equal(Boolean(requiredRoute.active), true);
|
||||
assert.equal(Boolean(requiredRoute.configured), true);
|
||||
assert.equal(Boolean(requiredRoute.owner), true);
|
||||
|
||||
const wrongOwner = hasRequiredM3Connection({
|
||||
status: {
|
||||
serviceId: "hwlab-box-simu",
|
||||
metadata: {
|
||||
propagation: "box-loopback"
|
||||
},
|
||||
activeConnections: requiredRoute.active ? [requiredRoute.active] : []
|
||||
},
|
||||
wiring: {
|
||||
constraints: {
|
||||
propagation: "patch-panel-only",
|
||||
localLoopbackSubstituteAllowed: false
|
||||
},
|
||||
connections: [
|
||||
{
|
||||
from: { resourceId: "res_boxsimu_1", port: "DO1" },
|
||||
to: { resourceId: "res_boxsimu_2", port: "DI1" },
|
||||
mode: "exclusive"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
assert.equal(Boolean(wrongOwner.active), true);
|
||||
assert.equal(Boolean(wrongOwner.configured), true);
|
||||
assert.equal(Boolean(wrongOwner.owner), false);
|
||||
});
|
||||
|
||||
test("Kubernetes discovery preserves current indexed M3 service identities", () => {
|
||||
|
||||
@@ -224,8 +224,9 @@ export async function buildM3IoControlSourceReport(options = {}) {
|
||||
}
|
||||
|
||||
export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } = {}) {
|
||||
const [serverSource, m3Source, appSource, htmlSource, artifactPublisherSource] = await Promise.all([
|
||||
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"),
|
||||
@@ -278,6 +279,18 @@ export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } =
|
||||
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",
|
||||
@@ -416,7 +429,14 @@ export function validateM3IoSourceResponseContract(m3Source) {
|
||||
/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,
|
||||
/auditState:\s*auditState\(/u,
|
||||
/evidenceState:\s*evidenceState\(/u,
|
||||
/durableStatus:\s*durableStatus\(/u,
|
||||
/blockerClassification:\s*redactedBlockerClassification\(/u,
|
||||
/persistence:\s*persistenceSummary\(/u,
|
||||
/frontendBypass:\s*false/u,
|
||||
/patchPanelOnlyPropagation:\s*true/u,
|
||||
|
||||
@@ -2644,6 +2644,20 @@ async function validateDevM3Report(report, label) {
|
||||
for (const field of ["operationId", "traceId", "auditId", "evidenceId", "summary"]) {
|
||||
assertString(report.liveOperation[field], `${label}.liveOperation.${field}`);
|
||||
}
|
||||
if (Object.hasOwn(report, "dryRunPlan")) {
|
||||
assert.notEqual(
|
||||
report.liveOperation.status,
|
||||
"pass",
|
||||
`${label}.liveOperation.status cannot be pass when only a DRY-RUN plan is present`
|
||||
);
|
||||
for (const field of ["operationId", "traceId", "auditId", "evidenceId"]) {
|
||||
assert.equal(
|
||||
report.liveOperation[field],
|
||||
"not_observed",
|
||||
`${label}.liveOperation.${field} must remain not_observed for DRY-RUN evidence`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertArray(report.blockers, `${label}.blockers`);
|
||||
assertUnique(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const namespace = "hwlab-dev";
|
||||
@@ -301,32 +301,50 @@ function assertM3RouteConfig(value, label) {
|
||||
assert.equal(value.constraints.localLoopbackSubstituteAllowed, false, `${label} loopback substitute`);
|
||||
}
|
||||
|
||||
const [deploy, workloads, services, topology] = await Promise.all([
|
||||
readJson("deploy/deploy.json"),
|
||||
readJson("deploy/k8s/base/workloads.yaml"),
|
||||
readJson("deploy/k8s/base/services.yaml"),
|
||||
readJson("fixtures/mvp/m3-hardware-loop/topology.json")
|
||||
]);
|
||||
export async function readDevM3CardinalityInputs() {
|
||||
const [deploy, workloads, services, topology] = await Promise.all([
|
||||
readJson("deploy/deploy.json"),
|
||||
readJson("deploy/k8s/base/workloads.yaml"),
|
||||
readJson("deploy/k8s/base/services.yaml"),
|
||||
readJson("fixtures/mvp/m3-hardware-loop/topology.json")
|
||||
]);
|
||||
|
||||
const patchPanelWorkloadEnv = envMap(
|
||||
workloadByName(workloads)
|
||||
.get("hwlab-patch-panel")
|
||||
?.spec?.template?.spec?.containers?.find((item) => item.name === "hwlab-patch-panel")
|
||||
?.env
|
||||
);
|
||||
return { deploy, workloads, services, topology };
|
||||
}
|
||||
|
||||
assertDeployManifestCardinality(deploy);
|
||||
assertDeployManifestInstanceMappings(deploy);
|
||||
assertDevK8sCardinality(workloads);
|
||||
assertDevK8sInstanceServices(services);
|
||||
assertM3FixtureWiring(topology);
|
||||
assertM3RouteConfig(
|
||||
parseEnvValue(patchPanelWorkloadEnv.HWLAB_PATCH_PANEL_WIRING_CONFIG),
|
||||
"DEV workload first-class M3 patch-panel wiring config"
|
||||
);
|
||||
assertM3RouteConfig(
|
||||
parseEnvValue(deploy.services.find((service) => service.serviceId === "hwlab-patch-panel")?.env?.HWLAB_PATCH_PANEL_WIRING_CONFIG),
|
||||
"deploy manifest first-class M3 patch-panel wiring config"
|
||||
);
|
||||
export function validateDevM3Cardinality({ deploy, workloads, services, topology }) {
|
||||
const patchPanelWorkloadEnv = envMap(
|
||||
workloadByName(workloads)
|
||||
.get("hwlab-patch-panel")
|
||||
?.spec?.template?.spec?.containers?.find((item) => item.name === "hwlab-patch-panel")
|
||||
?.env
|
||||
);
|
||||
|
||||
console.log("validated DEV M3 manifest cardinality: 2 distinct box-simu, 2 distinct gateway-simu, 1 patch-panel, M3 DO1->DI1 wiring");
|
||||
assertDeployManifestCardinality(deploy);
|
||||
assertDeployManifestInstanceMappings(deploy);
|
||||
assertDevK8sCardinality(workloads);
|
||||
assertDevK8sInstanceServices(services);
|
||||
assertM3FixtureWiring(topology);
|
||||
assertM3RouteConfig(
|
||||
parseEnvValue(patchPanelWorkloadEnv.HWLAB_PATCH_PANEL_WIRING_CONFIG),
|
||||
"DEV workload first-class M3 patch-panel wiring config"
|
||||
);
|
||||
assertM3RouteConfig(
|
||||
parseEnvValue(deploy.services.find((service) => service.serviceId === "hwlab-patch-panel")?.env?.HWLAB_PATCH_PANEL_WIRING_CONFIG),
|
||||
"deploy manifest first-class M3 patch-panel wiring config"
|
||||
);
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
validateDevM3Cardinality(await readDevM3CardinalityInputs());
|
||||
console.log("validated DEV M3 manifest cardinality: 2 distinct box-simu, 2 distinct gateway-simu, 1 patch-panel, M3 DO1->DI1 wiring");
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
readDevM3CardinalityInputs,
|
||||
validateDevM3Cardinality
|
||||
} from "./validate-dev-m3-cardinality.mjs";
|
||||
|
||||
const baseline = await readDevM3CardinalityInputs();
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function cloneInputs() {
|
||||
return clone(baseline);
|
||||
}
|
||||
|
||||
function deployService(inputs, serviceId) {
|
||||
return inputs.deploy.services.find((service) => service.serviceId === serviceId);
|
||||
}
|
||||
|
||||
function workloadContainer(inputs, workloadName) {
|
||||
return inputs.workloads.items
|
||||
.find((item) => item.metadata?.name === workloadName)
|
||||
?.spec?.template?.spec?.containers?.find((container) => container.name === workloadName);
|
||||
}
|
||||
|
||||
function setWorkloadEnv(inputs, workloadName, envName, value) {
|
||||
const entry = workloadContainer(inputs, workloadName)?.env?.find((item) => item.name === envName);
|
||||
assert.ok(entry, `${workloadName} ${envName} exists`);
|
||||
entry.value = value;
|
||||
}
|
||||
|
||||
function assertRejected(inputs, pattern) {
|
||||
assert.throws(() => validateDevM3Cardinality(inputs), pattern);
|
||||
}
|
||||
|
||||
test("accepts the checked-in DEV M3 minimum topology", () => {
|
||||
validateDevM3Cardinality(cloneInputs());
|
||||
});
|
||||
|
||||
test("rejects duplicate box-simu resource identity in deploy source", () => {
|
||||
const inputs = cloneInputs();
|
||||
deployService(inputs, "hwlab-box-simu").env.HWLAB_BOX_RESOURCE_ID = "res_boxsimu_1,res_boxsimu_1";
|
||||
|
||||
assertRejected(inputs, /hwlab-box-simu deploy HWLAB_BOX_RESOURCE_ID/u);
|
||||
});
|
||||
|
||||
test("rejects duplicate gateway-simu identity in Kubernetes source", () => {
|
||||
const inputs = cloneInputs();
|
||||
setWorkloadEnv(inputs, "hwlab-gateway-simu", "HWLAB_GATEWAY_ID", "gwsimu_1,gwsimu_1");
|
||||
|
||||
assertRejected(inputs, /hwlab-gateway-simu HWLAB_GATEWAY_ID/u);
|
||||
});
|
||||
|
||||
test("rejects duplicate gateway session assignment for box-simu resources", () => {
|
||||
const inputs = cloneInputs();
|
||||
deployService(inputs, "hwlab-box-simu").env.HWLAB_GATEWAY_SESSION_ID = "gws_gwsimu_1,gws_gwsimu_1";
|
||||
|
||||
assertRejected(inputs, /hwlab-box-simu deploy HWLAB_GATEWAY_SESSION_ID/u);
|
||||
});
|
||||
|
||||
test("rejects missing M3 patch-panel route", () => {
|
||||
const inputs = cloneInputs();
|
||||
const patchPanel = deployService(inputs, "hwlab-patch-panel");
|
||||
const wiringConfig = JSON.parse(patchPanel.env.HWLAB_PATCH_PANEL_WIRING_CONFIG);
|
||||
wiringConfig.connections = [];
|
||||
patchPanel.env.HWLAB_PATCH_PANEL_WIRING_CONFIG = JSON.stringify(wiringConfig);
|
||||
|
||||
assertRejected(inputs, /hwlab-patch-panel deploy HWLAB_PATCH_PANEL_WIRING_CONFIG/u);
|
||||
});
|
||||
|
||||
test("rejects wrong first-class route owner", () => {
|
||||
const inputs = cloneInputs();
|
||||
deployService(inputs, "hwlab-patch-panel").m3Route.patchPanelServiceId = "hwlab-box-simu";
|
||||
|
||||
assertRejected(inputs, /hwlab-patch-panel deploy first-class M3 route/u);
|
||||
});
|
||||
Reference in New Issue
Block a user