feat: add cloud api runtime smoke
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# Cloud API Runtime
|
||||
|
||||
The L1 `hwlab-cloud-api` runtime is a minimal local HTTP/JSON-RPC service. It
|
||||
does not deploy to DEV or PROD and does not claim live database persistence.
|
||||
|
||||
## Local Smoke
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
node scripts/cloud-api-runtime-smoke.mjs
|
||||
```
|
||||
|
||||
The smoke starts `cmd/hwlab-cloud-api` on `127.0.0.1` with an ephemeral port
|
||||
and verifies:
|
||||
|
||||
- `GET /health` reports `status: "degraded"` when live DB persistence is not
|
||||
connected.
|
||||
- The DB contract reports required env names and redacted Secret refs without
|
||||
exposing secret values.
|
||||
- `gateway.session.register`, `box.resource.register`,
|
||||
`box.capability.report`, `hardware.invoke.shell`, `audit.event.query`, and
|
||||
`evidence.record.query` round-trip through the same runtime store.
|
||||
- Shell invoke requests are accepted as recorded cloud operations but report
|
||||
`dispatchStatus: "not_connected"` until a real gateway shell adapter exists.
|
||||
- Audit and evidence records use the protocol schema field names.
|
||||
|
||||
## Persistence Boundary
|
||||
|
||||
The current runtime store is process-local memory:
|
||||
|
||||
- `runtime.adapter: "memory"`
|
||||
- `runtime.durable: false`
|
||||
- `runtime.status: "degraded"`
|
||||
|
||||
This is intentional for L1. If `HWLAB_CLOUD_DB_URL` and
|
||||
`HWLAB_CLOUD_DB_SSL_MODE` are absent, health reports `db.status: "blocked"`.
|
||||
If both env vars are present, health reports `db.status: "degraded"` and still
|
||||
keeps `db.connected: false`, `db.ready: false`, and `db.connectionChecked:
|
||||
false`; env presence is not live DB evidence.
|
||||
|
||||
Fixture: `fixtures/cloud-api-runtime/minimal-runtime.json`.
|
||||
@@ -36,7 +36,9 @@ Required DEV DB config names:
|
||||
|
||||
When the Secret/env is absent, the health JSON should keep
|
||||
`db.connected: false`, set `db.status: "blocked"`, and list the missing env
|
||||
names so gate reports can identify `cloud-api-db-health-gate`.
|
||||
names so gate reports can identify `cloud-api-db-health-gate`. When the env is
|
||||
present but no live DB probe is connected, `db.status` remains `"degraded"` and
|
||||
`db.ready` remains `false`; env presence alone is not live DB evidence.
|
||||
|
||||
## Route Classification
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# M1 Local Contract Smoke
|
||||
|
||||
The M1 local smoke is a fast contract check for the current HWLAB MVP
|
||||
skeleton. It runs only on the local machine, uses short-lived localhost
|
||||
runtime. It runs only on the local machine, uses short-lived localhost
|
||||
processes, and does not deploy or call the frozen DEV endpoint.
|
||||
|
||||
Run it from the repository root:
|
||||
@@ -20,9 +20,10 @@ npm run m1:smoke
|
||||
|
||||
- Starts `hwlab-cloud-api` on `127.0.0.1` with an ephemeral port and checks
|
||||
`/health`, `/live`, `GET /v1`, and `POST /rpc`.
|
||||
- Verifies the cloud-api skeleton returns `system.health`, describes the REST
|
||||
and JSON-RPC adapter surface, and rejects `hardware.operation.request` as a
|
||||
known skeleton response instead of dispatching real hardware work.
|
||||
- Verifies the cloud-api runtime returns degraded `system.health` when DB
|
||||
persistence is not connected, describes the REST and JSON-RPC adapter
|
||||
surface, registers a gateway/box/capability, and records an accepted
|
||||
`hardware.operation.request` in process-local memory.
|
||||
- Starts `hwlab-gateway-simu`, `hwlab-box-simu`, and `hwlab-patch-panel` on
|
||||
ephemeral localhost ports.
|
||||
- Checks simulator health and status payloads, confirms cross-device
|
||||
@@ -45,9 +46,9 @@ This smoke is not the future DEV e2e acceptance test. It intentionally does not:
|
||||
- replace the L5 deploy manifest work.
|
||||
|
||||
The check is scoped to local M1 contract readiness: existing cloud-api,
|
||||
patch-panel, simulator, and CLI dry-run skeletons can be started or parsed and
|
||||
still agree on the frozen service IDs, DEV environment, JSON-RPC surface, and
|
||||
patch-panel-only topology assumptions.
|
||||
patch-panel, simulator, and CLI dry-run runtime paths can be started or parsed
|
||||
and still agree on the frozen service IDs, DEV environment, JSON-RPC surface,
|
||||
DB degradation contract, and patch-panel-only topology assumptions.
|
||||
|
||||
## Follow-On DEV e2e
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ reason.
|
||||
| Milestone | Operator Gate | Required Acceptance |
|
||||
| --- | --- | --- |
|
||||
| M0 contract audit | Source-only | `docs/m0-contract-audit.md`, protocol docs, schemas, deploy schema, and M0 examples agree on service IDs, JSON-RPC envelope, errors, audit fields, topology, and DEV-only deploy shape. |
|
||||
| M1 local contract smoke | Local smoke | `npm run m1:smoke` or `node scripts/m1-contract-smoke.mjs` passes on localhost and confirms skeleton services, simulator contracts, patch-panel routing, CLI dry-run boundary, and no DEV/PROD changes. |
|
||||
| M1 local contract smoke | Local smoke | `npm run m1:smoke` or `node scripts/m1-contract-smoke.mjs` passes on localhost and confirms the local cloud-api runtime, simulator contracts, patch-panel routing, CLI dry-run boundary, and no DEV/PROD changes. |
|
||||
| M2 DEV deploy/runtime smoke | Dry-run, then authorized DEV runtime smoke | Source/manifests and `docs/dev-acceptance-matrix.md` identify the DEV route through edge, `frp`, and D601 router. Real DEV observation is read-only and must not be performed without commander authorization. |
|
||||
| M3 hardware trusted loop | Local smoke, then MVP e2e | `node scripts/m3-hardware-loop-smoke.mjs` proves the local `box-simu-1 DO1 -> box-simu-2 DI1` patch-panel contract. Real DEV acceptance must preserve patch-panel ownership and produce trace, audit, and evidence records. |
|
||||
| M4 agent automation loop | Source-only or dry-run, then MVP e2e | Agent manager, worker, skills, session lifecycle, scoped workspace, trace events, cleanup, and evidence chain are observable in fixtures or dry-run before any DEV runtime action. |
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"contract": "cloud-api-runtime-smoke",
|
||||
"environment": "dev",
|
||||
"projectId": "prj_cloud-api-runtime-smoke",
|
||||
"gatewaySession": {
|
||||
"gatewaySessionId": "gws_cloud-api-runtime-smoke",
|
||||
"projectId": "prj_cloud-api-runtime-smoke",
|
||||
"serviceId": "hwlab-gateway-simu",
|
||||
"gatewayId": "gtw_cloud-api-runtime-smoke",
|
||||
"endpoint": "http://127.0.0.1:7101",
|
||||
"status": "connected",
|
||||
"environment": "dev",
|
||||
"startedAt": "2026-05-21T00:00:00.000Z",
|
||||
"lastSeenAt": "2026-05-21T00:00:00.000Z",
|
||||
"labels": {
|
||||
"fixture": "cloud-api-runtime"
|
||||
}
|
||||
},
|
||||
"resource": {
|
||||
"resourceId": "res_cloud-api-runtime-smoke",
|
||||
"projectId": "prj_cloud-api-runtime-smoke",
|
||||
"gatewaySessionId": "gws_cloud-api-runtime-smoke",
|
||||
"boxId": "box_cloud-api-runtime-smoke",
|
||||
"resourceType": "board",
|
||||
"name": "cloud api runtime smoke board",
|
||||
"state": "available",
|
||||
"environment": "dev",
|
||||
"metadata": {
|
||||
"ports": ["shell0"]
|
||||
},
|
||||
"createdAt": "2026-05-21T00:00:00.000Z",
|
||||
"updatedAt": "2026-05-21T00:00:00.000Z"
|
||||
},
|
||||
"capability": {
|
||||
"capabilityId": "cap_cloud-api-runtime-smoke",
|
||||
"resourceId": "res_cloud-api-runtime-smoke",
|
||||
"projectId": "prj_cloud-api-runtime-smoke",
|
||||
"name": "shell.exec",
|
||||
"description": "Record a shell invoke request through the L1 cloud API runtime.",
|
||||
"direction": "bidirectional",
|
||||
"valueType": "object",
|
||||
"constraints": {
|
||||
"dispatch": "degraded-not-connected"
|
||||
},
|
||||
"mutatesState": true,
|
||||
"createdAt": "2026-05-21T00:00:00.000Z",
|
||||
"updatedAt": "2026-05-21T00:00:00.000Z"
|
||||
},
|
||||
"expected": {
|
||||
"healthStatus": "degraded",
|
||||
"dbStatusWhenEnvMissing": "blocked",
|
||||
"runtimeAdapter": "memory",
|
||||
"runtimeDurable": false,
|
||||
"shellDispatchStatus": "not_connected",
|
||||
"auditActions": [
|
||||
"gateway.session.register",
|
||||
"box.resource.register",
|
||||
"box.capability.report",
|
||||
"hardware.operation.request",
|
||||
"hardware.invoke.shell"
|
||||
],
|
||||
"evidenceKind": "trace"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
|
||||
import {
|
||||
ENVIRONMENT_DEV,
|
||||
assertProtocolRecord,
|
||||
isFrozenServiceId
|
||||
} from "../protocol/index.mjs";
|
||||
|
||||
export const CLOUD_API_SERVICE_ID = "hwlab-cloud-api";
|
||||
|
||||
@@ -12,6 +16,19 @@ export const AUDIT_FIELD_NAMES = Object.freeze([
|
||||
"timestamp"
|
||||
]);
|
||||
|
||||
export const PROTOCOL_AUDIT_FIELD_NAMES = Object.freeze([
|
||||
"auditId",
|
||||
"traceId",
|
||||
"actorType",
|
||||
"actorId",
|
||||
"action",
|
||||
"targetType",
|
||||
"targetId",
|
||||
"serviceId",
|
||||
"environment",
|
||||
"occurredAt"
|
||||
]);
|
||||
|
||||
export function createAuditRecord(input = {}) {
|
||||
const record = {
|
||||
requestId: asString(input.requestId, "req_unassigned"),
|
||||
@@ -27,6 +44,34 @@ export function createAuditRecord(input = {}) {
|
||||
return record;
|
||||
}
|
||||
|
||||
export function createProtocolAuditEvent(input = {}) {
|
||||
const event = {
|
||||
auditId: asString(input.auditId, `aud_${cryptoRandomId()}`),
|
||||
traceId: asString(input.traceId, `trc_${cryptoRandomId()}`),
|
||||
actorType: normalizeActorType(input.actorType),
|
||||
actorId: asString(input.actorId, `system_${CLOUD_API_SERVICE_ID}`),
|
||||
action: asString(input.action, "cloud_api.request"),
|
||||
targetType: asString(input.targetType, "cloud_api"),
|
||||
targetId: asString(input.targetId, `svc_${CLOUD_API_SERVICE_ID}`),
|
||||
serviceId: isFrozenServiceId(input.serviceId) ? input.serviceId : CLOUD_API_SERVICE_ID,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
occurredAt: asString(input.occurredAt, new Date().toISOString())
|
||||
};
|
||||
|
||||
copyOptional(event, input, [
|
||||
"projectId",
|
||||
"gatewaySessionId",
|
||||
"workerSessionId",
|
||||
"operationId",
|
||||
"outcome",
|
||||
"reason",
|
||||
"metadata"
|
||||
]);
|
||||
|
||||
assertProtocolRecord("auditEvent", event);
|
||||
return event;
|
||||
}
|
||||
|
||||
export function deriveActorFromMeta(meta = {}) {
|
||||
return {
|
||||
type: meta.actorType || "system",
|
||||
@@ -34,6 +79,13 @@ export function deriveActorFromMeta(meta = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveProtocolActorFromMeta(meta = {}, fallback = {}) {
|
||||
return {
|
||||
actorType: normalizeActorType(fallback.actorType || meta.actorType || "service"),
|
||||
actorId: asString(fallback.actorId || meta.actorId, `system_${CLOUD_API_SERVICE_ID}`)
|
||||
};
|
||||
}
|
||||
|
||||
export function assertAuditFields(record) {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
||||
throw new Error("audit record must be an object");
|
||||
@@ -143,3 +195,23 @@ function asString(value, fallback) {
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function normalizeActorType(value) {
|
||||
const actorType = asString(value, "system");
|
||||
if (["user", "service", "agent", "worker", "system"].includes(actorType)) {
|
||||
return actorType;
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
function copyOptional(target, source, fields) {
|
||||
for (const field of fields) {
|
||||
if (source[field] !== undefined && source[field] !== null) {
|
||||
target[field] = source[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cryptoRandomId() {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { AUDIT_FIELD_NAMES, assertAuditFields, createAuditRecord } from "./index.mjs";
|
||||
import {
|
||||
AUDIT_FIELD_NAMES,
|
||||
PROTOCOL_AUDIT_FIELD_NAMES,
|
||||
assertAuditFields,
|
||||
createAuditRecord,
|
||||
createProtocolAuditEvent
|
||||
} from "./index.mjs";
|
||||
|
||||
test("createAuditRecord exposes the L1 audit field names", () => {
|
||||
const record = createAuditRecord({
|
||||
@@ -49,3 +55,28 @@ test("assertAuditFields rejects missing audit fields", () => {
|
||||
/audit.timestamp is required/
|
||||
);
|
||||
});
|
||||
|
||||
test("createProtocolAuditEvent emits audit-event schema field names", () => {
|
||||
const event = createProtocolAuditEvent({
|
||||
auditId: "aud_01J00000000000000000000000",
|
||||
traceId: "trc_01J00000000000000000000000",
|
||||
actorType: "user",
|
||||
actorId: "usr_01J00000000000000000000000",
|
||||
action: "hardware.operation.request",
|
||||
targetType: "hardware_operation",
|
||||
targetId: "op_01J00000000000000000000000",
|
||||
projectId: "prj_01J00000000000000000000000",
|
||||
gatewaySessionId: "gws_01J00000000000000000000000",
|
||||
operationId: "op_01J00000000000000000000000",
|
||||
outcome: "accepted",
|
||||
occurredAt: "2026-05-21T00:00:00.000Z"
|
||||
});
|
||||
|
||||
for (const field of PROTOCOL_AUDIT_FIELD_NAMES) {
|
||||
assert.ok(Object.hasOwn(event, field), `expected auditEvent.${field}`);
|
||||
}
|
||||
|
||||
assert.equal(event.serviceId, "hwlab-cloud-api");
|
||||
assert.equal(event.environment, "dev");
|
||||
assert.equal(event.action, "hardware.operation.request");
|
||||
});
|
||||
|
||||
@@ -38,17 +38,20 @@ export function buildDbHealthContract(env = process.env) {
|
||||
};
|
||||
});
|
||||
const missingEnv = fields.filter((field) => !field.present).map((field) => field.name);
|
||||
const ready = missingEnv.length === 0;
|
||||
const configReady = missingEnv.length === 0;
|
||||
const connected = false;
|
||||
const connectionChecked = false;
|
||||
const status = configReady ? "degraded" : "blocked";
|
||||
|
||||
return {
|
||||
contractVersion: DEV_DB_ENV_CONTRACT.contractVersion,
|
||||
environment: DEV_DB_ENV_CONTRACT.environment,
|
||||
connected: false,
|
||||
connectionChecked: false,
|
||||
configReady: ready,
|
||||
ready,
|
||||
status: ready ? "ready" : "blocked",
|
||||
mode: ready ? "configured_pending_live_probe" : "not_configured",
|
||||
connected,
|
||||
connectionChecked,
|
||||
configReady,
|
||||
ready: connected,
|
||||
status,
|
||||
mode: configReady ? "configured_without_live_connection" : "not_configured",
|
||||
fields,
|
||||
missingEnv,
|
||||
secretRefs: DEV_DB_ENV_CONTRACT.secretRefs.map(({ env: envName, secretName, secretKey }) => ({
|
||||
@@ -65,7 +68,7 @@ export function buildDbHealthContract(env = process.env) {
|
||||
valuesRedacted: true,
|
||||
liveDbEvidence: false
|
||||
},
|
||||
evidence: ready ? "env_presence_only" : "env_contract_blocked"
|
||||
evidence: configReady ? "env_presence_only_no_live_db" : "env_contract_blocked"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+54
-30
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
ENVIRONMENT_DEV,
|
||||
ERROR_CODES,
|
||||
HwlabProtocolError,
|
||||
JSON_RPC_VERSION,
|
||||
validateRequest,
|
||||
validateResponse
|
||||
@@ -14,19 +15,38 @@ import {
|
||||
deriveActorFromMeta
|
||||
} from "../audit/index.mjs";
|
||||
import { buildDbHealthContract } from "./db-contract.mjs";
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
|
||||
export const SUPPORTED_RPC_METHODS = Object.freeze([
|
||||
"system.health",
|
||||
"cloud.adapter.describe",
|
||||
"hardware.operation.request"
|
||||
"gateway.session.register",
|
||||
"box.resource.register",
|
||||
"box.capability.report",
|
||||
"hardware.operation.request",
|
||||
"hardware.invoke.shell",
|
||||
"audit.event.write",
|
||||
"audit.event.query",
|
||||
"evidence.record.write",
|
||||
"evidence.record.query"
|
||||
]);
|
||||
|
||||
const rpcHandlers = new Map([
|
||||
["system.health", handleSystemHealth],
|
||||
["cloud.adapter.describe", handleAdapterDescribe],
|
||||
["hardware.operation.request", handleHardwareOperationRequest]
|
||||
["gateway.session.register", (params, envelope, context) => getRuntimeStore(context).registerGatewaySession(params, envelope.meta)],
|
||||
["box.resource.register", (params, envelope, context) => getRuntimeStore(context).registerBoxResource(params, envelope.meta)],
|
||||
["box.capability.report", (params, envelope, context) => getRuntimeStore(context).reportBoxCapabilities(params, envelope.meta)],
|
||||
["hardware.operation.request", (params, envelope, context) => getRuntimeStore(context).requestHardwareOperation(params, envelope.meta)],
|
||||
["hardware.invoke.shell", (params, envelope, context) => getRuntimeStore(context).invokeHardwareShell(params, envelope.meta)],
|
||||
["audit.event.write", (params, envelope, context) => getRuntimeStore(context).writeAuditEvent(params, envelope.meta)],
|
||||
["audit.event.query", (params, envelope, context) => getRuntimeStore(context).queryAuditEvents(params)],
|
||||
["evidence.record.write", (params, envelope, context) => getRuntimeStore(context).writeEvidenceRecord(params, envelope.meta)],
|
||||
["evidence.record.query", (params, envelope, context) => getRuntimeStore(context).queryEvidenceRecords(params)]
|
||||
]);
|
||||
|
||||
const defaultRuntimeStore = createCloudRuntimeStore();
|
||||
|
||||
export async function handleJsonRpcRequest(envelope, context = {}) {
|
||||
const requestMeta = extractMeta(envelope);
|
||||
const requestId = envelope?.id ?? "req_unassigned";
|
||||
@@ -84,6 +104,26 @@ export async function handleJsonRpcRequest(envelope, context = {}) {
|
||||
requestMeta: envelope.meta
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HwlabProtocolError) {
|
||||
return createErrorEnvelope({
|
||||
id: envelope.id,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
data: error.data,
|
||||
requestMeta: envelope.meta,
|
||||
context: {
|
||||
...context,
|
||||
...error.context,
|
||||
operation: envelope.method,
|
||||
target: {
|
||||
type: "rpc_method",
|
||||
id: envelope.method
|
||||
},
|
||||
result: "rejected"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return createErrorEnvelope({
|
||||
id: envelope.id,
|
||||
code: ERROR_CODES.internalError,
|
||||
@@ -177,50 +217,34 @@ export function createResponseMeta(requestMeta = {}) {
|
||||
return meta;
|
||||
}
|
||||
|
||||
function handleSystemHealth() {
|
||||
function handleSystemHealth(params, envelope, context) {
|
||||
return {
|
||||
status: "ok",
|
||||
status: healthStatus(),
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
db: buildDbHealthContract()
|
||||
db: buildDbHealthContract(),
|
||||
runtime: getRuntimeStore(context).summary()
|
||||
};
|
||||
}
|
||||
|
||||
function handleAdapterDescribe() {
|
||||
function handleAdapterDescribe(params, envelope, context) {
|
||||
return {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
rpcEndpoint: "POST /rpc",
|
||||
restEndpoint: "POST /v1/rpc/{method}",
|
||||
methods: SUPPORTED_RPC_METHODS,
|
||||
auditFields: AUDIT_FIELD_NAMES,
|
||||
db: buildDbHealthContract()
|
||||
db: buildDbHealthContract(),
|
||||
runtime: getRuntimeStore(context).summary()
|
||||
};
|
||||
}
|
||||
|
||||
function handleHardwareOperationRequest(params, envelope, context) {
|
||||
const projectId = params.projectId || "project_unspecified";
|
||||
function getRuntimeStore(context = {}) {
|
||||
return context.runtimeStore || defaultRuntimeStore;
|
||||
}
|
||||
|
||||
return {
|
||||
accepted: false,
|
||||
status: "skeleton",
|
||||
reason: "cloud core L1 does not dispatch hardware operations yet",
|
||||
audit: createAuditRecord({
|
||||
requestId: envelope.id,
|
||||
actor: deriveActorFromMeta(envelope.meta),
|
||||
source: {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
traceId: envelope.meta.traceId,
|
||||
adapter: context.adapter || "json-rpc"
|
||||
},
|
||||
operation: "hardware.operation.request",
|
||||
target: {
|
||||
type: "project",
|
||||
id: projectId
|
||||
},
|
||||
result: "rejected"
|
||||
})
|
||||
};
|
||||
function healthStatus() {
|
||||
return "degraded";
|
||||
}
|
||||
|
||||
function extractMeta(envelope) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import { AUDIT_FIELD_NAMES } from "../audit/index.mjs";
|
||||
import { ERROR_CODES, validateResponse } from "../protocol/index.mjs";
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
import { handleJsonRpcRequest } from "./json-rpc.mjs";
|
||||
|
||||
test("unknown JSON-RPC method returns an error envelope with audit fields", async () => {
|
||||
@@ -36,33 +37,6 @@ test("unknown JSON-RPC method returns an error envelope with audit fields", asyn
|
||||
assert.equal(audit.result, "rejected");
|
||||
});
|
||||
|
||||
test("hardware.operation.request is a non-dispatching skeleton with visible audit fields", async () => {
|
||||
const response = await handleJsonRpcRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: "req_01J00000000000000000000001",
|
||||
method: "hardware.operation.request",
|
||||
params: {
|
||||
projectId: "prj_01J00000000000000000000000"
|
||||
},
|
||||
meta: {
|
||||
traceId: "trc_01J00000000000000000000001",
|
||||
actorId: "usr_01J00000000000000000000000",
|
||||
serviceId: "hwlab-cloud-web",
|
||||
environment: "dev"
|
||||
}
|
||||
});
|
||||
|
||||
validateResponse(response);
|
||||
assert.equal(response.result.accepted, false);
|
||||
assert.equal(response.result.status, "skeleton");
|
||||
|
||||
for (const field of AUDIT_FIELD_NAMES) {
|
||||
assert.ok(Object.hasOwn(response.result.audit, field), `expected audit.${field}`);
|
||||
}
|
||||
assert.equal(response.result.audit.operation, "hardware.operation.request");
|
||||
assert.equal(response.result.audit.target.id, "prj_01J00000000000000000000000");
|
||||
});
|
||||
|
||||
test("system.health includes the redacted DEV DB env contract", async () => {
|
||||
const originalUrl = process.env.HWLAB_CLOUD_DB_URL;
|
||||
const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
@@ -83,8 +57,10 @@ test("system.health includes the redacted DEV DB env contract", async () => {
|
||||
});
|
||||
|
||||
validateResponse(response);
|
||||
assert.equal(response.result.status, "degraded");
|
||||
assert.equal(response.result.db.status, "blocked");
|
||||
assert.equal(response.result.db.connected, false);
|
||||
assert.equal(response.result.runtime.durable, false);
|
||||
assert.ok(response.result.db.missingEnv.includes("HWLAB_CLOUD_DB_URL"));
|
||||
assert.equal(response.result.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db");
|
||||
assert.equal(response.result.db.secretRefs[0].secretKey, "database-url");
|
||||
@@ -102,3 +78,122 @@ test("system.health includes the redacted DEV DB env contract", async () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud-api runtime accepts register/report/invoke and exposes audit/evidence records", async () => {
|
||||
const runtimeStore = createCloudRuntimeStore({
|
||||
now: () => "2026-05-21T00:00:00.000Z"
|
||||
});
|
||||
const context = { runtimeStore };
|
||||
const meta = {
|
||||
traceId: "trc_01J00000000000000000000010",
|
||||
actorId: "usr_01J00000000000000000000010",
|
||||
serviceId: "hwlab-cloud-web",
|
||||
environment: "dev"
|
||||
};
|
||||
|
||||
async function rpc(id, method, params = {}) {
|
||||
const response = await handleJsonRpcRequest(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
meta
|
||||
},
|
||||
context
|
||||
);
|
||||
validateResponse(response);
|
||||
assert.equal(Object.hasOwn(response, "error"), false, response.error?.message);
|
||||
return response.result;
|
||||
}
|
||||
|
||||
const gateway = await rpc("req_01J00000000000000000000010", "gateway.session.register", {
|
||||
projectId: "prj_01J00000000000000000000010",
|
||||
gatewaySessionId: "gws_01J00000000000000000000010",
|
||||
gatewayId: "gtw_01J00000000000000000000010",
|
||||
serviceId: "hwlab-gateway-simu",
|
||||
endpoint: "http://127.0.0.1:7101"
|
||||
});
|
||||
assert.equal(gateway.registered, true);
|
||||
assert.equal(gateway.gatewaySession.status, "connected");
|
||||
assert.equal(gateway.auditEvent.action, "gateway.session.register");
|
||||
|
||||
const resource = await rpc("req_01J00000000000000000000011", "box.resource.register", {
|
||||
projectId: "prj_01J00000000000000000000010",
|
||||
gatewaySessionId: "gws_01J00000000000000000000010",
|
||||
resourceId: "res_01J00000000000000000000010",
|
||||
boxId: "box_01J00000000000000000000010",
|
||||
resourceType: "board",
|
||||
state: "available"
|
||||
});
|
||||
assert.equal(resource.registered, true);
|
||||
assert.equal(resource.resource.environment, "dev");
|
||||
|
||||
const capability = await rpc("req_01J00000000000000000000012", "box.capability.report", {
|
||||
capabilityId: "cap_01J00000000000000000000010",
|
||||
resourceId: "res_01J00000000000000000000010",
|
||||
projectId: "prj_01J00000000000000000000010",
|
||||
name: "shell.exec",
|
||||
direction: "bidirectional",
|
||||
valueType: "object",
|
||||
mutatesState: true
|
||||
});
|
||||
assert.equal(capability.reported, true);
|
||||
assert.equal(capability.capabilities[0].name, "shell.exec");
|
||||
|
||||
const invoke = await rpc("req_01J00000000000000000000013", "hardware.invoke.shell", {
|
||||
projectId: "prj_01J00000000000000000000010",
|
||||
gatewaySessionId: "gws_01J00000000000000000000010",
|
||||
resourceId: "res_01J00000000000000000000010",
|
||||
capabilityId: "cap_01J00000000000000000000010",
|
||||
input: {
|
||||
command: "echo hwlab"
|
||||
}
|
||||
});
|
||||
assert.equal(invoke.accepted, true);
|
||||
assert.equal(invoke.operation.status, "accepted");
|
||||
assert.equal(invoke.dispatch.shellExecuted, false);
|
||||
assert.equal(invoke.dispatch.dispatchStatus, "not_connected");
|
||||
assert.equal(invoke.auditEvent.action, "hardware.invoke.shell");
|
||||
assert.equal(invoke.evidenceRecord.kind, "trace");
|
||||
assert.match(invoke.evidenceRecord.sha256, /^[a-f0-9]{64}$/);
|
||||
|
||||
const audit = await rpc("req_01J00000000000000000000014", "audit.event.query", {
|
||||
projectId: "prj_01J00000000000000000000010"
|
||||
});
|
||||
assert.equal(audit.count, 5);
|
||||
assert.ok(audit.events.some((event) => event.action === "hardware.operation.request"));
|
||||
assert.ok(audit.events.some((event) => event.action === "hardware.invoke.shell"));
|
||||
|
||||
const evidence = await rpc("req_01J00000000000000000000015", "evidence.record.query", {
|
||||
projectId: "prj_01J00000000000000000000010"
|
||||
});
|
||||
assert.equal(evidence.count, 1);
|
||||
assert.equal(evidence.records[0].operationId, invoke.operationId);
|
||||
assert.equal(evidence.records[0].serviceId, "hwlab-cloud-api");
|
||||
});
|
||||
|
||||
test("hardware.operation.request rejects missing registration instead of faking dispatch", async () => {
|
||||
const response = await handleJsonRpcRequest({
|
||||
jsonrpc: "2.0",
|
||||
id: "req_01J00000000000000000000020",
|
||||
method: "hardware.operation.request",
|
||||
params: {
|
||||
projectId: "prj_01J00000000000000000000020",
|
||||
gatewaySessionId: "gws_01J00000000000000000000020",
|
||||
resourceId: "res_01J00000000000000000000020",
|
||||
capabilityId: "cap_01J00000000000000000000020"
|
||||
},
|
||||
meta: {
|
||||
traceId: "trc_01J00000000000000000000020",
|
||||
actorId: "usr_01J00000000000000000000020",
|
||||
serviceId: "hwlab-cloud-web",
|
||||
environment: "dev"
|
||||
}
|
||||
});
|
||||
|
||||
validateResponse(response);
|
||||
assert.equal(response.error.code, ERROR_CODES.sessionNotFound);
|
||||
assert.equal(response.error.data.audit.operation, "hardware.operation.request");
|
||||
assert.equal(response.error.data.audit.result, "rejected");
|
||||
});
|
||||
|
||||
+17
-11
@@ -13,13 +13,15 @@ import {
|
||||
handleJsonRpcRequest
|
||||
} from "./json-rpc.mjs";
|
||||
import { buildDbHealthContract } from "./db-contract.mjs";
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
|
||||
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
||||
|
||||
export function createCloudApiServer(options = {}) {
|
||||
const runtimeStore = options.runtimeStore || createCloudRuntimeStore();
|
||||
return createServer(async (request, response) => {
|
||||
try {
|
||||
await routeRequest(request, response, options);
|
||||
await routeRequest(request, response, { ...options, runtimeStore });
|
||||
} catch (error) {
|
||||
sendJson(response, 500, {
|
||||
error: {
|
||||
@@ -32,7 +34,7 @@ export function createCloudApiServer(options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export function buildHealthPayload() {
|
||||
export function buildHealthPayload(options = {}) {
|
||||
const serviceId = CLOUD_API_SERVICE_ID;
|
||||
const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown";
|
||||
const imageReference = process.env.HWLAB_IMAGE || "unknown";
|
||||
@@ -42,7 +44,7 @@ export function buildHealthPayload() {
|
||||
return {
|
||||
serviceId,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
status: "ok",
|
||||
status: db.status === "blocked" ? "degraded" : "degraded",
|
||||
service: {
|
||||
id: serviceId,
|
||||
role: "cloud-api",
|
||||
@@ -60,7 +62,8 @@ export function buildHealthPayload() {
|
||||
},
|
||||
endpoint: process.env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT,
|
||||
observedAt: new Date().toISOString(),
|
||||
db
|
||||
db,
|
||||
runtime: options.runtimeStore?.summary?.() ?? createCloudRuntimeStore().summary()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,7 +71,7 @@ async function routeRequest(request, response, options) {
|
||||
const url = new URL(request.url || "/", "http://hwlab-cloud-api.local");
|
||||
|
||||
if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) {
|
||||
sendJson(response, 200, buildHealthPayload());
|
||||
sendJson(response, 200, buildHealthPayload(options));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -90,7 +93,7 @@ async function routeRequest(request, response, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendRestError(request, response, 404, "not_found", "Route is not part of the L1 cloud-api skeleton", {
|
||||
sendRestError(request, response, 404, "not_found", "Route is not part of the L1 cloud-api runtime", {
|
||||
operation: `${request.method || "GET"} ${url.pathname}`,
|
||||
target: {
|
||||
type: "route",
|
||||
@@ -130,7 +133,8 @@ async function handleRpcHttpRequest(request, response, options) {
|
||||
}
|
||||
|
||||
const rpcResponse = await handleJsonRpcRequest(envelope, {
|
||||
adapter: "json-rpc"
|
||||
adapter: "json-rpc",
|
||||
runtimeStore: options.runtimeStore
|
||||
});
|
||||
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
|
||||
}
|
||||
@@ -140,17 +144,18 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
sendJson(response, 200, {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
adapter: "rest",
|
||||
status: "skeleton",
|
||||
status: "degraded",
|
||||
rpcBridge: "POST /v1/rpc/{method}",
|
||||
methods: SUPPORTED_RPC_METHODS,
|
||||
auditFields: AUDIT_FIELD_NAMES,
|
||||
db: buildDbHealthContract()
|
||||
db: buildDbHealthContract(),
|
||||
runtime: options.runtimeStore.summary()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method !== "POST" || !url.pathname.startsWith("/v1/rpc/")) {
|
||||
sendRestError(request, response, 404, "not_found", "REST route is not implemented in the L1 skeleton", {
|
||||
sendRestError(request, response, 404, "not_found", "REST route is not implemented in the L1 cloud-api runtime", {
|
||||
operation: `${request.method || "GET"} ${url.pathname}`,
|
||||
target: {
|
||||
type: "route",
|
||||
@@ -205,7 +210,8 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
}
|
||||
},
|
||||
{
|
||||
adapter: "rest"
|
||||
adapter: "rest",
|
||||
runtimeStore: options.runtimeStore
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -20,11 +20,14 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
const healthPayload = await health.json();
|
||||
assert.equal(healthPayload.serviceId, "hwlab-cloud-api");
|
||||
assert.equal(healthPayload.environment, "dev");
|
||||
assert.equal(healthPayload.status, "degraded");
|
||||
assert.equal(healthPayload.commit.id.length > 0, true);
|
||||
assert.equal(healthPayload.image.reference.length > 0, true);
|
||||
assert.equal(healthPayload.service.id, "hwlab-cloud-api");
|
||||
assert.equal(healthPayload.db.status, "blocked");
|
||||
assert.equal(healthPayload.db.connected, false);
|
||||
assert.equal(healthPayload.runtime.durable, false);
|
||||
assert.equal(healthPayload.runtime.status, "degraded");
|
||||
assert.deepEqual(healthPayload.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]);
|
||||
assert.equal(healthPayload.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db");
|
||||
assert.equal(healthPayload.db.secretRefs[0].redacted, true);
|
||||
@@ -33,13 +36,13 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
assert.equal(healthLive.status, 200);
|
||||
const healthLivePayload = await healthLive.json();
|
||||
assert.equal(healthLivePayload.serviceId, "hwlab-cloud-api");
|
||||
assert.equal(healthLivePayload.status, "ok");
|
||||
assert.equal(healthLivePayload.status, "degraded");
|
||||
assert.equal(healthLivePayload.commit.id.length > 0, true);
|
||||
assert.equal(healthLivePayload.db.ready, false);
|
||||
|
||||
const readiness = await fetch(`http://127.0.0.1:${port}/health/live`);
|
||||
assert.equal(readiness.status, 200);
|
||||
assert.equal((await readiness.json()).status, "ok");
|
||||
assert.equal((await readiness.json()).status, "degraded");
|
||||
|
||||
const live = await fetch(`http://127.0.0.1:${port}/live`);
|
||||
assert.equal(live.status, 200);
|
||||
@@ -74,11 +77,13 @@ test("cloud api health reports DB env presence without leaking values", async ()
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.db.status, "ready");
|
||||
assert.equal(payload.status, "degraded");
|
||||
assert.equal(payload.db.status, "degraded");
|
||||
assert.equal(payload.db.connected, false);
|
||||
assert.equal(payload.db.connectionChecked, false);
|
||||
assert.equal(payload.db.configReady, true);
|
||||
assert.equal(payload.db.evidence, "env_presence_only");
|
||||
assert.equal(payload.db.ready, false);
|
||||
assert.equal(payload.db.evidence, "env_presence_only_no_live_db");
|
||||
assert.deepEqual(payload.db.missingEnv, []);
|
||||
assert.equal(JSON.stringify(payload.db).includes("password"), false);
|
||||
assert.equal(JSON.stringify(payload.db).includes("db.example.invalid"), false);
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
ENVIRONMENT_DEV,
|
||||
ERROR_CODES,
|
||||
HwlabProtocolError,
|
||||
assertProtocolRecord
|
||||
} from "../protocol/index.mjs";
|
||||
import {
|
||||
CLOUD_API_SERVICE_ID,
|
||||
createProtocolAuditEvent,
|
||||
deriveProtocolActorFromMeta
|
||||
} from "../audit/index.mjs";
|
||||
|
||||
export const RUNTIME_STORE_KIND = "memory";
|
||||
|
||||
export function createCloudRuntimeStore(options = {}) {
|
||||
return new CloudRuntimeStore(options);
|
||||
}
|
||||
|
||||
export class CloudRuntimeStore {
|
||||
constructor({ now = () => new Date().toISOString() } = {}) {
|
||||
this.now = now;
|
||||
this.gatewaySessions = new Map();
|
||||
this.boxResources = new Map();
|
||||
this.boxCapabilities = new Map();
|
||||
this.hardwareOperations = new Map();
|
||||
this.auditEvents = new Map();
|
||||
this.evidenceRecords = new Map();
|
||||
}
|
||||
|
||||
summary() {
|
||||
return {
|
||||
adapter: RUNTIME_STORE_KIND,
|
||||
durable: false,
|
||||
status: "degraded",
|
||||
reason: "live DB persistence is not connected; L1 runtime writes are process-local only",
|
||||
counts: {
|
||||
gatewaySessions: this.gatewaySessions.size,
|
||||
boxResources: this.boxResources.size,
|
||||
boxCapabilities: this.boxCapabilities.size,
|
||||
hardwareOperations: this.hardwareOperations.size,
|
||||
auditEvents: this.auditEvents.size,
|
||||
evidenceRecords: this.evidenceRecords.size
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
registerGatewaySession(params = {}, requestMeta = {}) {
|
||||
const now = this.now();
|
||||
const input = asObject(params.gatewaySession ?? params, "gatewaySession");
|
||||
const gatewaySession = pruneUndefined({
|
||||
gatewaySessionId: input.gatewaySessionId || makeId("gws"),
|
||||
projectId: requireProtocolId(input, "projectId"),
|
||||
serviceId: input.serviceId || "hwlab-gateway-simu",
|
||||
gatewayId: input.gatewayId || makeId("gtw"),
|
||||
endpoint: input.endpoint,
|
||||
status: input.status || "connected",
|
||||
environment: ENVIRONMENT_DEV,
|
||||
startedAt: input.startedAt || now,
|
||||
lastSeenAt: input.lastSeenAt || now,
|
||||
stoppedAt: input.stoppedAt,
|
||||
labels: normalizeJsonObject(input.labels)
|
||||
});
|
||||
|
||||
assertProtocolRecord("gatewaySession", gatewaySession);
|
||||
this.gatewaySessions.set(gatewaySession.gatewaySessionId, gatewaySession);
|
||||
|
||||
const auditEvent = this.recordAuditEvent({
|
||||
requestMeta,
|
||||
params,
|
||||
action: "gateway.session.register",
|
||||
targetType: "gateway_session",
|
||||
targetId: gatewaySession.gatewaySessionId,
|
||||
projectId: gatewaySession.projectId,
|
||||
gatewaySessionId: gatewaySession.gatewaySessionId,
|
||||
outcome: "accepted",
|
||||
metadata: {
|
||||
gatewayId: gatewaySession.gatewayId,
|
||||
serviceId: gatewaySession.serviceId
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
registered: true,
|
||||
gatewaySession,
|
||||
auditEvent,
|
||||
persistence: this.summary()
|
||||
};
|
||||
}
|
||||
|
||||
registerBoxResource(params = {}, requestMeta = {}) {
|
||||
const now = this.now();
|
||||
const input = asObject(params.resource ?? params.boxResource ?? params, "boxResource");
|
||||
const gatewaySessionId = requireProtocolId(input, "gatewaySessionId");
|
||||
const gatewaySession = this.requireGatewaySession(gatewaySessionId);
|
||||
const projectId = input.projectId || gatewaySession.projectId;
|
||||
const resource = pruneUndefined({
|
||||
resourceId: input.resourceId || makeId("res"),
|
||||
projectId,
|
||||
gatewaySessionId,
|
||||
boxId: input.boxId || makeId("box"),
|
||||
resourceType: input.resourceType || "simulator_endpoint",
|
||||
name: input.name,
|
||||
state: input.state || "available",
|
||||
environment: ENVIRONMENT_DEV,
|
||||
metadata: normalizeJsonObject(input.metadata),
|
||||
createdAt: input.createdAt || now,
|
||||
updatedAt: input.updatedAt || now
|
||||
});
|
||||
|
||||
assertProtocolRecord("boxResource", resource);
|
||||
this.boxResources.set(resource.resourceId, resource);
|
||||
|
||||
const auditEvent = this.recordAuditEvent({
|
||||
requestMeta,
|
||||
params,
|
||||
action: "box.resource.register",
|
||||
targetType: "box_resource",
|
||||
targetId: resource.resourceId,
|
||||
projectId: resource.projectId,
|
||||
gatewaySessionId: resource.gatewaySessionId,
|
||||
outcome: "accepted",
|
||||
metadata: {
|
||||
boxId: resource.boxId,
|
||||
resourceType: resource.resourceType
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
registered: true,
|
||||
resource,
|
||||
auditEvent,
|
||||
persistence: this.summary()
|
||||
};
|
||||
}
|
||||
|
||||
reportBoxCapabilities(params = {}, requestMeta = {}) {
|
||||
const now = this.now();
|
||||
const inputs = normalizeCapabilityInputs(params);
|
||||
const capabilities = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
const resourceId = requireProtocolId(input, "resourceId");
|
||||
const resource = this.requireBoxResource(resourceId);
|
||||
const capability = pruneUndefined({
|
||||
capabilityId: input.capabilityId || makeId("cap"),
|
||||
resourceId,
|
||||
projectId: input.projectId || resource.projectId,
|
||||
name: requireString(input, "name"),
|
||||
description: input.description,
|
||||
direction: input.direction || "bidirectional",
|
||||
valueType: input.valueType || "object",
|
||||
unit: input.unit,
|
||||
constraints: normalizeJsonObject(input.constraints),
|
||||
mutatesState: input.mutatesState ?? true,
|
||||
createdAt: input.createdAt || now,
|
||||
updatedAt: input.updatedAt || now
|
||||
});
|
||||
|
||||
assertProtocolRecord("boxCapability", capability);
|
||||
this.boxCapabilities.set(capability.capabilityId, capability);
|
||||
capabilities.push(capability);
|
||||
}
|
||||
|
||||
const first = capabilities[0];
|
||||
const auditEvent = this.recordAuditEvent({
|
||||
requestMeta,
|
||||
params,
|
||||
action: "box.capability.report",
|
||||
targetType: "box_capability",
|
||||
targetId: first.capabilityId,
|
||||
projectId: first.projectId,
|
||||
gatewaySessionId: this.boxResources.get(first.resourceId)?.gatewaySessionId,
|
||||
outcome: "accepted",
|
||||
metadata: {
|
||||
capabilityIds: capabilities.map((capability) => capability.capabilityId)
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
reported: true,
|
||||
capabilities,
|
||||
auditEvent,
|
||||
persistence: this.summary()
|
||||
};
|
||||
}
|
||||
|
||||
requestHardwareOperation(params = {}, requestMeta = {}) {
|
||||
const refs = this.requireOperationRefs(params, requestMeta);
|
||||
const now = this.now();
|
||||
const operation = pruneUndefined({
|
||||
operationId: params.operationId || makeId("op"),
|
||||
projectId: refs.projectId,
|
||||
gatewaySessionId: refs.gatewaySession.gatewaySessionId,
|
||||
agentSessionId: params.agentSessionId,
|
||||
workerSessionId: params.workerSessionId,
|
||||
resourceId: refs.resource.resourceId,
|
||||
capabilityId: refs.capability.capabilityId,
|
||||
requestedBy: refs.actor.actorId,
|
||||
input: normalizeJsonObject(params.input),
|
||||
status: "accepted",
|
||||
environment: ENVIRONMENT_DEV,
|
||||
requestedAt: params.requestedAt || now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
assertProtocolRecord("hardwareOperation", operation);
|
||||
this.hardwareOperations.set(operation.operationId, operation);
|
||||
|
||||
const auditEvent = this.recordAuditEvent({
|
||||
requestMeta,
|
||||
params,
|
||||
action: "hardware.operation.request",
|
||||
targetType: "hardware_operation",
|
||||
targetId: operation.operationId,
|
||||
projectId: operation.projectId,
|
||||
gatewaySessionId: operation.gatewaySessionId,
|
||||
workerSessionId: operation.workerSessionId,
|
||||
operationId: operation.operationId,
|
||||
outcome: "accepted",
|
||||
metadata: {
|
||||
resourceId: operation.resourceId,
|
||||
capabilityId: operation.capabilityId
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
accepted: true,
|
||||
status: operation.status,
|
||||
operationId: operation.operationId,
|
||||
operation,
|
||||
auditEvent,
|
||||
persistence: this.summary()
|
||||
};
|
||||
}
|
||||
|
||||
invokeHardwareShell(params = {}, requestMeta = {}) {
|
||||
const shellInput = normalizeShellInput(params);
|
||||
const operationResult = this.requestHardwareOperation(
|
||||
{
|
||||
...params,
|
||||
input: {
|
||||
...normalizeJsonObject(params.input),
|
||||
shell: shellInput
|
||||
}
|
||||
},
|
||||
requestMeta
|
||||
);
|
||||
const operation = {
|
||||
...operationResult.operation,
|
||||
output: {
|
||||
shellExecuted: false,
|
||||
dispatchStatus: "not_connected",
|
||||
message: "cloud-api recorded the shell invoke request; no gateway shell adapter is connected in L1"
|
||||
},
|
||||
updatedAt: this.now()
|
||||
};
|
||||
assertProtocolRecord("hardwareOperation", operation);
|
||||
this.hardwareOperations.set(operation.operationId, operation);
|
||||
|
||||
const evidenceRecord = this.writeEvidenceRecord(
|
||||
{
|
||||
projectId: operation.projectId,
|
||||
operationId: operation.operationId,
|
||||
kind: "trace",
|
||||
mimeType: "application/json",
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
metadata: {
|
||||
traceId: requestMeta.traceId,
|
||||
gatewaySessionId: operation.gatewaySessionId,
|
||||
resourceId: operation.resourceId,
|
||||
capabilityId: operation.capabilityId,
|
||||
shellExecuted: false,
|
||||
dispatchStatus: "not_connected"
|
||||
},
|
||||
content: {
|
||||
operationId: operation.operationId,
|
||||
shell: shellInput,
|
||||
dispatchStatus: "not_connected"
|
||||
}
|
||||
},
|
||||
requestMeta
|
||||
).evidenceRecord;
|
||||
|
||||
const auditEvent = this.recordAuditEvent({
|
||||
requestMeta,
|
||||
params,
|
||||
action: "hardware.invoke.shell",
|
||||
targetType: "hardware_operation",
|
||||
targetId: operation.operationId,
|
||||
projectId: operation.projectId,
|
||||
gatewaySessionId: operation.gatewaySessionId,
|
||||
workerSessionId: operation.workerSessionId,
|
||||
operationId: operation.operationId,
|
||||
outcome: "accepted",
|
||||
metadata: {
|
||||
evidenceId: evidenceRecord.evidenceId,
|
||||
shellExecuted: false,
|
||||
dispatchStatus: "not_connected"
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
accepted: true,
|
||||
status: operation.status,
|
||||
operationId: operation.operationId,
|
||||
operation,
|
||||
auditEvent,
|
||||
evidenceRecord,
|
||||
dispatch: operation.output,
|
||||
persistence: this.summary()
|
||||
};
|
||||
}
|
||||
|
||||
writeAuditEvent(params = {}, requestMeta = {}) {
|
||||
const eventInput = params.event ?? params.auditEvent ?? params;
|
||||
const auditEvent = eventInput.auditId
|
||||
? pruneUndefined({
|
||||
...eventInput,
|
||||
environment: ENVIRONMENT_DEV
|
||||
})
|
||||
: this.buildAuditEventFromInput(eventInput, requestMeta);
|
||||
|
||||
assertProtocolRecord("auditEvent", auditEvent);
|
||||
this.auditEvents.set(auditEvent.auditId, auditEvent);
|
||||
|
||||
return {
|
||||
written: true,
|
||||
auditEvent,
|
||||
persistence: this.summary()
|
||||
};
|
||||
}
|
||||
|
||||
queryAuditEvents(params = {}) {
|
||||
const events = [...this.auditEvents.values()].filter((event) => matchesQuery(event, params, [
|
||||
"auditId",
|
||||
"traceId",
|
||||
"projectId",
|
||||
"gatewaySessionId",
|
||||
"operationId",
|
||||
"action",
|
||||
"targetId"
|
||||
]));
|
||||
|
||||
return {
|
||||
events: limitResults(events, params.limit),
|
||||
count: events.length,
|
||||
persistence: this.summary()
|
||||
};
|
||||
}
|
||||
|
||||
writeEvidenceRecord(params = {}, requestMeta = {}) {
|
||||
const now = this.now();
|
||||
const input = asObject(params.record ?? params.evidenceRecord ?? params, "evidenceRecord");
|
||||
const content = input.content ?? input.metadata ?? {};
|
||||
const sha256 = input.sha256 || sha256Hex(content);
|
||||
const evidenceId = input.evidenceId || makeId("evd");
|
||||
const evidenceRecord = pruneUndefined({
|
||||
evidenceId,
|
||||
projectId: requireProtocolId(input, "projectId"),
|
||||
operationId: requireProtocolId(input, "operationId"),
|
||||
agentSessionId: input.agentSessionId,
|
||||
workerSessionId: input.workerSessionId,
|
||||
kind: input.kind || "trace",
|
||||
uri: input.uri || `memory://hwlab/evidence/${evidenceId}`,
|
||||
mimeType: input.mimeType,
|
||||
sha256,
|
||||
sizeBytes: input.sizeBytes ?? Buffer.byteLength(stableJson(content), "utf8"),
|
||||
serviceId: input.serviceId || CLOUD_API_SERVICE_ID,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
metadata: {
|
||||
...normalizeJsonObject(input.metadata),
|
||||
...(requestMeta.traceId ? { traceId: requestMeta.traceId } : {})
|
||||
},
|
||||
createdAt: input.createdAt || now
|
||||
});
|
||||
|
||||
assertProtocolRecord("evidenceRecord", evidenceRecord);
|
||||
this.evidenceRecords.set(evidenceRecord.evidenceId, evidenceRecord);
|
||||
|
||||
return {
|
||||
written: true,
|
||||
evidenceRecord,
|
||||
persistence: this.summary()
|
||||
};
|
||||
}
|
||||
|
||||
queryEvidenceRecords(params = {}) {
|
||||
const records = [...this.evidenceRecords.values()].filter((record) => matchesQuery(record, params, [
|
||||
"evidenceId",
|
||||
"projectId",
|
||||
"operationId",
|
||||
"agentSessionId",
|
||||
"workerSessionId",
|
||||
"kind",
|
||||
"serviceId"
|
||||
]));
|
||||
|
||||
return {
|
||||
records: limitResults(records, params.limit),
|
||||
count: records.length,
|
||||
persistence: this.summary()
|
||||
};
|
||||
}
|
||||
|
||||
recordAuditEvent(input) {
|
||||
return this.writeAuditEvent(this.buildAuditEventFromInput(input, input.requestMeta), input.requestMeta).auditEvent;
|
||||
}
|
||||
|
||||
buildAuditEventFromInput(input = {}, requestMeta = {}) {
|
||||
const actor = deriveProtocolActorFromMeta(requestMeta, input.params?.audit ?? input);
|
||||
return createProtocolAuditEvent({
|
||||
auditId: input.auditId,
|
||||
traceId: requestMeta.traceId || input.traceId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
action: input.action,
|
||||
targetType: input.targetType,
|
||||
targetId: input.targetId,
|
||||
projectId: input.projectId,
|
||||
gatewaySessionId: input.gatewaySessionId,
|
||||
workerSessionId: input.workerSessionId,
|
||||
operationId: input.operationId,
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
outcome: input.outcome,
|
||||
reason: input.reason || input.params?.audit?.reason,
|
||||
metadata: input.metadata,
|
||||
occurredAt: input.occurredAt
|
||||
});
|
||||
}
|
||||
|
||||
requireOperationRefs(params, requestMeta = {}) {
|
||||
const projectId = requireProtocolId(params, "projectId");
|
||||
const gatewaySession = this.requireGatewaySession(requireProtocolId(params, "gatewaySessionId"));
|
||||
const resource = this.requireBoxResource(requireProtocolId(params, "resourceId"));
|
||||
const capability = this.requireBoxCapability(requireProtocolId(params, "capabilityId"));
|
||||
|
||||
if (gatewaySession.projectId !== projectId || resource.projectId !== projectId || capability.projectId !== projectId) {
|
||||
throw new HwlabProtocolError("hardware operation references must belong to one project", {
|
||||
code: ERROR_CODES.invalidParams,
|
||||
data: {
|
||||
projectId,
|
||||
gatewaySessionId: gatewaySession.gatewaySessionId,
|
||||
resourceId: resource.resourceId,
|
||||
capabilityId: capability.capabilityId
|
||||
}
|
||||
});
|
||||
}
|
||||
if (resource.gatewaySessionId !== gatewaySession.gatewaySessionId) {
|
||||
throw new HwlabProtocolError("resource is not registered under the requested gateway session", {
|
||||
code: ERROR_CODES.resourceLocked,
|
||||
data: {
|
||||
gatewaySessionId: gatewaySession.gatewaySessionId,
|
||||
resourceGatewaySessionId: resource.gatewaySessionId,
|
||||
resourceId: resource.resourceId
|
||||
}
|
||||
});
|
||||
}
|
||||
if (capability.resourceId !== resource.resourceId) {
|
||||
throw new HwlabProtocolError("capability is not registered on the requested resource", {
|
||||
code: ERROR_CODES.capabilityUnavailable,
|
||||
data: {
|
||||
resourceId: resource.resourceId,
|
||||
capabilityId: capability.capabilityId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
projectId,
|
||||
gatewaySession,
|
||||
resource,
|
||||
capability,
|
||||
actor: deriveProtocolActorFromMeta(requestMeta, params.audit ?? {})
|
||||
};
|
||||
}
|
||||
|
||||
requireGatewaySession(gatewaySessionId) {
|
||||
const gatewaySession = this.gatewaySessions.get(gatewaySessionId);
|
||||
if (!gatewaySession) {
|
||||
throw new HwlabProtocolError(`gateway session ${gatewaySessionId} is not registered`, {
|
||||
code: ERROR_CODES.sessionNotFound,
|
||||
data: { gatewaySessionId }
|
||||
});
|
||||
}
|
||||
return gatewaySession;
|
||||
}
|
||||
|
||||
requireBoxResource(resourceId) {
|
||||
const resource = this.boxResources.get(resourceId);
|
||||
if (!resource) {
|
||||
throw new HwlabProtocolError(`box resource ${resourceId} is not registered`, {
|
||||
code: ERROR_CODES.capabilityUnavailable,
|
||||
data: { resourceId }
|
||||
});
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
|
||||
requireBoxCapability(capabilityId) {
|
||||
const capability = this.boxCapabilities.get(capabilityId);
|
||||
if (!capability) {
|
||||
throw new HwlabProtocolError(`capability ${capabilityId} is not reported`, {
|
||||
code: ERROR_CODES.capabilityUnavailable,
|
||||
data: { capabilityId }
|
||||
});
|
||||
}
|
||||
return capability;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCapabilityInputs(params) {
|
||||
const value = params.capabilities ?? params.capability ?? params;
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
throw new HwlabProtocolError("capabilities must not be empty", {
|
||||
code: ERROR_CODES.invalidParams
|
||||
});
|
||||
}
|
||||
return value.map((item) => asObject(item, "capability"));
|
||||
}
|
||||
return [asObject(value, "capability")];
|
||||
}
|
||||
|
||||
function normalizeShellInput(params) {
|
||||
const input = normalizeJsonObject(params.input);
|
||||
const command = params.command ?? input.command;
|
||||
if (command === undefined || command === null || command === "") {
|
||||
throw new HwlabProtocolError("hardware.invoke.shell requires input.command or command", {
|
||||
code: ERROR_CODES.invalidParams,
|
||||
data: {
|
||||
required: ["projectId", "gatewaySessionId", "resourceId", "capabilityId", "input.command"]
|
||||
}
|
||||
});
|
||||
}
|
||||
if (Array.isArray(command)) {
|
||||
return {
|
||||
command: command.map((item) => String(item)),
|
||||
cwd: input.cwd,
|
||||
timeoutMs: input.timeoutMs
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: String(command),
|
||||
cwd: input.cwd,
|
||||
timeoutMs: input.timeoutMs
|
||||
};
|
||||
}
|
||||
|
||||
function asObject(value, label) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new HwlabProtocolError(`${label} must be a JSON object`, {
|
||||
code: ERROR_CODES.invalidParams
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireString(input, field) {
|
||||
if (typeof input[field] !== "string" || input[field].trim() === "") {
|
||||
throw new HwlabProtocolError(`${field} is required`, {
|
||||
code: ERROR_CODES.invalidParams,
|
||||
data: { field }
|
||||
});
|
||||
}
|
||||
return input[field];
|
||||
}
|
||||
|
||||
function requireProtocolId(input, field) {
|
||||
const value = requireString(input, field);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeJsonObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
function pruneUndefined(input) {
|
||||
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
||||
}
|
||||
|
||||
function makeId(prefix) {
|
||||
return `${prefix}_${randomUUID()}`;
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
return JSON.stringify(sortJson(value));
|
||||
}
|
||||
|
||||
function sha256Hex(value) {
|
||||
return createHash("sha256").update(stableJson(value)).digest("hex");
|
||||
}
|
||||
|
||||
function sortJson(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sortJson);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function matchesQuery(record, params, fields) {
|
||||
for (const field of fields) {
|
||||
if (params[field] !== undefined && params[field] !== null && record[field] !== params[field]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function limitResults(records, limit) {
|
||||
const parsed = Number.parseInt(limit ?? "", 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
return records;
|
||||
}
|
||||
return records.slice(0, parsed);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { TABLES } from "../protocol/index.mjs";
|
||||
import { TABLES, assertProtocolRecord, assertProtocolRecords } from "../protocol/index.mjs";
|
||||
import {
|
||||
CLOUD_CORE_MIGRATIONS,
|
||||
FROZEN_CLOUD_CORE_TABLES,
|
||||
@@ -34,3 +34,48 @@ test("initial migration skeleton declares every frozen table", async () => {
|
||||
assert.match(sql, /\bresult\b/);
|
||||
assert.match(sql, /\btimestamp\b/);
|
||||
});
|
||||
|
||||
test("protocol record guards catch schema drift before runtime writes", () => {
|
||||
assertProtocolRecord("gatewaySession", {
|
||||
gatewaySessionId: "gws_01J00000000000000000000000",
|
||||
projectId: "prj_01J00000000000000000000000",
|
||||
serviceId: "hwlab-gateway-simu",
|
||||
gatewayId: "gtw_01J00000000000000000000000",
|
||||
endpoint: "http://127.0.0.1:7101",
|
||||
status: "connected",
|
||||
environment: "dev",
|
||||
startedAt: "2026-05-21T00:00:00.000Z",
|
||||
lastSeenAt: "2026-05-21T00:00:00.000Z"
|
||||
});
|
||||
|
||||
assertProtocolRecords("boxCapability", [
|
||||
{
|
||||
capabilityId: "cap_01J00000000000000000000000",
|
||||
resourceId: "res_01J00000000000000000000000",
|
||||
projectId: "prj_01J00000000000000000000000",
|
||||
name: "shell.exec",
|
||||
direction: "bidirectional",
|
||||
valueType: "object",
|
||||
mutatesState: true,
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
updatedAt: "2026-05-21T00:00:00.000Z"
|
||||
}
|
||||
]);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
assertProtocolRecord("evidenceRecord", {
|
||||
evidenceId: "evd_01J00000000000000000000000",
|
||||
projectId: "prj_01J00000000000000000000000",
|
||||
operationId: "op_01J00000000000000000000000",
|
||||
kind: "trace",
|
||||
uri: "memory://hwlab/evidence/evd_01J00000000000000000000000",
|
||||
sha256: "0".repeat(64),
|
||||
serviceId: "hwlab-cloud-api",
|
||||
environment: "dev",
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
evidenceType: "trace"
|
||||
}),
|
||||
/evidenceRecord.evidenceType is not part of the frozen protocol schema/
|
||||
);
|
||||
});
|
||||
|
||||
@@ -50,12 +50,264 @@ export const ERROR_CODES = Object.freeze({
|
||||
auditRequired: -32040
|
||||
});
|
||||
|
||||
function freezeRecordShape({ required, allowed }) {
|
||||
return Object.freeze({
|
||||
required: Object.freeze([...required]),
|
||||
allowed: Object.freeze([...allowed])
|
||||
});
|
||||
}
|
||||
|
||||
export const PROTOCOL_RECORD_SHAPES = Object.freeze({
|
||||
gatewaySession: freezeRecordShape({
|
||||
required: [
|
||||
"gatewaySessionId",
|
||||
"projectId",
|
||||
"serviceId",
|
||||
"gatewayId",
|
||||
"status",
|
||||
"environment",
|
||||
"startedAt"
|
||||
],
|
||||
allowed: [
|
||||
"gatewaySessionId",
|
||||
"projectId",
|
||||
"serviceId",
|
||||
"gatewayId",
|
||||
"endpoint",
|
||||
"status",
|
||||
"environment",
|
||||
"startedAt",
|
||||
"lastSeenAt",
|
||||
"stoppedAt",
|
||||
"labels"
|
||||
]
|
||||
}),
|
||||
boxResource: freezeRecordShape({
|
||||
required: [
|
||||
"resourceId",
|
||||
"projectId",
|
||||
"gatewaySessionId",
|
||||
"boxId",
|
||||
"resourceType",
|
||||
"state",
|
||||
"environment",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
],
|
||||
allowed: [
|
||||
"resourceId",
|
||||
"projectId",
|
||||
"gatewaySessionId",
|
||||
"boxId",
|
||||
"resourceType",
|
||||
"name",
|
||||
"state",
|
||||
"environment",
|
||||
"metadata",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
]
|
||||
}),
|
||||
boxCapability: freezeRecordShape({
|
||||
required: [
|
||||
"capabilityId",
|
||||
"resourceId",
|
||||
"projectId",
|
||||
"name",
|
||||
"direction",
|
||||
"valueType",
|
||||
"mutatesState",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
],
|
||||
allowed: [
|
||||
"capabilityId",
|
||||
"resourceId",
|
||||
"projectId",
|
||||
"name",
|
||||
"description",
|
||||
"direction",
|
||||
"valueType",
|
||||
"unit",
|
||||
"constraints",
|
||||
"mutatesState",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
]
|
||||
}),
|
||||
hardwareOperation: freezeRecordShape({
|
||||
required: [
|
||||
"operationId",
|
||||
"projectId",
|
||||
"gatewaySessionId",
|
||||
"resourceId",
|
||||
"capabilityId",
|
||||
"requestedBy",
|
||||
"status",
|
||||
"environment",
|
||||
"requestedAt",
|
||||
"updatedAt"
|
||||
],
|
||||
allowed: [
|
||||
"operationId",
|
||||
"projectId",
|
||||
"gatewaySessionId",
|
||||
"agentSessionId",
|
||||
"workerSessionId",
|
||||
"resourceId",
|
||||
"capabilityId",
|
||||
"requestedBy",
|
||||
"input",
|
||||
"output",
|
||||
"status",
|
||||
"environment",
|
||||
"requestedAt",
|
||||
"startedAt",
|
||||
"completedAt",
|
||||
"updatedAt",
|
||||
"error"
|
||||
]
|
||||
}),
|
||||
auditEvent: freezeRecordShape({
|
||||
required: [
|
||||
"auditId",
|
||||
"traceId",
|
||||
"actorType",
|
||||
"actorId",
|
||||
"action",
|
||||
"targetType",
|
||||
"targetId",
|
||||
"serviceId",
|
||||
"environment",
|
||||
"occurredAt"
|
||||
],
|
||||
allowed: [
|
||||
"auditId",
|
||||
"traceId",
|
||||
"actorType",
|
||||
"actorId",
|
||||
"action",
|
||||
"targetType",
|
||||
"targetId",
|
||||
"projectId",
|
||||
"gatewaySessionId",
|
||||
"workerSessionId",
|
||||
"operationId",
|
||||
"serviceId",
|
||||
"environment",
|
||||
"outcome",
|
||||
"reason",
|
||||
"metadata",
|
||||
"occurredAt"
|
||||
]
|
||||
}),
|
||||
evidenceRecord: freezeRecordShape({
|
||||
required: [
|
||||
"evidenceId",
|
||||
"projectId",
|
||||
"operationId",
|
||||
"kind",
|
||||
"uri",
|
||||
"sha256",
|
||||
"serviceId",
|
||||
"environment",
|
||||
"createdAt"
|
||||
],
|
||||
allowed: [
|
||||
"evidenceId",
|
||||
"projectId",
|
||||
"operationId",
|
||||
"agentSessionId",
|
||||
"workerSessionId",
|
||||
"kind",
|
||||
"uri",
|
||||
"mimeType",
|
||||
"sha256",
|
||||
"sizeBytes",
|
||||
"serviceId",
|
||||
"environment",
|
||||
"metadata",
|
||||
"createdAt"
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
export class HwlabProtocolError extends Error {
|
||||
constructor(message, { code = ERROR_CODES.hwlabUnknown, data = {}, context = {} } = {}) {
|
||||
super(message);
|
||||
this.name = "HwlabProtocolError";
|
||||
this.code = code;
|
||||
this.data = data;
|
||||
this.context = context;
|
||||
}
|
||||
}
|
||||
|
||||
const methodPattern = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$/;
|
||||
const protocolIdPattern = /^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/;
|
||||
const actionPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
|
||||
const timestampFieldNames = new Set([
|
||||
"startedAt",
|
||||
"lastSeenAt",
|
||||
"stoppedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"requestedAt",
|
||||
"completedAt",
|
||||
"occurredAt"
|
||||
]);
|
||||
const idFieldNames = new Set([
|
||||
"gatewaySessionId",
|
||||
"projectId",
|
||||
"gatewayId",
|
||||
"resourceId",
|
||||
"boxId",
|
||||
"capabilityId",
|
||||
"operationId",
|
||||
"agentSessionId",
|
||||
"workerSessionId",
|
||||
"requestedBy",
|
||||
"auditId",
|
||||
"traceId",
|
||||
"actorId",
|
||||
"targetId",
|
||||
"evidenceId"
|
||||
]);
|
||||
const actorTypes = new Set(["user", "service", "agent", "worker", "system"]);
|
||||
const auditOutcomes = new Set(["accepted", "succeeded", "failed", "rejected", "canceled"]);
|
||||
const evidenceKinds = new Set(["log", "screenshot", "measurement", "artifact", "report", "trace"]);
|
||||
const gatewayStatuses = new Set(["starting", "connected", "degraded", "disconnected", "stopped"]);
|
||||
const resourceStates = new Set(["available", "reserved", "running", "maintenance", "offline"]);
|
||||
const operationStatuses = new Set([
|
||||
"requested",
|
||||
"accepted",
|
||||
"queued",
|
||||
"running",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"rejected",
|
||||
"canceled",
|
||||
"timed_out"
|
||||
]);
|
||||
const resourceTypes = new Set([
|
||||
"board",
|
||||
"power_supply",
|
||||
"serial_port",
|
||||
"network_port",
|
||||
"relay",
|
||||
"sensor",
|
||||
"simulator_endpoint"
|
||||
]);
|
||||
const capabilityDirections = new Set(["input", "output", "bidirectional"]);
|
||||
const capabilityValueTypes = new Set(["boolean", "integer", "number", "string", "object", "binary"]);
|
||||
|
||||
export function isFrozenServiceId(serviceId) {
|
||||
return SERVICE_IDS.includes(serviceId);
|
||||
}
|
||||
|
||||
export function isProtocolId(value) {
|
||||
return typeof value === "string" && protocolIdPattern.test(value);
|
||||
}
|
||||
|
||||
export function validateMeta(meta) {
|
||||
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
|
||||
throw new Error("meta must be an object");
|
||||
@@ -149,3 +401,96 @@ export function createRpcErrorBody({ code = ERROR_CODES.hwlabUnknown, message =
|
||||
error
|
||||
};
|
||||
}
|
||||
|
||||
export function assertProtocolRecord(kind, record) {
|
||||
const shape = PROTOCOL_RECORD_SHAPES[kind];
|
||||
if (!shape) {
|
||||
throw new Error(`unknown protocol record kind ${JSON.stringify(kind)}`);
|
||||
}
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
||||
throw new Error(`${kind} must be an object`);
|
||||
}
|
||||
|
||||
for (const field of shape.required) {
|
||||
if (!Object.hasOwn(record, field)) {
|
||||
throw new Error(`${kind}.${field} is required`);
|
||||
}
|
||||
}
|
||||
|
||||
const allowed = new Set(shape.allowed);
|
||||
for (const [field, value] of Object.entries(record)) {
|
||||
if (!allowed.has(field)) {
|
||||
throw new Error(`${kind}.${field} is not part of the frozen protocol schema`);
|
||||
}
|
||||
assertProtocolField(kind, field, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertProtocolRecords(kind, records) {
|
||||
if (!Array.isArray(records)) {
|
||||
throw new Error(`${kind} records must be an array`);
|
||||
}
|
||||
for (const record of records) {
|
||||
assertProtocolRecord(kind, record);
|
||||
}
|
||||
}
|
||||
|
||||
function assertProtocolField(kind, field, value) {
|
||||
if (idFieldNames.has(field) && !isProtocolId(value)) {
|
||||
throw new Error(`${kind}.${field} must be a protocol id`);
|
||||
}
|
||||
if (timestampFieldNames.has(field) && Number.isNaN(Date.parse(value))) {
|
||||
throw new Error(`${kind}.${field} must be an RFC 3339 timestamp`);
|
||||
}
|
||||
if (field === "environment" && value !== ENVIRONMENT_DEV) {
|
||||
throw new Error(`${kind}.environment must be ${ENVIRONMENT_DEV}`);
|
||||
}
|
||||
if (field === "serviceId" && !isFrozenServiceId(value)) {
|
||||
throw new Error(`${kind}.serviceId is not a frozen service id`);
|
||||
}
|
||||
if ((field === "action" || field === "name") && !actionPattern.test(value)) {
|
||||
throw new Error(`${kind}.${field} must be a dotted lower-case action name`);
|
||||
}
|
||||
if (field === "actorType" && !actorTypes.has(value)) {
|
||||
throw new Error(`${kind}.actorType is invalid`);
|
||||
}
|
||||
if (field === "outcome" && !auditOutcomes.has(value)) {
|
||||
throw new Error(`${kind}.outcome is invalid`);
|
||||
}
|
||||
if (field === "kind" && !evidenceKinds.has(value)) {
|
||||
throw new Error(`${kind}.kind is invalid`);
|
||||
}
|
||||
if (field === "status") {
|
||||
assertStatusField(kind, value);
|
||||
}
|
||||
if (field === "state" && !resourceStates.has(value)) {
|
||||
throw new Error(`${kind}.state is invalid`);
|
||||
}
|
||||
if (field === "resourceType" && !resourceTypes.has(value)) {
|
||||
throw new Error(`${kind}.resourceType is invalid`);
|
||||
}
|
||||
if (field === "direction" && !capabilityDirections.has(value)) {
|
||||
throw new Error(`${kind}.direction is invalid`);
|
||||
}
|
||||
if (field === "valueType" && !capabilityValueTypes.has(value)) {
|
||||
throw new Error(`${kind}.valueType is invalid`);
|
||||
}
|
||||
if (field === "mutatesState" && typeof value !== "boolean") {
|
||||
throw new Error(`${kind}.mutatesState must be boolean`);
|
||||
}
|
||||
if (field === "sha256" && !/^[a-f0-9]{64}$/.test(value)) {
|
||||
throw new Error(`${kind}.sha256 must be lowercase sha256 hex`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStatusField(kind, value) {
|
||||
if (kind === "gatewaySession" && !gatewayStatuses.has(value)) {
|
||||
throw new Error(`${kind}.status is invalid`);
|
||||
}
|
||||
if (kind === "boxResource" && !resourceStates.has(value)) {
|
||||
throw new Error(`${kind}.status is invalid`);
|
||||
}
|
||||
if (kind === "hardwareOperation" && !operationStatuses.has(value)) {
|
||||
throw new Error(`${kind}.status is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -5,8 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"validate": "node scripts/validate-contract.mjs",
|
||||
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node --test internal/agent/index.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
|
||||
"check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node --test internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh",
|
||||
"dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs",
|
||||
"cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs",
|
||||
"m1:smoke": "node scripts/m1-contract-smoke.mjs",
|
||||
"l2:smoke": "node scripts/l2-runtime-contract-smoke.mjs",
|
||||
"dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty",
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:net";
|
||||
|
||||
import { createCloudApiServer } from "../internal/cloud/server.mjs";
|
||||
import { ENVIRONMENT_DEV } from "../internal/protocol/index.mjs";
|
||||
|
||||
const previousEnv = {
|
||||
HWLAB_CLOUD_DB_URL: process.env.HWLAB_CLOUD_DB_URL,
|
||||
HWLAB_CLOUD_DB_SSL_MODE: process.env.HWLAB_CLOUD_DB_SSL_MODE
|
||||
};
|
||||
|
||||
function logOk(name) {
|
||||
process.stdout.write(`[cloud-api-runtime-smoke] ok ${name}\n`);
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
const server = createServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : null;
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return port;
|
||||
}
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(options.headers ?? {})
|
||||
}
|
||||
});
|
||||
const text = await response.text();
|
||||
return {
|
||||
response,
|
||||
body: text ? JSON.parse(text) : null
|
||||
};
|
||||
}
|
||||
|
||||
function rpcBody(id, method, params = {}) {
|
||||
return JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
meta: {
|
||||
traceId: `trc_${id}`,
|
||||
actorId: "usr_cloud_api_runtime_smoke",
|
||||
serviceId: "hwlab-cli",
|
||||
environment: ENVIRONMENT_DEV
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function rpc(baseUrl, id, method, params = {}) {
|
||||
const { response, body } = await requestJson(`${baseUrl}/rpc`, {
|
||||
method: "POST",
|
||||
body: rpcBody(id, method, params)
|
||||
});
|
||||
assert.equal(response.status, 200, `${method} http status`);
|
||||
assert.equal(body.jsonrpc, "2.0", `${method} JSON-RPC version`);
|
||||
assert.equal(Object.hasOwn(body, "error"), false, body.error?.message);
|
||||
return body.result;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
delete process.env.HWLAB_CLOUD_DB_URL;
|
||||
delete process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
|
||||
const port = await freePort();
|
||||
const server = createCloudApiServer();
|
||||
await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
try {
|
||||
const health = await requestJson(`${baseUrl}/health`);
|
||||
assert.equal(health.response.status, 200);
|
||||
assert.equal(health.body.serviceId, "hwlab-cloud-api");
|
||||
assert.equal(health.body.environment, ENVIRONMENT_DEV);
|
||||
assert.equal(health.body.status, "degraded");
|
||||
assert.equal(health.body.db.status, "blocked");
|
||||
assert.equal(health.body.db.connected, false);
|
||||
assert.equal(health.body.db.ready, false);
|
||||
assert.deepEqual(health.body.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]);
|
||||
assert.equal(health.body.runtime.durable, false);
|
||||
assert.equal(health.body.runtime.status, "degraded");
|
||||
logOk("health degraded DB contract");
|
||||
|
||||
const v1 = await requestJson(`${baseUrl}/v1`);
|
||||
assert.equal(v1.response.status, 200);
|
||||
assert.equal(v1.body.status, "degraded");
|
||||
for (const method of [
|
||||
"gateway.session.register",
|
||||
"box.resource.register",
|
||||
"box.capability.report",
|
||||
"hardware.operation.request",
|
||||
"hardware.invoke.shell",
|
||||
"audit.event.query",
|
||||
"evidence.record.query"
|
||||
]) {
|
||||
assert.ok(v1.body.methods.includes(method), `missing ${method}`);
|
||||
}
|
||||
logOk("REST adapter method contract");
|
||||
|
||||
const projectId = "prj_cloud-api-runtime-smoke";
|
||||
const gatewaySessionId = "gws_cloud-api-runtime-smoke";
|
||||
const resourceId = "res_cloud-api-runtime-smoke";
|
||||
const capabilityId = "cap_cloud-api-runtime-smoke";
|
||||
|
||||
const gateway = await rpc(baseUrl, "req_cloud_api_runtime_gateway", "gateway.session.register", {
|
||||
projectId,
|
||||
gatewaySessionId,
|
||||
gatewayId: "gtw_cloud-api-runtime-smoke",
|
||||
serviceId: "hwlab-gateway-simu",
|
||||
endpoint: "http://127.0.0.1:7101"
|
||||
});
|
||||
assert.equal(gateway.registered, true);
|
||||
assert.equal(gateway.gatewaySession.gatewaySessionId, gatewaySessionId);
|
||||
assert.equal(gateway.auditEvent.action, "gateway.session.register");
|
||||
|
||||
const resource = await rpc(baseUrl, "req_cloud_api_runtime_box", "box.resource.register", {
|
||||
projectId,
|
||||
gatewaySessionId,
|
||||
resourceId,
|
||||
boxId: "box_cloud-api-runtime-smoke",
|
||||
resourceType: "board",
|
||||
state: "available",
|
||||
metadata: {
|
||||
ports: ["shell0"]
|
||||
}
|
||||
});
|
||||
assert.equal(resource.registered, true);
|
||||
assert.equal(resource.resource.resourceId, resourceId);
|
||||
|
||||
const capability = await rpc(baseUrl, "req_cloud_api_runtime_capability", "box.capability.report", {
|
||||
capabilityId,
|
||||
resourceId,
|
||||
projectId,
|
||||
name: "shell.exec",
|
||||
direction: "bidirectional",
|
||||
valueType: "object",
|
||||
mutatesState: true
|
||||
});
|
||||
assert.equal(capability.reported, true);
|
||||
assert.equal(capability.capabilities[0].capabilityId, capabilityId);
|
||||
logOk("gateway/box/capability write path");
|
||||
|
||||
const invoke = await rpc(baseUrl, "req_cloud_api_runtime_shell", "hardware.invoke.shell", {
|
||||
projectId,
|
||||
gatewaySessionId,
|
||||
resourceId,
|
||||
capabilityId,
|
||||
input: {
|
||||
command: "echo hwlab"
|
||||
}
|
||||
});
|
||||
assert.equal(invoke.accepted, true);
|
||||
assert.equal(invoke.operation.status, "accepted");
|
||||
assert.equal(invoke.dispatch.shellExecuted, false);
|
||||
assert.equal(invoke.dispatch.dispatchStatus, "not_connected");
|
||||
assert.equal(invoke.evidenceRecord.kind, "trace");
|
||||
assert.equal(invoke.evidenceRecord.serviceId, "hwlab-cloud-api");
|
||||
assert.match(invoke.evidenceRecord.sha256, /^[a-f0-9]{64}$/);
|
||||
logOk("hardware invoke shell degraded dispatch with evidence");
|
||||
|
||||
const audit = await rpc(baseUrl, "req_cloud_api_runtime_audit_query", "audit.event.query", {
|
||||
projectId
|
||||
});
|
||||
assert.ok(audit.count >= 5);
|
||||
assert.ok(audit.events.every((event) => event.serviceId === "hwlab-cloud-api"));
|
||||
assert.ok(audit.events.every((event) => event.environment === ENVIRONMENT_DEV));
|
||||
assert.ok(audit.events.some((event) => event.action === "hardware.operation.request"));
|
||||
assert.ok(audit.events.some((event) => event.action === "hardware.invoke.shell"));
|
||||
|
||||
const evidence = await rpc(baseUrl, "req_cloud_api_runtime_evidence_query", "evidence.record.query", {
|
||||
projectId
|
||||
});
|
||||
assert.equal(evidence.count, 1);
|
||||
assert.equal(evidence.records[0].operationId, invoke.operationId);
|
||||
assert.equal(evidence.records[0].metadata.dispatchStatus, "not_connected");
|
||||
logOk("audit/evidence query shape");
|
||||
|
||||
process.stdout.write("[cloud-api-runtime-smoke] passed\n");
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
restoreEnv();
|
||||
}
|
||||
}
|
||||
|
||||
function restoreEnv() {
|
||||
for (const [name, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await run();
|
||||
@@ -151,9 +151,10 @@ async function smokeCloudApi() {
|
||||
assert.equal(health.response.status, 200);
|
||||
assert.equal(health.body.serviceId, "hwlab-cloud-api");
|
||||
assert.equal(health.body.environment, ENVIRONMENT_DEV);
|
||||
assert.equal(health.body.status, "ok");
|
||||
assert.equal(health.body.status, "degraded");
|
||||
assert.equal(health.body.db.connected, false);
|
||||
assert.equal(health.body.db.status, "blocked");
|
||||
assert.equal(health.body.runtime.durable, false);
|
||||
assert.deepEqual(health.body.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]);
|
||||
assert.equal(health.body.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db");
|
||||
assert.equal(health.body.db.secretRefs[0].redacted, true);
|
||||
@@ -164,9 +165,13 @@ async function smokeCloudApi() {
|
||||
|
||||
const adapter = await requestJson(`${baseUrl}/v1`);
|
||||
assert.equal(adapter.response.status, 200);
|
||||
assert.equal(adapter.body.status, "skeleton");
|
||||
assert.equal(adapter.body.status, "degraded");
|
||||
assert.ok(adapter.body.methods.includes("system.health"));
|
||||
assert.ok(adapter.body.methods.includes("gateway.session.register"));
|
||||
assert.ok(adapter.body.methods.includes("box.capability.report"));
|
||||
assert.ok(adapter.body.methods.includes("hardware.operation.request"));
|
||||
assert.ok(adapter.body.methods.includes("audit.event.query"));
|
||||
assert.ok(adapter.body.methods.includes("evidence.record.query"));
|
||||
|
||||
const rpcHealth = await requestJson(`${baseUrl}/rpc`, {
|
||||
method: "POST",
|
||||
@@ -174,11 +179,75 @@ async function smokeCloudApi() {
|
||||
});
|
||||
assert.equal(rpcHealth.response.status, 200);
|
||||
assert.equal(rpcHealth.body.jsonrpc, "2.0");
|
||||
assert.equal(rpcHealth.body.result.status, "ok");
|
||||
assert.equal(rpcHealth.body.result.status, "degraded");
|
||||
assert.equal(rpcHealth.body.result.serviceId, "hwlab-cloud-api");
|
||||
assert.equal(rpcHealth.body.result.db.status, "blocked");
|
||||
assert.equal(rpcHealth.body.meta.serviceId, "hwlab-cloud-api");
|
||||
|
||||
const projectId = "prj_mvp_topology";
|
||||
const gatewaySessionId = "gws_m1_runtime";
|
||||
const resourceId = "res_m1_runtime";
|
||||
const capabilityId = "cap_m1_runtime";
|
||||
|
||||
const rpcGateway = await requestJson(`${baseUrl}/rpc`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
rpcEnvelope({
|
||||
id: "req_m1_gateway",
|
||||
method: "gateway.session.register",
|
||||
params: {
|
||||
projectId,
|
||||
gatewaySessionId,
|
||||
gatewayId: "gtw_m1_runtime",
|
||||
serviceId: "hwlab-gateway-simu",
|
||||
endpoint: `http://127.0.0.1:${port}/gateway`
|
||||
}
|
||||
})
|
||||
)
|
||||
});
|
||||
assert.equal(rpcGateway.response.status, 200);
|
||||
assert.equal(rpcGateway.body.result.registered, true);
|
||||
|
||||
const rpcBox = await requestJson(`${baseUrl}/rpc`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
rpcEnvelope({
|
||||
id: "req_m1_box",
|
||||
method: "box.resource.register",
|
||||
params: {
|
||||
projectId,
|
||||
gatewaySessionId,
|
||||
resourceId,
|
||||
boxId: "box_m1_runtime",
|
||||
resourceType: "board"
|
||||
}
|
||||
})
|
||||
)
|
||||
});
|
||||
assert.equal(rpcBox.response.status, 200);
|
||||
assert.equal(rpcBox.body.result.registered, true);
|
||||
|
||||
const rpcCapability = await requestJson(`${baseUrl}/rpc`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
rpcEnvelope({
|
||||
id: "req_m1_capability",
|
||||
method: "box.capability.report",
|
||||
params: {
|
||||
capabilityId,
|
||||
resourceId,
|
||||
projectId,
|
||||
name: "shell.exec",
|
||||
direction: "bidirectional",
|
||||
valueType: "object",
|
||||
mutatesState: true
|
||||
}
|
||||
})
|
||||
)
|
||||
});
|
||||
assert.equal(rpcCapability.response.status, 200);
|
||||
assert.equal(rpcCapability.body.result.reported, true);
|
||||
|
||||
const rpcHardware = await requestJson(`${baseUrl}/rpc`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
@@ -186,17 +255,24 @@ async function smokeCloudApi() {
|
||||
id: "req_m1_hardware",
|
||||
method: "hardware.operation.request",
|
||||
params: {
|
||||
projectId: "prj_mvp_topology"
|
||||
projectId,
|
||||
gatewaySessionId,
|
||||
resourceId,
|
||||
capabilityId,
|
||||
input: {
|
||||
command: "echo m1"
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
});
|
||||
assert.equal(rpcHardware.response.status, 200);
|
||||
assert.equal(rpcHardware.body.result.accepted, false);
|
||||
assert.equal(rpcHardware.body.result.status, "skeleton");
|
||||
assert.equal(rpcHardware.body.result.audit.source.serviceId, "hwlab-cloud-api");
|
||||
assert.equal(rpcHardware.body.result.accepted, true);
|
||||
assert.equal(rpcHardware.body.result.status, "accepted");
|
||||
assert.equal(rpcHardware.body.result.auditEvent.serviceId, "hwlab-cloud-api");
|
||||
assert.equal(rpcHardware.body.result.persistence.durable, false);
|
||||
|
||||
logOk("cloud-api health/live/rpc skeleton");
|
||||
logOk("cloud-api health/live/rpc runtime");
|
||||
}
|
||||
|
||||
async function smokePatchPanelAndSimulators() {
|
||||
@@ -283,7 +359,7 @@ async function smokePatchPanelAndSimulators() {
|
||||
observedAt: routed.body.deliveries[0].observedAt
|
||||
});
|
||||
|
||||
logOk("patch-panel/sim status and route skeleton");
|
||||
logOk("patch-panel/sim status and route runtime");
|
||||
}
|
||||
|
||||
async function smokeCliDryRunFixture() {
|
||||
|
||||
@@ -244,10 +244,11 @@ async function inspectContracts() {
|
||||
|
||||
function inspectCloudApiDbContract(deploy) {
|
||||
const env = deploy.services?.find((service) => service.serviceId === "hwlab-cloud-api")?.env ?? {};
|
||||
const configReady = Boolean(env.HWLAB_CLOUD_DB_URL && env.HWLAB_CLOUD_DB_SSL_MODE);
|
||||
const db = {
|
||||
status: env.HWLAB_CLOUD_DB_URL && env.HWLAB_CLOUD_DB_SSL_MODE ? "ready" : "blocked",
|
||||
ready: Boolean(env.HWLAB_CLOUD_DB_URL && env.HWLAB_CLOUD_DB_SSL_MODE),
|
||||
configReady: Boolean(env.HWLAB_CLOUD_DB_URL && env.HWLAB_CLOUD_DB_SSL_MODE),
|
||||
status: configReady ? "degraded" : "blocked",
|
||||
ready: false,
|
||||
configReady,
|
||||
connected: false,
|
||||
connectionChecked: false,
|
||||
fields: [
|
||||
|
||||
Reference in New Issue
Block a user