372 lines
9.8 KiB
JavaScript
372 lines
9.8 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createAuditEvent, createEvidenceRecord, operationContext } from "../../internal/sim/l2-runtime.mjs";
|
|
import {
|
|
createGatewayState,
|
|
createHealth,
|
|
resourceIdForBox,
|
|
selectInstanceValue
|
|
} from "../../internal/sim/model.mjs";
|
|
import { fetchJson, createJsonServer, listen, parsePort, readJson } from "../../internal/sim/http.mjs";
|
|
import { createRpcErrorBody, ERROR_CODES } from "../../internal/protocol/index.mjs";
|
|
|
|
const gatewayId = selectInstanceValue(process.env.HWLAB_GATEWAY_ID, "gateway-a");
|
|
const port = parsePort(process.env.PORT, 7101);
|
|
const endpoint = `http://127.0.0.1:${port}`;
|
|
const boxUrls = parseBoxUrls(process.env.HWLAB_BOX_URLS);
|
|
const boxes = parseBoxes({
|
|
resources: process.env.HWLAB_BOX_RESOURCES,
|
|
ids: process.env.HWLAB_BOX_IDS,
|
|
urls: boxUrls
|
|
});
|
|
const state = createGatewayState({
|
|
gatewayId,
|
|
endpoint,
|
|
boxes: boxes.map((box) => box.resourceId)
|
|
});
|
|
state.registry = {
|
|
gatewayId,
|
|
gatewaySessionId: state.session.gatewaySessionId,
|
|
endpoint,
|
|
boxes
|
|
};
|
|
state.lastHeartbeatAt = state.session.lastSeenAt;
|
|
|
|
const routes = new Map();
|
|
|
|
routes.set("GET /health/live", () =>
|
|
({
|
|
...createHealth({ serviceId: "hwlab-gateway-simu", name: gatewayId }),
|
|
gatewayId,
|
|
gatewaySessionId: state.session.gatewaySessionId
|
|
})
|
|
);
|
|
routes.set("GET /status", () => state);
|
|
routes.set("GET /boxes", () => ({
|
|
gatewayId,
|
|
gatewaySessionId: state.session.gatewaySessionId,
|
|
boxes: state.registry.boxes
|
|
}));
|
|
routes.set("POST /register", async ({ req }) => {
|
|
const body = await readJson(req);
|
|
const now = new Date().toISOString();
|
|
const registered = normalizeBoxRegistration(body);
|
|
upsertBox(registered);
|
|
state.boxes = state.registry.boxes.map((box) => box.resourceId);
|
|
state.updatedAt = now;
|
|
state.session.lastSeenAt = now;
|
|
|
|
return {
|
|
accepted: true,
|
|
serviceId: "hwlab-gateway-simu",
|
|
gatewayId,
|
|
gatewaySessionId: state.session.gatewaySessionId,
|
|
registered,
|
|
registry: state.registry
|
|
};
|
|
});
|
|
routes.set("POST /heartbeat", async ({ req }) => {
|
|
const body = await readJson(req);
|
|
const now = new Date().toISOString();
|
|
state.lastHeartbeatAt = now;
|
|
state.session.lastSeenAt = now;
|
|
state.updatedAt = now;
|
|
|
|
return {
|
|
accepted: true,
|
|
serviceId: "hwlab-gateway-simu",
|
|
gatewayId,
|
|
gatewaySessionId: state.session.gatewaySessionId,
|
|
heartbeat: {
|
|
observedAt: now,
|
|
boxCount: state.registry.boxes.length,
|
|
reportedBy: body.reportedBy ?? gatewayId
|
|
}
|
|
};
|
|
});
|
|
routes.set("GET /capabilities", async () => capabilityReport());
|
|
routes.set("POST /capabilities/report", async () => capabilityReport());
|
|
routes.set("POST /invoke", async ({ req }) => {
|
|
const body = await readJson(req);
|
|
return invokeBox(body);
|
|
});
|
|
|
|
function rpcHttpError(statusCode, { code, message, data = {} }) {
|
|
return Object.assign(new Error(message), {
|
|
statusCode,
|
|
body: createRpcErrorBody({
|
|
code,
|
|
message,
|
|
data
|
|
})
|
|
});
|
|
}
|
|
|
|
function parseBoxUrls(value) {
|
|
return String(value ?? "")
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseList(value) {
|
|
return String(value ?? "")
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseBoxes({ resources, ids, urls }) {
|
|
const explicitResources = parseList(resources);
|
|
const explicitIds = parseList(ids);
|
|
const count = Math.max(explicitResources.length, explicitIds.length, urls.length, 1);
|
|
const parsed = [];
|
|
|
|
for (let index = 0; index < count; index += 1) {
|
|
const boxId =
|
|
explicitIds[index] ??
|
|
explicitResources[index]?.replace(/^res_/, "") ??
|
|
(count === 1 ? "boxsim_alpha" : `box-${index + 1}`);
|
|
const resourceId = explicitResources[index] ?? resourceIdForBox(boxId);
|
|
parsed.push({
|
|
boxId,
|
|
resourceId,
|
|
url: urls[index] ?? null,
|
|
state: "registered"
|
|
});
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function normalizeBoxRegistration(body) {
|
|
const boxId = body.boxId ?? body.identity?.boxId;
|
|
const resourceId = body.resourceId ?? body.resource?.resourceId ?? (boxId ? resourceIdForBox(boxId) : null);
|
|
if (!boxId || !resourceId) {
|
|
throw rpcHttpError(400, {
|
|
code: ERROR_CODES.invalidParams,
|
|
message: "register requires boxId or resourceId",
|
|
data: {
|
|
reason: "identity_required"
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
boxId,
|
|
resourceId,
|
|
url: body.url ?? body.endpoint ?? null,
|
|
state: "registered",
|
|
capabilities: Array.isArray(body.capabilities) ? body.capabilities : undefined,
|
|
registeredAt: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
function upsertBox(box) {
|
|
const index = state.registry.boxes.findIndex((item) => item.resourceId === box.resourceId);
|
|
if (index >= 0) {
|
|
state.registry.boxes[index] = {
|
|
...state.registry.boxes[index],
|
|
...box
|
|
};
|
|
return;
|
|
}
|
|
state.registry.boxes.push(box);
|
|
}
|
|
|
|
async function capabilityReport() {
|
|
const reports = [];
|
|
for (const box of state.registry.boxes) {
|
|
if (box.url) {
|
|
try {
|
|
const response = await fetchJson(new URL("/capabilities", box.url));
|
|
if (response.ok) {
|
|
const body = response.body;
|
|
const capabilities = body?.capabilities ?? [];
|
|
upsertBox({
|
|
...box,
|
|
boxId: body?.boxId ?? box.boxId,
|
|
resourceId: body?.resourceId ?? box.resourceId,
|
|
capabilities
|
|
});
|
|
reports.push({
|
|
boxId: body?.boxId ?? box.boxId,
|
|
resourceId: body?.resourceId ?? box.resourceId,
|
|
url: box.url,
|
|
status: "available",
|
|
capabilities
|
|
});
|
|
continue;
|
|
}
|
|
} catch (error) {
|
|
reports.push({
|
|
boxId: box.boxId,
|
|
resourceId: box.resourceId,
|
|
url: box.url,
|
|
status: "unreachable",
|
|
capabilities: box.capabilities ?? [],
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
reports.push({
|
|
boxId: box.boxId,
|
|
resourceId: box.resourceId,
|
|
url: box.url,
|
|
status: box.capabilities ? "cached" : "unreachable",
|
|
capabilities: box.capabilities ?? []
|
|
});
|
|
}
|
|
|
|
state.capabilityReport = {
|
|
serviceId: "hwlab-gateway-simu",
|
|
gatewayId,
|
|
gatewaySessionId: state.session.gatewaySessionId,
|
|
reportedAt: new Date().toISOString(),
|
|
boxes: reports
|
|
};
|
|
|
|
return state.capabilityReport;
|
|
}
|
|
|
|
function findBoxTarget(body) {
|
|
const resourceId = body.resourceId;
|
|
const boxId = body.boxId;
|
|
return state.registry.boxes.find(
|
|
(box) => (resourceId && box.resourceId === resourceId) || (boxId && box.boxId === boxId)
|
|
);
|
|
}
|
|
|
|
async function invokeBox(body) {
|
|
const box = findBoxTarget(body);
|
|
if (!box) {
|
|
throw rpcHttpError(404, {
|
|
code: ERROR_CODES.sessionNotFound,
|
|
message: "Target box is not registered with this gateway",
|
|
data: {
|
|
gatewayId,
|
|
gatewaySessionId: state.session.gatewaySessionId,
|
|
boxId: body.boxId ?? null,
|
|
resourceId: body.resourceId ?? null
|
|
}
|
|
});
|
|
}
|
|
if (!box.url) {
|
|
throw rpcHttpError(409, {
|
|
code: ERROR_CODES.operationRejected,
|
|
message: "Target box has no invoke URL",
|
|
data: {
|
|
gatewayId,
|
|
resourceId: box.resourceId,
|
|
reason: "box_url_missing"
|
|
}
|
|
});
|
|
}
|
|
|
|
const context = operationContext(body, {
|
|
serviceId: "hwlab-gateway-simu",
|
|
actorId: "svc_hwlab-gateway-simu"
|
|
});
|
|
const forwarded = {
|
|
...body,
|
|
operationId: context.operationId,
|
|
traceId: context.traceId,
|
|
actorType: "service",
|
|
actorId: "svc_hwlab-gateway-simu",
|
|
source: body.source ?? "gateway-simu"
|
|
};
|
|
const response = await fetchJson(new URL("/invoke", box.url), {
|
|
method: "POST",
|
|
body: forwarded
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw Object.assign(new Error("box invoke failed"), {
|
|
statusCode: response.status,
|
|
body:
|
|
response.body ??
|
|
createRpcErrorBody({
|
|
code: ERROR_CODES.hwlabUnknown,
|
|
message: "Box invoke failed",
|
|
data: {
|
|
status: response.status
|
|
}
|
|
})
|
|
});
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const audit = createAuditEvent({
|
|
serviceId: "hwlab-gateway-simu",
|
|
action: "hardware.operation.dispatched",
|
|
targetType: "box_resource",
|
|
targetId: box.resourceId,
|
|
projectId: state.projectId,
|
|
gatewaySessionId: state.session.gatewaySessionId,
|
|
operationId: context.operationId,
|
|
traceId: context.traceId,
|
|
actorType: "service",
|
|
actorId: "svc_hwlab-gateway-simu",
|
|
outcome: "succeeded",
|
|
metadata: {
|
|
gatewayId,
|
|
boxId: box.boxId,
|
|
resourceId: box.resourceId,
|
|
capabilityId: body.capabilityId ?? null,
|
|
port: body.port ?? null,
|
|
forwardedTo: box.url
|
|
},
|
|
occurredAt: now
|
|
});
|
|
const evidence = createEvidenceRecord({
|
|
serviceId: "hwlab-gateway-simu",
|
|
projectId: state.projectId,
|
|
operationId: context.operationId,
|
|
traceId: context.traceId,
|
|
kind: "trace",
|
|
payload: {
|
|
gatewayId,
|
|
boxId: box.boxId,
|
|
resourceId: box.resourceId,
|
|
result: response.body
|
|
},
|
|
metadata: {
|
|
gatewaySessionId: state.session.gatewaySessionId,
|
|
forwardedTo: box.url,
|
|
producerServiceId: "hwlab-gateway-simu"
|
|
},
|
|
createdAt: now
|
|
});
|
|
|
|
state.lastDispatch = {
|
|
operationId: context.operationId,
|
|
traceId: context.traceId,
|
|
resourceId: box.resourceId,
|
|
observedAt: now,
|
|
accepted: true
|
|
};
|
|
state.updatedAt = now;
|
|
|
|
return {
|
|
accepted: true,
|
|
serviceId: "hwlab-gateway-simu",
|
|
gatewayId,
|
|
gatewaySessionId: state.session.gatewaySessionId,
|
|
operationId: context.operationId,
|
|
traceId: context.traceId,
|
|
target: {
|
|
boxId: box.boxId,
|
|
resourceId: box.resourceId,
|
|
url: box.url
|
|
},
|
|
result: response.body,
|
|
auditId: audit.auditId,
|
|
evidenceId: evidence.evidenceId,
|
|
audit,
|
|
evidence
|
|
};
|
|
}
|
|
|
|
listen(createJsonServer({ routes }), port);
|