feat: establish standalone Device Core repository
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
docs
|
||||
node_modules
|
||||
**/test
|
||||
**/*.log
|
||||
**/*.prev-*
|
||||
**/*.next-*
|
||||
runtime
|
||||
secrets
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
__pycache__/
|
||||
*.pyc
|
||||
runtime-data/
|
||||
deploy-artifacts/
|
||||
infra/deploy-artifacts/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
secrets/
|
||||
enrollment/
|
||||
runtime/
|
||||
vendor/
|
||||
@@ -0,0 +1,69 @@
|
||||
# NODE.DC Device Core
|
||||
|
||||
Device Core is the product-owned source repository for the universal NODE.DC
|
||||
device control plane. It owns the Device Manager UI/BFF, provider-neutral
|
||||
device runtime, adapters, edge channel, deployment descriptors and canonical
|
||||
artifact builders for the `device-plane` and `device-edge-vps` components.
|
||||
|
||||
The repository boundary does **not** change the production boundary:
|
||||
|
||||
- Synology live root remains `/volume1/docker/nodedc-device-plane`;
|
||||
- Compose project remains `nodedc-device-plane`;
|
||||
- deploy artifacts keep `component=device-plane` or `component=device-edge-vps`;
|
||||
- runtime databases, volumes, secrets, mTLS identity and edge registrations are
|
||||
preserved and are never stored in Git;
|
||||
- the root-owned `nodedc-deploy` runner and component registry remain owned by
|
||||
`NODEDC_PLATFORM`;
|
||||
- Hub/Authentik authorization, Launcher service grants and the platform public
|
||||
reverse-proxy route remain owned by `NODEDC_PLATFORM`;
|
||||
- the shared UI canon remains owned by `NODEDC_DESIGN_GUIDELINE`.
|
||||
|
||||
## Source layout
|
||||
|
||||
- `apps/device-manager` — Device Manager browser app and server-owned BFF;
|
||||
- `packages/*` — protocol, adapter and edge-channel contracts;
|
||||
- `services/*` — control core, gateway and edge runtimes;
|
||||
- `deployment/*` — immutable release/bootstrap descriptor templates;
|
||||
- `vps/*` — reviewed VPS process, firewall and systemd definitions;
|
||||
- `infra/deploy-runner/*` — Device Core artifact builders and builder tests;
|
||||
- `docker-compose.*.yml` — reviewed runtime topologies.
|
||||
|
||||
## Canonical checkout topology
|
||||
|
||||
The Device Manager consumes the canonical UI packages directly from the
|
||||
sibling Design Guideline repository; their source is intentionally not copied
|
||||
here:
|
||||
|
||||
```text
|
||||
NODEDC/
|
||||
├── NODEDC_DEVICE_CORE/
|
||||
├── NODEDC_DESIGN_GUIDELINE/
|
||||
└── platform/
|
||||
```
|
||||
|
||||
Install and verify from this repository root:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm run typecheck
|
||||
npm test
|
||||
```
|
||||
|
||||
`npm run test:deploy` validates the product-owned artifact builders. The
|
||||
platform runner registry is validated separately in `NODEDC_PLATFORM`.
|
||||
|
||||
## Deployment ownership
|
||||
|
||||
Artifact builders in `infra/deploy-runner` emit data-only tarballs. They never
|
||||
orchestrate Docker or mutate a live host. Promotion remains the established
|
||||
two-step workflow:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/nodedc-deploy plan /volume1/docker/nodedc-deploy/inbox/<artifact>.tgz
|
||||
sudo /usr/local/sbin/nodedc-deploy apply /volume1/docker/nodedc-deploy/inbox/<artifact>.tgz
|
||||
```
|
||||
|
||||
See [Repository boundary](docs/REPOSITORY_BOUNDARY.md) and
|
||||
[Implementation baseline](docs/IMPLEMENTATION_BASELINE.md) for the security,
|
||||
runtime and rollout constraints.
|
||||
@@ -0,0 +1,60 @@
|
||||
# NODE.DC Device Manager
|
||||
|
||||
Standalone Device Core application shell for Hub-authenticated device administration.
|
||||
It is intentionally vendor-neutral: adapters and model profiles describe protocol-specific
|
||||
behavior; projects, inventory, collections and access remain shared Device Core concepts.
|
||||
|
||||
## Runtime boundary
|
||||
|
||||
- The browser talks only to the Device Manager BFF under `/api/device-manager/*`.
|
||||
- Launcher consumes the one-time handoff and periodically revalidates the process-local,
|
||||
opaque Device Manager cookie.
|
||||
- The BFF derives the Core actor from that trusted Hub identity. Browser-supplied role,
|
||||
group or owner headers are ignored.
|
||||
- The BFF reads the Core bearer token from `NODEDC_DEVICE_CORE_TOKEN_FILE`; the token is
|
||||
never embedded into client assets or accepted as a raw environment value.
|
||||
- Device Control Core owns authorization, lifecycle validation, idempotency and persistence.
|
||||
- Query responses contain masked identifiers and bounded metadata only. Identifier and
|
||||
credential digests, external approval proofs, command parameters/transport refs, raw
|
||||
configuration documents and audit payloads stay inside Device Control Core.
|
||||
|
||||
The project workspace covers inventory, discovery, collections, adapter/profile metadata,
|
||||
Edges, routes, sessions, bindings, configuration state, the honest command ledger, immutable
|
||||
audit metadata and project grants. Navigation and actions are derived from effective project
|
||||
capabilities. Global adapter/profile/Edge mutation is additionally restricted to a Hub owner.
|
||||
|
||||
Command planning and transport intentionally have no Device Manager mutation route yet.
|
||||
The UI never presents `sent` as success: `acknowledged` and `verified` remain different
|
||||
ledger states, and the disabled transport policy is visible in the Commands section.
|
||||
|
||||
Hub currently supplies identity and groups but no signed company-membership/owner-scope
|
||||
claim. Therefore an admin may create projects in their personal scope. Existing company
|
||||
projects remain visible through explicit project grants, but company project creation stays
|
||||
closed until Hub extends the handoff contract.
|
||||
|
||||
## Local source preview
|
||||
|
||||
The preview store starts empty and exists only to exercise the shell without a deployed Core.
|
||||
All visible resources must still be created through the same command-shaped BFF endpoints.
|
||||
It is forbidden when `NODE_ENV=production`.
|
||||
|
||||
```sh
|
||||
NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW=1 \
|
||||
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED=0 \
|
||||
npm run build --workspace @nodedc/device-manager
|
||||
|
||||
NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW=1 \
|
||||
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED=0 \
|
||||
npm run serve --workspace @nodedc/device-manager
|
||||
```
|
||||
|
||||
Production additionally requires:
|
||||
|
||||
- `NODEDC_LAUNCHER_BASE_URL`
|
||||
- `NODEDC_LAUNCHER_INTERNAL_URL`
|
||||
- `NODEDC_INTERNAL_ACCESS_TOKEN` or `NODEDC_PLATFORM_SERVICE_TOKEN`
|
||||
- `NODEDC_DEVICE_CORE_INTERNAL_URL`
|
||||
- `NODEDC_DEVICE_CORE_TOKEN_FILE`
|
||||
|
||||
The application source does not create a Hub service entry, DNS record, reverse proxy,
|
||||
database or deployment artifact. Those remain explicit infrastructure phases.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#101114" />
|
||||
<title>NODE.DC Device Core</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@nodedc/device-manager",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"test": "node --test server/*.test.mjs",
|
||||
"serve": "node server/device-manager-server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55"><defs><style>.cls-1{fill:#e2e1e1;}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:0.75px;}</style></defs><path class="cls-1" d="M52.8,23.61,46.92,33.76,41.05,23.61H52.8m18-10.39H23.06L46.92,54.55Z"/><polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/><path class="cls-2" d="M116.35,18.49V1h1.27l10.34,15V1h1.33V18.49H128l-10.34-15v15Z"/><path class="cls-2" d="M140.43,18.64c-4.79,0-8.16-3.72-8.16-8.89S135.64.86,140.43.86s8.17,3.72,8.17,8.89S145.25,18.64,140.43,18.64Zm0-1.25c4,0,6.79-3.17,6.79-7.64s-2.77-7.64-6.79-7.64-6.77,3.17-6.77,7.64S136.44,17.39,140.43,17.39Z"/><path class="cls-2" d="M151.6,18.49V1h5.1c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74ZM153,17.24h3.75c4.77,0,7.42-2.92,7.42-7.49s-2.65-7.49-7.42-7.49H153Z"/><path class="cls-2" d="M168.49,1h10.77V2.26h-9.42V8.93h7.89v1.25h-7.89v7.06h9.74v1.25H168.49Z"/><path class="cls-2" d="M188.88,18.49V1H194c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74Zm1.35-1.25H194c4.77,0,7.41-2.92,7.41-7.49S198.75,2.26,194,2.26h-3.75Z"/><path class="cls-2" d="M205.15,9.75c0-5.24,3.19-8.89,8.11-8.89a6.8,6.8,0,0,1,7.1,5.52h-1.43a5.54,5.54,0,0,0-5.74-4.27c-4.05,0-6.64,3.17-6.64,7.64s2.54,7.64,6.59,7.64a5.46,5.46,0,0,0,5.74-4.29h1.43c-.75,3.52-3.4,5.54-7.15,5.54C208.27,18.64,205.15,15.05,205.15,9.75Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg id="nodedc-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93.22 54.55"><path fill="#e2e1e1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/><polygon fill="#e2e1e1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/></svg>
|
||||
|
After Width: | Height: | Size: 315 B |
@@ -0,0 +1,919 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const commandRoutes = new Map([
|
||||
["owner-scopes:ensure", "/internal/v1/management/owner-scopes:ensure"],
|
||||
["projects:ensure", "/internal/v1/management/projects:ensure"],
|
||||
["collections:ensure", "/internal/v1/management/collections:ensure"],
|
||||
["project-grants:upsert", "/internal/v1/management/project-grants:upsert"],
|
||||
["adapter-packages:ensure", "/internal/v1/management/adapter-packages:ensure"],
|
||||
["adapter-versions:register", "/internal/v1/management/adapter-versions:register"],
|
||||
["model-profiles:register", "/internal/v1/management/model-profiles:register"],
|
||||
["edges:ensure", "/internal/v1/management/edges:ensure"],
|
||||
["routes:ensure", "/internal/v1/management/routes:ensure"],
|
||||
["enrollment-intents:ensure", "/internal/v1/management/enrollment-intents:ensure"],
|
||||
["devices:claim", "/internal/v1/management/devices:claim"],
|
||||
["devices:update", "/internal/v1/management/devices:update"],
|
||||
["device-bindings:ensure", "/internal/v1/management/device-bindings:ensure"],
|
||||
["device-bindings:revoke", "/internal/v1/management/device-bindings:revoke"],
|
||||
[
|
||||
"device-configuration-revisions:create",
|
||||
"/internal/v1/management/device-configuration-revisions:create",
|
||||
],
|
||||
[
|
||||
"device-configurations:set-desired",
|
||||
"/internal/v1/management/device-configurations:set-desired",
|
||||
],
|
||||
["commands:service-ping", "/internal/v1/commands:service-ping"],
|
||||
]);
|
||||
|
||||
export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {}) {
|
||||
const endpoint = normalizeBaseUrl(baseUrl);
|
||||
if (typeof token !== "string" || token.length < 32) {
|
||||
throw serviceError("device_core_token_invalid", 503);
|
||||
}
|
||||
|
||||
async function request(pathname, actor, init = {}) {
|
||||
const response = await fetchImpl(new URL(pathname, endpoint), {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...actorHeaders(actor),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok || body?.ok !== true) {
|
||||
throw serviceError(
|
||||
safeCoreError(body?.error),
|
||||
response.status >= 400 && response.status < 600 ? response.status : 502,
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
async listProjects(actor) {
|
||||
return request("/internal/v1/query/projects", actor)
|
||||
.then((body) => body.projects);
|
||||
},
|
||||
async getWorkspace(actor, projectRef) {
|
||||
const projectId = entityId(projectRef, "project");
|
||||
return request(`/internal/v1/query/projects/${projectId}/workspace`, actor)
|
||||
.then((body) => body.workspace);
|
||||
},
|
||||
async execute(command, actor, input, idempotencyKey) {
|
||||
const pathname = commandRoutes.get(command);
|
||||
if (!pathname) throw serviceError("device_manager_command_invalid", 404);
|
||||
if (!/^[\x21-\x7e]{8,256}$/.test(idempotencyKey || "")) {
|
||||
throw serviceError("device_idempotency_key_invalid", 400);
|
||||
}
|
||||
return request(pathname, actor, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": idempotencyKey,
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
}).then(({ replayed, result }) => ({ replayed, result }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
|
||||
const ownerScopes = new Map();
|
||||
const projects = new Map();
|
||||
const collections = new Map();
|
||||
const adapterPackages = new Map();
|
||||
const adapterVersions = new Map();
|
||||
const modelProfiles = new Map();
|
||||
const edges = new Map();
|
||||
const routes = new Map();
|
||||
const enrollments = new Map();
|
||||
const devices = new Map();
|
||||
const sessions = new Map();
|
||||
const bindings = new Map();
|
||||
const grants = new Map();
|
||||
const configurationRevisions = new Map();
|
||||
const configurationStates = new Map();
|
||||
const auditEvents = [];
|
||||
const commands = new Map();
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function projectValues(store, projectRef) {
|
||||
return [...store.values()].filter((value) => value.projectRef === projectRef);
|
||||
}
|
||||
|
||||
function audit(actor, projectRef, eventType, refs = {}) {
|
||||
auditEvents.unshift({
|
||||
auditEventRef: `audit-event:${randomUUID()}`,
|
||||
eventType,
|
||||
actorRef: actor.userRef,
|
||||
deviceRef: refs.deviceRef ?? null,
|
||||
discoveryRef: refs.discoveryRef ?? null,
|
||||
projectRef,
|
||||
occurredAt: now(),
|
||||
});
|
||||
}
|
||||
|
||||
function projectSummary(project) {
|
||||
const projectCollections = [...collections.values()]
|
||||
.filter((collection) => collection.projectRef === project.projectRef);
|
||||
return {
|
||||
...project,
|
||||
counts: {
|
||||
devices: projectValues(devices, project.projectRef).length,
|
||||
collections: projectCollections.length,
|
||||
discoveries: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(projectRef) {
|
||||
const project = projects.get(projectRef);
|
||||
if (!project) throw serviceError("device_project_not_found", 404);
|
||||
return {
|
||||
project: projectSummary(project),
|
||||
devices: projectValues(devices, projectRef)
|
||||
.map(({ projectRef: _projectRef, ...device }) => device),
|
||||
discoveries: [],
|
||||
enrollments: projectValues(enrollments, projectRef),
|
||||
collections: projectValues(collections, projectRef)
|
||||
.map(({ projectRef: _projectRef, ...collection }) => collection),
|
||||
adapterPackages: [...adapterPackages.values()],
|
||||
adapterVersions: [...adapterVersions.values()],
|
||||
modelProfiles: [...modelProfiles.values()],
|
||||
edges: [...edges.values()],
|
||||
routes: projectValues(routes, projectRef),
|
||||
sessions: projectValues(sessions, projectRef)
|
||||
.map(({ projectRef: _projectRef, ...session }) => session),
|
||||
bindings: projectValues(bindings, projectRef),
|
||||
configurationRevisions: projectValues(configurationRevisions, projectRef),
|
||||
configurationStates: projectValues(configurationStates, projectRef),
|
||||
commands: projectValues(commands, projectRef),
|
||||
auditEvents: auditEvents.filter((event) => event.projectRef === projectRef),
|
||||
grants: projectValues(grants, projectRef),
|
||||
policies: {
|
||||
commandTransport: fixture === "arusnavi-b2"
|
||||
? "typed-service-ping-v1"
|
||||
: "disabled",
|
||||
commandPlanningApi: fixture === "arusnavi-b2" ? "enabled" : "disabled",
|
||||
identifierProjection: fixture === "arusnavi-b2" ? "authorized-full" : "masked-only",
|
||||
auditPayloadProjection: "metadata-only",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (fixture === "arusnavi-b2") seedArusnaviB2Preview({
|
||||
ownerScopes,
|
||||
projects,
|
||||
devices,
|
||||
modelProfiles,
|
||||
edges,
|
||||
routes,
|
||||
sessions,
|
||||
configurationStates,
|
||||
});
|
||||
else if (fixture != null && fixture !== "") {
|
||||
throw serviceError("device_manager_preview_fixture_invalid", 400);
|
||||
}
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
async listProjects() {
|
||||
return [...projects.values()].map(projectSummary);
|
||||
},
|
||||
async getWorkspace(_actor, projectRef) {
|
||||
return workspace(projectRef);
|
||||
},
|
||||
async execute(command, actor, input) {
|
||||
if (command === "commands:service-ping") {
|
||||
if (fixture !== "arusnavi-b2") {
|
||||
throw serviceError("device_command_transport_disabled", 409);
|
||||
}
|
||||
const device = devices.get(input.deviceRef);
|
||||
if (!device || device.projectRef !== input.projectRef) {
|
||||
throw serviceError("device_command_route_unavailable", 409);
|
||||
}
|
||||
if (typeof input.accessCode !== "string" || !/^\d{6}$/.test(input.accessCode)) {
|
||||
throw serviceError("device_service_ping_access_code_invalid", 400);
|
||||
}
|
||||
const commandRef = `command:${randomUUID()}`;
|
||||
const at = now();
|
||||
const view = {
|
||||
commandRef,
|
||||
projectRef: input.projectRef,
|
||||
deviceRef: input.deviceRef,
|
||||
deviceName: device.displayName,
|
||||
commandKey: `preview-service-ping-${randomUUID()}`,
|
||||
commandCatalogRef: "arusnavi.b2.internal.v1:service-ping",
|
||||
commandType: "service.ping",
|
||||
riskClass: "low",
|
||||
lifecycleState: "queued",
|
||||
plannedAt: at,
|
||||
expiresAt: new Date(Date.now() + Number(input.expiresInSeconds) * 1000).toISOString(),
|
||||
confirmedAt: null,
|
||||
dispatchedAt: null,
|
||||
acknowledgedAt: null,
|
||||
terminalAt: null,
|
||||
terminalReasonCode: null,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
};
|
||||
commands.set(commandRef, view);
|
||||
return { replayed: false, result: view };
|
||||
}
|
||||
if (command === "owner-scopes:ensure") {
|
||||
const key = `${input.scopeKind}:${input.ownerRef}`;
|
||||
const created = !ownerScopes.has(key);
|
||||
const scope = {
|
||||
ownerScopeRef: ownerScopes.get(key)?.ownerScopeRef || `owner-scope:${randomUUID()}`,
|
||||
scopeKind: input.scopeKind,
|
||||
ownerRef: input.ownerRef,
|
||||
displayName: input.displayName,
|
||||
lifecycleState: "active",
|
||||
};
|
||||
ownerScopes.set(key, scope);
|
||||
return { replayed: false, result: { created, ownerScope: scope } };
|
||||
}
|
||||
if (command === "projects:ensure") {
|
||||
const scope = ownerScopes.get(`${input.scopeKind}:${input.ownerRef}`);
|
||||
if (!scope) throw serviceError("device_owner_scope_not_found", 404);
|
||||
const existing = [...projects.values()].find((project) =>
|
||||
project.ownerScope.ownerRef === input.ownerRef
|
||||
&& project.projectKey === input.projectKey
|
||||
);
|
||||
const projectRef = existing?.projectRef || `project:${randomUUID()}`;
|
||||
const project = {
|
||||
projectRef,
|
||||
projectKey: input.projectKey,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
lifecycleState: "active",
|
||||
ownerScope: scope,
|
||||
access: { projectRole: "owner", capabilities: ownerCapabilities },
|
||||
counts: { devices: 0, collections: 0, discoveries: 0 },
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
projects.set(projectRef, project);
|
||||
if (!existing) {
|
||||
const grantRef = `grant:${randomUUID()}`;
|
||||
grants.set(grantRef, {
|
||||
grantRef,
|
||||
projectRef,
|
||||
principalKind: "user",
|
||||
principalRef: actor.userRef,
|
||||
projectRole: "owner",
|
||||
capabilityAllow: [],
|
||||
capabilityDeny: [],
|
||||
lifecycleState: "active",
|
||||
});
|
||||
audit(actor, projectRef, "project.created");
|
||||
}
|
||||
return { replayed: false, result: { created: !existing, project } };
|
||||
}
|
||||
if (command === "collections:ensure") {
|
||||
const projectRef = input.projectRef;
|
||||
if (!projects.has(projectRef)) throw serviceError("device_project_not_found", 404);
|
||||
const existing = [...collections.values()].find((collection) =>
|
||||
collection.projectRef === projectRef
|
||||
&& collection.collectionKey === input.collectionKey
|
||||
);
|
||||
const collectionRef = existing?.collectionRef || `collection:${randomUUID()}`;
|
||||
const collection = {
|
||||
collectionRef,
|
||||
projectRef,
|
||||
collectionKey: input.collectionKey,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
lifecycleState: "active",
|
||||
memberCount: existing?.memberCount || 0,
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
collections.set(collectionRef, collection);
|
||||
audit(actor, projectRef, createdEvent(existing, "collection"));
|
||||
return { replayed: false, result: { created: !existing, collection } };
|
||||
}
|
||||
if (command === "project-grants:upsert") {
|
||||
if (!projects.has(input.projectRef)) {
|
||||
throw serviceError("device_project_not_found", 404);
|
||||
}
|
||||
const existing = [...grants.values()].find((grant) =>
|
||||
grant.projectRef === input.projectRef
|
||||
&& grant.principalKind === input.principalKind
|
||||
&& grant.principalRef === input.principalRef
|
||||
);
|
||||
const grantRef = existing?.grantRef || `grant:${randomUUID()}`;
|
||||
const grant = {
|
||||
grantRef,
|
||||
projectRef: input.projectRef,
|
||||
principalKind: input.principalKind,
|
||||
principalRef: input.principalRef,
|
||||
projectRole: input.projectRole,
|
||||
capabilityAllow: input.capabilityAllow ?? [],
|
||||
capabilityDeny: input.capabilityDeny ?? [],
|
||||
lifecycleState: input.lifecycleState ?? "active",
|
||||
};
|
||||
grants.set(grantRef, grant);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "project_grant"));
|
||||
return { replayed: false, result: { created: !existing, grant } };
|
||||
}
|
||||
if (command === "adapter-packages:ensure") {
|
||||
requirePlatformOwner(actor);
|
||||
const existing = [...adapterPackages.values()].find(
|
||||
(entry) => entry.packageKey === input.packageKey,
|
||||
);
|
||||
const adapterPackageRef = existing?.adapterPackageRef
|
||||
|| `adapter-package:${randomUUID()}`;
|
||||
assertPreviewTransition(
|
||||
existing?.lifecycleState,
|
||||
input.lifecycleState ?? "active",
|
||||
previewTransitions.adapterPackage,
|
||||
"device_adapter_package_transition_invalid",
|
||||
);
|
||||
const adapterPackage = {
|
||||
adapterPackageRef,
|
||||
packageKey: input.packageKey,
|
||||
displayName: input.displayName,
|
||||
publisherRef: input.publisherRef,
|
||||
lifecycleState: input.lifecycleState ?? "active",
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
adapterPackages.set(adapterPackageRef, adapterPackage);
|
||||
return { replayed: false, result: { created: !existing, adapterPackage } };
|
||||
}
|
||||
if (command === "adapter-versions:register") {
|
||||
requirePlatformOwner(actor);
|
||||
const adapterPackage = adapterPackages.get(input.adapterPackageRef);
|
||||
if (!adapterPackage) {
|
||||
throw serviceError("device_adapter_package_not_found", 404);
|
||||
}
|
||||
if (adapterPackage.lifecycleState !== "active") {
|
||||
throw serviceError("device_adapter_package_inactive", 409);
|
||||
}
|
||||
const existing = [...adapterVersions.values()].find((entry) =>
|
||||
entry.adapterPackageRef === input.adapterPackageRef
|
||||
&& entry.version === input.version
|
||||
);
|
||||
const adapterVersionRef = existing?.adapterVersionRef
|
||||
|| `adapter-version:${randomUUID()}`;
|
||||
assertPreviewTransition(
|
||||
existing?.lifecycleState,
|
||||
input.lifecycleState ?? "draft",
|
||||
previewTransitions.catalogVersion,
|
||||
"device_adapter_version_transition_invalid",
|
||||
);
|
||||
const adapterVersion = {
|
||||
adapterVersionRef,
|
||||
adapterPackageRef: input.adapterPackageRef,
|
||||
version: input.version,
|
||||
runtimePackageRef: input.runtimePackageRef,
|
||||
contentDigest: input.contentDigest,
|
||||
contractVersion: input.contractVersion,
|
||||
capabilities: input.capabilities ?? [],
|
||||
lifecycleState: input.lifecycleState ?? "draft",
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
adapterVersions.set(adapterVersionRef, adapterVersion);
|
||||
return { replayed: false, result: { created: !existing, adapterVersion } };
|
||||
}
|
||||
if (command === "model-profiles:register") {
|
||||
requirePlatformOwner(actor);
|
||||
const adapterVersion = adapterVersions.get(input.adapterVersionRef);
|
||||
if (!adapterVersion) {
|
||||
throw serviceError("device_adapter_version_not_found", 404);
|
||||
}
|
||||
const existing = modelProfiles.get(input.profileRef);
|
||||
const lifecycleState = input.lifecycleState ?? "draft";
|
||||
if (lifecycleState === "active" && adapterVersion.lifecycleState !== "active") {
|
||||
throw serviceError("device_model_profile_adapter_not_active", 409);
|
||||
}
|
||||
assertPreviewTransition(
|
||||
existing?.lifecycleState,
|
||||
lifecycleState,
|
||||
previewTransitions.catalogVersion,
|
||||
"device_model_profile_transition_invalid",
|
||||
);
|
||||
const modelProfile = {
|
||||
modelProfileRef: input.profileRef,
|
||||
adapterVersionRef: input.adapterVersionRef,
|
||||
schemaVersion: input.schemaVersion,
|
||||
vendor: input.vendor,
|
||||
model: input.model,
|
||||
deviceType: input.deviceType,
|
||||
protocol: input.protocol,
|
||||
schemaArtifactRef: input.schemaArtifactRef,
|
||||
profileDigest: input.profileDigest,
|
||||
capabilities: input.capabilities ?? [],
|
||||
lifecycleState,
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
modelProfiles.set(input.profileRef, modelProfile);
|
||||
return { replayed: false, result: { created: !existing, modelProfile } };
|
||||
}
|
||||
if (command === "edges:ensure") {
|
||||
requirePlatformOwner(actor);
|
||||
const existing = [...edges.values()].find(
|
||||
(entry) => entry.edgeKey === input.edgeKey,
|
||||
);
|
||||
const edgeRef = existing?.edgeRef || `edge:${randomUUID()}`;
|
||||
assertPreviewTransition(
|
||||
existing?.lifecycleState,
|
||||
input.lifecycleState ?? "provisioning",
|
||||
previewTransitions.edge,
|
||||
"device_edge_transition_invalid",
|
||||
);
|
||||
const edge = {
|
||||
edgeRef,
|
||||
edgeKey: input.edgeKey,
|
||||
displayName: input.displayName,
|
||||
deploymentRef: input.deploymentRef ?? null,
|
||||
lifecycleState: input.lifecycleState ?? "provisioning",
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
edges.set(edgeRef, edge);
|
||||
return { replayed: false, result: { created: !existing, edge } };
|
||||
}
|
||||
if (command === "routes:ensure") {
|
||||
if (!projects.has(input.projectRef)) {
|
||||
throw serviceError("device_project_not_found", 404);
|
||||
}
|
||||
const edge = edges.get(input.edgeRef);
|
||||
const profile = modelProfiles.get(input.modelProfileRef);
|
||||
if (!edge) throw serviceError("device_edge_not_found", 404);
|
||||
if (!profile) throw serviceError("device_model_profile_not_found", 404);
|
||||
const existing = projectValues(routes, input.projectRef).find(
|
||||
(entry) => entry.routeKey === input.routeKey,
|
||||
);
|
||||
const routeRef = existing?.routeRef || `route:${randomUUID()}`;
|
||||
const lifecycleState = input.lifecycleState ?? "draft";
|
||||
if (
|
||||
lifecycleState === "active"
|
||||
&& (edge.lifecycleState !== "active" || profile.lifecycleState !== "active")
|
||||
) {
|
||||
throw serviceError("device_route_dependency_not_active", 409);
|
||||
}
|
||||
assertPreviewTransition(
|
||||
existing?.lifecycleState,
|
||||
lifecycleState,
|
||||
previewTransitions.route,
|
||||
"device_route_transition_invalid",
|
||||
);
|
||||
const route = {
|
||||
routeRef,
|
||||
projectRef: input.projectRef,
|
||||
routeKey: input.routeKey,
|
||||
displayName: input.displayName,
|
||||
edgeRef: input.edgeRef,
|
||||
edgeName: edge.displayName,
|
||||
modelProfileRef: input.modelProfileRef,
|
||||
profileName: `${profile.vendor} ${profile.model}`,
|
||||
listenerRef: input.listenerRef,
|
||||
protocol: input.protocol,
|
||||
direction: input.direction ?? "telemetry",
|
||||
lifecycleState,
|
||||
sessionCount: 0,
|
||||
activeSessionCount: 0,
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
routes.set(routeRef, route);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "route"));
|
||||
return { replayed: false, result: { created: !existing, route } };
|
||||
}
|
||||
if (command === "device-bindings:ensure") {
|
||||
if (!projects.has(input.projectRef)) {
|
||||
throw serviceError("device_project_not_found", 404);
|
||||
}
|
||||
if (input.source?.kind !== "collection" || !collections.has(input.source.ref)) {
|
||||
throw serviceError("device_binding_source_not_found", 404);
|
||||
}
|
||||
const existing = projectValues(bindings, input.projectRef).find(
|
||||
(entry) => entry.bindingKey === input.bindingKey,
|
||||
);
|
||||
const bindingRef = existing?.bindingRef || `binding:${randomUUID()}`;
|
||||
const source = collections.get(input.source.ref);
|
||||
const binding = {
|
||||
bindingRef,
|
||||
projectRef: input.projectRef,
|
||||
bindingKey: input.bindingKey,
|
||||
displayName: input.displayName,
|
||||
source: {
|
||||
kind: input.source.kind,
|
||||
ref: input.source.ref,
|
||||
displayName: source.name,
|
||||
},
|
||||
target: { kind: input.targetKind, ref: input.targetRef },
|
||||
capabilities: input.capabilities,
|
||||
lifecycleState: "pending_external_approval",
|
||||
sourceApprovedAt: now(),
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
bindings.set(bindingRef, binding);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "device_binding"));
|
||||
return { replayed: false, result: { created: !existing, binding } };
|
||||
}
|
||||
if (command === "device-bindings:revoke") {
|
||||
const binding = bindings.get(input.bindingRef);
|
||||
if (!binding || binding.projectRef !== input.projectRef) {
|
||||
throw serviceError("device_binding_not_found", 404);
|
||||
}
|
||||
const revoked = { ...binding, lifecycleState: "revoked", updatedAt: now() };
|
||||
bindings.set(binding.bindingRef, revoked);
|
||||
audit(actor, input.projectRef, "device_binding.revoked");
|
||||
return { replayed: false, result: { revoked: true, binding: revoked } };
|
||||
}
|
||||
if (command === "device-configuration-revisions:create") {
|
||||
throw serviceError("device_not_found", 404);
|
||||
}
|
||||
if (command === "device-configurations:set-desired") {
|
||||
throw serviceError("device_configuration_revision_not_found", 404);
|
||||
}
|
||||
if (command === "enrollment-intents:ensure") {
|
||||
if (!projects.has(input.projectRef)) {
|
||||
throw serviceError("device_project_not_found", 404);
|
||||
}
|
||||
const route = routes.get(input.routeRef);
|
||||
if (!route || route.projectRef !== input.projectRef) {
|
||||
throw serviceError("device_route_not_found", 404);
|
||||
}
|
||||
if (route.lifecycleState !== "active") {
|
||||
throw serviceError("device_enrollment_route_inactive", 409);
|
||||
}
|
||||
if (route.modelProfileRef !== input.modelProfileRef) {
|
||||
throw serviceError("device_enrollment_profile_mismatch", 409);
|
||||
}
|
||||
if (
|
||||
input.identifier?.kind !== "imei"
|
||||
|| typeof input.identifier.value !== "string"
|
||||
|| !/^\d{15}$/.test(input.identifier.value)
|
||||
) {
|
||||
throw serviceError("restricted_identifier_imei_invalid", 400);
|
||||
}
|
||||
const existing = projectValues(enrollments, input.projectRef).find(
|
||||
(entry) => entry.enrollmentKey === input.enrollmentKey,
|
||||
);
|
||||
const enrollmentIntentRef = existing?.enrollmentIntentRef
|
||||
|| `enrollment-intent:${randomUUID()}`;
|
||||
const enrollment = {
|
||||
enrollmentIntentRef,
|
||||
projectRef: input.projectRef,
|
||||
enrollmentKey: input.enrollmentKey,
|
||||
displayName: input.displayName,
|
||||
routeRef: input.routeRef,
|
||||
modelProfileRef: input.modelProfileRef,
|
||||
expectedIdentifier: {
|
||||
kind: "imei",
|
||||
masked: `***********${input.identifier.value.slice(-4)}`,
|
||||
},
|
||||
lifecycleState: "pending",
|
||||
observedDiscoveryRef: null,
|
||||
claimedDeviceRef: null,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
enrollments.set(enrollmentIntentRef, enrollment);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "enrollment_intent"));
|
||||
const { expectedIdentifier, ...safeEnrollment } = enrollment;
|
||||
return {
|
||||
replayed: false,
|
||||
result: {
|
||||
created: !existing,
|
||||
enrollmentIntent: {
|
||||
...safeEnrollment,
|
||||
identifier: expectedIdentifier,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (command === "devices:claim") {
|
||||
throw serviceError("device_discovery_not_found", 404);
|
||||
}
|
||||
if (command === "devices:update") {
|
||||
const device = devices.get(input.deviceRef);
|
||||
if (!device || device.projectRef !== input.projectRef) {
|
||||
throw serviceError("device_not_found", 404);
|
||||
}
|
||||
if (typeof input.displayName !== "string" || !input.displayName.trim()) {
|
||||
throw serviceError("device_display_name_invalid", 400);
|
||||
}
|
||||
const updated = {
|
||||
...device,
|
||||
displayName: input.displayName.trim(),
|
||||
integrationDeviceId: typeof input.integrationDeviceId === "string"
|
||||
? input.integrationDeviceId.trim() || null
|
||||
: null,
|
||||
updatedAt: now(),
|
||||
};
|
||||
devices.set(input.deviceRef, updated);
|
||||
audit(actor, input.projectRef, "device.updated");
|
||||
return { replayed: false, result: { updated: true, device: updated } };
|
||||
}
|
||||
throw serviceError("device_manager_command_invalid", 404);
|
||||
},
|
||||
snapshot() {
|
||||
return {
|
||||
ownerScopes,
|
||||
projects,
|
||||
collections,
|
||||
adapterPackages,
|
||||
adapterVersions,
|
||||
modelProfiles,
|
||||
edges,
|
||||
routes,
|
||||
enrollments,
|
||||
devices,
|
||||
sessions,
|
||||
bindings,
|
||||
grants,
|
||||
configurationRevisions,
|
||||
configurationStates,
|
||||
commands,
|
||||
auditEvents,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createdEvent(existing, resource) {
|
||||
return `${resource}.${existing ? "updated" : "created"}`;
|
||||
}
|
||||
|
||||
function requirePlatformOwner(actor) {
|
||||
if (actor?.hubRole !== "owner") {
|
||||
throw serviceError("device_platform_catalog_access_denied", 403);
|
||||
}
|
||||
}
|
||||
|
||||
function seedArusnaviB2Preview({
|
||||
ownerScopes,
|
||||
projects,
|
||||
devices,
|
||||
modelProfiles,
|
||||
edges,
|
||||
routes,
|
||||
sessions,
|
||||
configurationStates,
|
||||
}) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const ownerScopeRef = "owner-scope:78da71d5-f48f-4de0-8e47-729f6d644151";
|
||||
const projectRef = "project:ad7b357c-c7ac-4bf8-a638-c7f956e9aa71";
|
||||
const deviceRef = "device:b6a55921-7888-44b5-a93e-241aa2fdd3d7";
|
||||
const edgeRef = "edge:73da0c42-a641-4559-b8f7-23509b60bfe9";
|
||||
const routeRef = "route:fef9b7a0-a462-4d68-9991-af026203368b";
|
||||
const sessionRef = "session:57ead610-47de-45f7-a42d-fbe4fa0aba38";
|
||||
const scope = {
|
||||
ownerScopeRef,
|
||||
scopeKind: "personal",
|
||||
ownerRef: "user:local-device-admin",
|
||||
displayName: "Local Device Admin",
|
||||
lifecycleState: "active",
|
||||
};
|
||||
ownerScopes.set("personal:user:local-device-admin", scope);
|
||||
projects.set(projectRef, {
|
||||
projectRef,
|
||||
projectKey: "arusnavi-b2-preview",
|
||||
name: "ARUSNAVI B2 preview",
|
||||
description: "Локальная визуальная фикстура пилотного ARUSNAVI B2",
|
||||
lifecycleState: "active",
|
||||
ownerScope: scope,
|
||||
access: { projectRole: "owner", capabilities: ownerCapabilities },
|
||||
counts: { devices: 1, collections: 0, discoveries: 0 },
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
modelProfiles.set("arusnavi.b2.internal.v1", {
|
||||
modelProfileRef: "arusnavi.b2.internal.v1",
|
||||
adapterVersionRef: null,
|
||||
schemaVersion: "1.0.0",
|
||||
vendor: "ARUSNAVI",
|
||||
model: "B2",
|
||||
deviceType: "tracker",
|
||||
protocol: "INTERNAL",
|
||||
schemaArtifactRef: "schema:arusnavi.b2.internal.v1",
|
||||
profileDigest: null,
|
||||
capabilities: ["telemetry", "configuration", "commands"],
|
||||
lifecycleState: "active",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
edges.set(edgeRef, {
|
||||
edgeRef,
|
||||
edgeKey: "preview-edge",
|
||||
displayName: "Preview VPS edge",
|
||||
deploymentRef: "deployment:preview",
|
||||
lifecycleState: "active",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
routes.set(routeRef, {
|
||||
routeRef,
|
||||
projectRef,
|
||||
routeKey: "preview-b2-route",
|
||||
displayName: "B2 direct preview",
|
||||
edgeRef,
|
||||
edgeName: "Preview VPS edge",
|
||||
modelProfileRef: "arusnavi.b2.internal.v1",
|
||||
profileName: "ARUSNAVI B2",
|
||||
listenerRef: "listener:preview",
|
||||
protocol: "INTERNAL",
|
||||
direction: "bidirectional",
|
||||
lifecycleState: "active",
|
||||
sessionCount: 1,
|
||||
activeSessionCount: 1,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
devices.set(deviceRef, {
|
||||
projectRef,
|
||||
deviceRef,
|
||||
deviceKey: "pilot-b2-preview",
|
||||
displayName: "Пилотный B2",
|
||||
integrationDeviceId: "8028",
|
||||
modelProfileRef: "arusnavi.b2.internal.v1",
|
||||
lifecycleState: "active",
|
||||
identifier: {
|
||||
kind: "imei",
|
||||
masked: "***********1088",
|
||||
value: "863151070211088",
|
||||
},
|
||||
session: { state: "online", lastSeenAt: timestamp },
|
||||
reported: {
|
||||
observedAt: timestamp,
|
||||
identity: {
|
||||
imei: "863151070211088",
|
||||
iccid1: "****************1111",
|
||||
iccid2: "****************2222",
|
||||
},
|
||||
firmware: { currentVersion: "0.02", appliedAt: timestamp, availableVersion: "0.05" },
|
||||
configuration: {
|
||||
monitoring: {
|
||||
servers: [
|
||||
{ host: "legacy.example.invalid", port: 20623, protocol: "INTERNAL", identity: "0" },
|
||||
{ host: "direct.example.invalid", port: 9921, protocol: "INTERNAL", identity: "0" },
|
||||
],
|
||||
},
|
||||
transmission: { navigation: { position: true, motion: true, hdop: false } },
|
||||
trajectory: {
|
||||
normal: { courseDeltaDegrees: 15, speedDeltaKph: 10, distanceMeters: 15, parkingIntervalSeconds: 15 },
|
||||
roaming: { courseDeltaDegrees: 20, speedDeltaKph: 50, distanceMeters: 1000, parkingIntervalSeconds: 300 },
|
||||
},
|
||||
navigation: {
|
||||
sources: { satellite: true, wifi: false, lbs: false, tag: false },
|
||||
constellations: { gps: true, glonass: true, galileo: false, beidou: false },
|
||||
filter: { minimumSatellites: 4, maximumHdopTimesTen: 30 },
|
||||
},
|
||||
},
|
||||
telemetry: {
|
||||
navigation: {
|
||||
latitude: "55.7500",
|
||||
longitude: "37.6200",
|
||||
speedKph: 18,
|
||||
altitudeMeters: 156,
|
||||
satellites: 12,
|
||||
courseDegrees: 84,
|
||||
hdop: 1.2,
|
||||
},
|
||||
gsm: { signal: 79, operator: "preview", lac: "masked", cid: "masked" },
|
||||
system: { externalVoltageMv: 13240, internalVoltageMv: 4120, status: "Норма" },
|
||||
},
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
sessions.set(sessionRef, {
|
||||
sessionRef,
|
||||
projectRef,
|
||||
routeRef,
|
||||
routeName: "B2 direct preview",
|
||||
deviceRef,
|
||||
deviceName: "Пилотный B2",
|
||||
protocol: "INTERNAL",
|
||||
lifecycleState: "online",
|
||||
connectedAt: timestamp,
|
||||
lastSeenAt: timestamp,
|
||||
disconnectedAt: null,
|
||||
closeReasonCode: null,
|
||||
frameCount: 1842,
|
||||
byteCount: 734208,
|
||||
});
|
||||
configurationStates.set(deviceRef, {
|
||||
projectRef,
|
||||
deviceRef,
|
||||
deviceName: "Пилотный B2",
|
||||
desiredConfigurationRevisionRef: null,
|
||||
appliedConfigurationRevisionRef: null,
|
||||
appliedAt: null,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
const previewTransitions = Object.freeze({
|
||||
adapterPackage: Object.freeze({
|
||||
active: Object.freeze(["active", "retired"]),
|
||||
retired: Object.freeze(["retired"]),
|
||||
}),
|
||||
catalogVersion: Object.freeze({
|
||||
draft: Object.freeze(["draft", "active", "retired"]),
|
||||
active: Object.freeze(["active", "retired"]),
|
||||
retired: Object.freeze(["retired"]),
|
||||
}),
|
||||
edge: Object.freeze({
|
||||
provisioning: Object.freeze(["provisioning", "active", "retired"]),
|
||||
active: Object.freeze(["active", "suspended", "retired"]),
|
||||
suspended: Object.freeze(["suspended", "active", "retired"]),
|
||||
retired: Object.freeze(["retired"]),
|
||||
}),
|
||||
route: Object.freeze({
|
||||
draft: Object.freeze(["draft", "active", "retired"]),
|
||||
active: Object.freeze(["active", "suspended", "retired"]),
|
||||
suspended: Object.freeze(["suspended", "active", "retired"]),
|
||||
retired: Object.freeze(["retired"]),
|
||||
}),
|
||||
});
|
||||
|
||||
function assertPreviewTransition(previous, next, transitions, code) {
|
||||
if (!previous) return;
|
||||
if (!transitions[previous]?.includes(next)) {
|
||||
throw serviceError(code, 409);
|
||||
}
|
||||
}
|
||||
|
||||
const ownerCapabilities = Object.freeze([
|
||||
"project.read",
|
||||
"project.manage",
|
||||
"access.manage",
|
||||
"inventory.read",
|
||||
"device.enroll",
|
||||
"device.claim",
|
||||
"device.transfer",
|
||||
"collection.manage",
|
||||
"route.manage",
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"configuration.read",
|
||||
"configuration.manage",
|
||||
"command.plan",
|
||||
"command.confirm",
|
||||
"command.dispatch",
|
||||
"credential.manage",
|
||||
"audit.read",
|
||||
]);
|
||||
|
||||
function actorHeaders(actor) {
|
||||
if (!actor || typeof actor !== "object") throw serviceError("device_actor_required", 401);
|
||||
return {
|
||||
"X-NODEDC-User-Ref": actor.userRef,
|
||||
"X-NODEDC-Hub-Role": actor.hubRole,
|
||||
"X-NODEDC-Group-Refs": (actor.groupRefs ?? []).join(","),
|
||||
"X-NODEDC-Owner-Scopes": (actor.ownerScopes ?? [])
|
||||
.map((scope) => `${scope.scopeKind}=${scope.ownerRef}`)
|
||||
.join(","),
|
||||
};
|
||||
}
|
||||
|
||||
function entityId(value, prefix) {
|
||||
const match = String(value || "").match(new RegExp(
|
||||
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
|
||||
"i",
|
||||
));
|
||||
if (!match) throw serviceError(`device_${prefix}_ref_invalid`, 400);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw serviceError("device_core_url_required", 503);
|
||||
}
|
||||
const url = new URL(value);
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
|
||||
throw serviceError("device_core_url_invalid", 503);
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/$/, "") || "/";
|
||||
return url;
|
||||
}
|
||||
|
||||
function safeCoreError(value) {
|
||||
return typeof value === "string" && /^device_[a-z0-9._:-]{2,120}$/.test(value)
|
||||
? value
|
||||
: "device_core_unavailable";
|
||||
}
|
||||
|
||||
function serviceError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createDeviceCoreClient,
|
||||
createLocalPreviewDeviceCore,
|
||||
} from "./device-core-client.mjs";
|
||||
|
||||
const token = "device-core-test-token-that-is-never-exposed";
|
||||
const actor = Object.freeze({
|
||||
userRef: "user:device-admin",
|
||||
hubRole: "admin",
|
||||
groupRefs: ["group:device-engineers"],
|
||||
ownerScopes: [{ scopeKind: "personal", ownerRef: "user:device-admin" }],
|
||||
});
|
||||
const platformActor = Object.freeze({ ...actor, hubRole: "owner" });
|
||||
|
||||
test("Device Core client creates trusted actor headers and keeps its token server-side", async () => {
|
||||
const calls = [];
|
||||
const client = createDeviceCoreClient({
|
||||
baseUrl: "http://device-control-core:3210",
|
||||
token,
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return jsonResponse(200, { ok: true, projects: [] });
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await client.listProjects(actor), []);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].init.headers.Authorization, `Bearer ${token}`);
|
||||
assert.equal(calls[0].init.headers["X-NODEDC-User-Ref"], actor.userRef);
|
||||
assert.equal(calls[0].init.headers["X-NODEDC-Hub-Role"], "admin");
|
||||
assert.equal(calls[0].init.headers["X-NODEDC-Group-Refs"], "group:device-engineers");
|
||||
assert.equal(
|
||||
calls[0].init.headers["X-NODEDC-Owner-Scopes"],
|
||||
"personal=user:device-admin",
|
||||
);
|
||||
assert.equal(JSON.stringify(await client.listProjects(actor)).includes(token), false);
|
||||
});
|
||||
|
||||
test("Device Core client accepts only canonical commands and entity refs", async () => {
|
||||
const client = createDeviceCoreClient({
|
||||
baseUrl: "http://127.0.0.1:3210",
|
||||
token,
|
||||
fetchImpl: async () => jsonResponse(200, { ok: true, replayed: false, result: {} }),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
client.execute("raw:proxy", actor, {}, "device-manager-12345678"),
|
||||
/device_manager_command_invalid/,
|
||||
);
|
||||
await assert.rejects(
|
||||
client.getWorkspace(actor, "project:not-a-uuid"),
|
||||
/device_project_ref_invalid/,
|
||||
);
|
||||
await assert.rejects(
|
||||
client.execute("projects:ensure", actor, {}, "short"),
|
||||
/device_idempotency_key_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("local preview is empty and creates resources only through canonical commands", async () => {
|
||||
const client = createLocalPreviewDeviceCore();
|
||||
assert.deepEqual(await client.listProjects(actor), []);
|
||||
|
||||
const owner = await client.execute("owner-scopes:ensure", actor, {
|
||||
scopeKind: "personal",
|
||||
ownerRef: actor.userRef,
|
||||
displayName: "Device Admin",
|
||||
});
|
||||
assert.equal(owner.result.created, true);
|
||||
|
||||
const created = await client.execute("projects:ensure", actor, {
|
||||
scopeKind: "personal",
|
||||
ownerRef: actor.userRef,
|
||||
projectKey: "sandbox",
|
||||
name: "Device sandbox",
|
||||
description: null,
|
||||
});
|
||||
assert.equal(created.result.created, true);
|
||||
const projectRef = created.result.project.projectRef;
|
||||
|
||||
await client.execute("collections:ensure", actor, {
|
||||
projectRef,
|
||||
collectionKey: "field-devices",
|
||||
name: "Field devices",
|
||||
description: null,
|
||||
});
|
||||
|
||||
const adapterPackage = await client.execute("adapter-packages:ensure", platformActor, {
|
||||
packageKey: "generic-tracker",
|
||||
displayName: "Generic tracker",
|
||||
publisherRef: "publisher:nodedc",
|
||||
lifecycleState: "active",
|
||||
});
|
||||
const adapterVersion = await client.execute("adapter-versions:register", platformActor, {
|
||||
adapterPackageRef: adapterPackage.result.adapterPackage.adapterPackageRef,
|
||||
version: "1.0.0",
|
||||
runtimePackageRef: "artifact:generic-tracker:1.0.0",
|
||||
contentDigest: `sha256:${"a".repeat(64)}`,
|
||||
contractVersion: "device-adapter.v1",
|
||||
capabilities: ["telemetry"],
|
||||
lifecycleState: "draft",
|
||||
});
|
||||
const modelProfile = await client.execute("model-profiles:register", platformActor, {
|
||||
adapterVersionRef: adapterVersion.result.adapterVersion.adapterVersionRef,
|
||||
profileRef: "generic.tracker.v1",
|
||||
schemaVersion: "1.0.0",
|
||||
vendor: "Generic",
|
||||
model: "Tracker",
|
||||
deviceType: "tracker",
|
||||
protocol: "INTERNAL",
|
||||
schemaArtifactRef: "schema:generic.tracker.v1",
|
||||
profileDigest: `sha256:${"b".repeat(64)}`,
|
||||
capabilities: ["telemetry"],
|
||||
lifecycleState: "draft",
|
||||
});
|
||||
const edge = await client.execute("edges:ensure", platformActor, {
|
||||
edgeKey: "preview-edge",
|
||||
displayName: "Preview Edge",
|
||||
deploymentRef: "deployment:preview-edge",
|
||||
lifecycleState: "provisioning",
|
||||
});
|
||||
const route = await client.execute("routes:ensure", actor, {
|
||||
projectRef,
|
||||
routeKey: "preview-route",
|
||||
displayName: "Preview route",
|
||||
edgeRef: edge.result.edge.edgeRef,
|
||||
modelProfileRef: modelProfile.result.modelProfile.modelProfileRef,
|
||||
listenerRef: "listener:preview",
|
||||
protocol: "INTERNAL",
|
||||
direction: "telemetry",
|
||||
lifecycleState: "draft",
|
||||
});
|
||||
await client.execute("adapter-versions:register", platformActor, {
|
||||
adapterPackageRef: adapterPackage.result.adapterPackage.adapterPackageRef,
|
||||
version: "1.0.0",
|
||||
runtimePackageRef: "artifact:generic-tracker:1.0.0",
|
||||
contentDigest: `sha256:${"a".repeat(64)}`,
|
||||
contractVersion: "device-adapter.v1",
|
||||
capabilities: ["telemetry"],
|
||||
lifecycleState: "active",
|
||||
});
|
||||
await client.execute("model-profiles:register", platformActor, {
|
||||
adapterVersionRef: adapterVersion.result.adapterVersion.adapterVersionRef,
|
||||
profileRef: "generic.tracker.v1",
|
||||
schemaVersion: "1.0.0",
|
||||
vendor: "Generic",
|
||||
model: "Tracker",
|
||||
deviceType: "tracker",
|
||||
protocol: "INTERNAL",
|
||||
schemaArtifactRef: "schema:generic.tracker.v1",
|
||||
profileDigest: `sha256:${"b".repeat(64)}`,
|
||||
capabilities: ["telemetry"],
|
||||
lifecycleState: "active",
|
||||
});
|
||||
await client.execute("edges:ensure", platformActor, {
|
||||
edgeKey: "preview-edge",
|
||||
displayName: "Preview Edge",
|
||||
deploymentRef: "deployment:preview-edge",
|
||||
lifecycleState: "active",
|
||||
});
|
||||
await client.execute("routes:ensure", actor, {
|
||||
projectRef,
|
||||
routeKey: "preview-route",
|
||||
displayName: "Preview route",
|
||||
edgeRef: edge.result.edge.edgeRef,
|
||||
modelProfileRef: modelProfile.result.modelProfile.modelProfileRef,
|
||||
listenerRef: "listener:preview",
|
||||
protocol: "INTERNAL",
|
||||
direction: "telemetry",
|
||||
lifecycleState: "active",
|
||||
});
|
||||
const enrollment = await client.execute("enrollment-intents:ensure", actor, {
|
||||
projectRef,
|
||||
enrollmentKey: "preview-device",
|
||||
routeRef: route.result.route.routeRef,
|
||||
modelProfileRef: modelProfile.result.modelProfile.modelProfileRef,
|
||||
displayName: "Preview device",
|
||||
identifier: { kind: "imei", value: "123456789012345" },
|
||||
expiresAt: null,
|
||||
});
|
||||
assert.equal(enrollment.result.enrollmentIntent.identifier.masked, "***********2345");
|
||||
assert.equal(JSON.stringify(enrollment).includes("123456789012345"), false);
|
||||
const collectionRef = (await client.getWorkspace(actor, projectRef))
|
||||
.collections[0].collectionRef;
|
||||
await client.execute("device-bindings:ensure", actor, {
|
||||
projectRef,
|
||||
bindingKey: "preview-binding",
|
||||
displayName: "Preview binding",
|
||||
source: { kind: "collection", ref: collectionRef },
|
||||
targetKind: "foundry.application",
|
||||
targetRef: "application:preview-map",
|
||||
capabilities: ["observe"],
|
||||
});
|
||||
await client.execute("project-grants:upsert", actor, {
|
||||
projectRef,
|
||||
principalKind: "group",
|
||||
principalRef: "group:preview-viewers",
|
||||
projectRole: "viewer",
|
||||
capabilityAllow: [],
|
||||
capabilityDeny: [],
|
||||
lifecycleState: "active",
|
||||
});
|
||||
|
||||
const projects = await client.listProjects(actor);
|
||||
assert.equal(projects.length, 1);
|
||||
assert.equal(projects[0].counts.collections, 1);
|
||||
const workspace = await client.getWorkspace(actor, projectRef);
|
||||
assert.equal(workspace.collections[0].collectionKey, "field-devices");
|
||||
assert.equal(workspace.adapterPackages[0].packageKey, "generic-tracker");
|
||||
assert.equal(workspace.routes[0].routeKey, "preview-route");
|
||||
assert.equal(workspace.routes[0].lifecycleState, "active");
|
||||
assert.equal(workspace.enrollments[0].expectedIdentifier.masked, "***********2345");
|
||||
assert.equal(workspace.bindings[0].lifecycleState, "pending_external_approval");
|
||||
assert.equal(workspace.grants.length, 2);
|
||||
assert.ok(workspace.auditEvents.some((event) => event.eventType === "device_binding.created"));
|
||||
assert.equal(workspace.policies.commandTransport, "disabled");
|
||||
assert.deepEqual(workspace.devices, []);
|
||||
});
|
||||
|
||||
test("explicit B2 preview fixture is isolated from the empty canonical preview", async () => {
|
||||
const client = createLocalPreviewDeviceCore({ fixture: "arusnavi-b2" });
|
||||
const projects = await client.listProjects(actor);
|
||||
assert.equal(projects.length, 1);
|
||||
assert.equal(projects[0].counts.devices, 1);
|
||||
const workspace = await client.getWorkspace(actor, projects[0].projectRef);
|
||||
assert.equal(workspace.devices[0].modelProfileRef, "arusnavi.b2.internal.v1");
|
||||
assert.equal(workspace.devices[0].identifier.masked, "***********1088");
|
||||
assert.equal(workspace.devices[0].identifier.value, "863151070211088");
|
||||
assert.equal(workspace.devices[0].integrationDeviceId, "8028");
|
||||
assert.equal(workspace.sessions[0].lifecycleState, "online");
|
||||
assert.equal(workspace.policies.commandTransport, "typed-service-ping-v1");
|
||||
assert.equal(workspace.policies.identifierProjection, "authorized-full");
|
||||
assert.equal(JSON.stringify(workspace).includes("123456789012345"), false);
|
||||
|
||||
assert.throws(
|
||||
() => createLocalPreviewDeviceCore({ fixture: "unknown" }),
|
||||
/device_manager_preview_fixture_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
function jsonResponse(status, body) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
const DEFAULT_VALIDATION_TTL_MS = 20_000;
|
||||
const DEFAULT_VALIDATION_GRACE_MS = 30_000;
|
||||
|
||||
export function createDeviceManagerAuth({
|
||||
env = process.env,
|
||||
fetchImpl = fetch,
|
||||
now = Date.now,
|
||||
internalToken: providedInternalToken,
|
||||
} = {}) {
|
||||
const authRequired = booleanValue(
|
||||
env.NODEDC_DEVICE_MANAGER_AUTH_REQUIRED,
|
||||
env.NODE_ENV === "production",
|
||||
);
|
||||
const serviceSlug = textValue(env.NODEDC_DEVICE_MANAGER_SERVICE_SLUG, "device-core");
|
||||
const launcherBaseUrl = baseUrl(env.NODEDC_LAUNCHER_BASE_URL, "http://127.0.0.1:5173");
|
||||
const launcherInternalUrl = baseUrl(env.NODEDC_LAUNCHER_INTERNAL_URL, launcherBaseUrl);
|
||||
const internalToken = textValue(
|
||||
providedInternalToken
|
||||
|| env.NODEDC_INTERNAL_ACCESS_TOKEN
|
||||
|| env.NODEDC_PLATFORM_SERVICE_TOKEN,
|
||||
"",
|
||||
);
|
||||
const sessionCookie = textValue(
|
||||
env.NODEDC_DEVICE_MANAGER_SESSION_COOKIE,
|
||||
"nodedc_device_manager_session",
|
||||
);
|
||||
const sessionTtlMs = boundedInteger(
|
||||
env.NODEDC_DEVICE_MANAGER_SESSION_TTL_MS,
|
||||
DEFAULT_SESSION_TTL_MS,
|
||||
60_000,
|
||||
24 * 60 * 60 * 1000,
|
||||
);
|
||||
const validationTtlMs = boundedInteger(
|
||||
env.NODEDC_DEVICE_MANAGER_SESSION_VALIDATION_TTL_MS,
|
||||
DEFAULT_VALIDATION_TTL_MS,
|
||||
15_000,
|
||||
30_000,
|
||||
);
|
||||
const validationGraceMs = boundedInteger(
|
||||
env.NODEDC_DEVICE_MANAGER_SESSION_VALIDATION_GRACE_MS,
|
||||
DEFAULT_VALIDATION_GRACE_MS,
|
||||
0,
|
||||
60_000,
|
||||
);
|
||||
const secureCookie = booleanValue(
|
||||
env.NODEDC_DEVICE_MANAGER_COOKIE_SECURE,
|
||||
authRequired,
|
||||
);
|
||||
const sessions = new Map();
|
||||
|
||||
function buildCookie(value, maxAgeSeconds) {
|
||||
return [
|
||||
`${sessionCookie}=${encodeURIComponent(value)}`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
|
||||
...(secureCookie ? ["Secure"] : []),
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function createSession(response, handoff) {
|
||||
pruneSessions();
|
||||
const id = randomBytes(32).toString("base64url");
|
||||
const createdAt = now();
|
||||
sessions.set(id, {
|
||||
id,
|
||||
user: handoff.user,
|
||||
access: handoff.access,
|
||||
launcherSessionId: handoff.launcherSessionId,
|
||||
expiresAt: createdAt + sessionTtlMs,
|
||||
validatedAt: createdAt,
|
||||
validationInFlight: null,
|
||||
});
|
||||
appendCookie(response, buildCookie(id, sessionTtlMs / 1000));
|
||||
}
|
||||
|
||||
function currentSession(request) {
|
||||
const id = parseCookies(request.headers.cookie)[sessionCookie];
|
||||
const session = id ? sessions.get(id) : null;
|
||||
if (!session || session.expiresAt <= now()) {
|
||||
if (id) sessions.delete(id);
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
async function launcherRequest(pathname, payload) {
|
||||
if (!internalToken) throw serviceError("device_manager_auth_not_configured", 503);
|
||||
const response = await fetchImpl(new URL(pathname, launcherInternalUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${internalToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function handleHandoff(request, response, url) {
|
||||
const nextPath = safeReturnTo(
|
||||
url.searchParams.get("next_path") || url.searchParams.get("returnTo"),
|
||||
);
|
||||
if (!authRequired) return redirect(response, nextPath);
|
||||
const token = String(url.searchParams.get("token") || "");
|
||||
if (!token) return sendText(response, 400, "Missing Launcher handoff token.");
|
||||
try {
|
||||
const result = await launcherRequest("/api/internal/handoff/consume", {
|
||||
token,
|
||||
serviceSlug,
|
||||
});
|
||||
if (!result.response.ok || result.body?.ok !== true || !result.body?.user) {
|
||||
return sendText(response, 401, "Launcher handoff rejected.");
|
||||
}
|
||||
createSession(response, {
|
||||
user: result.body.user,
|
||||
access: result.body.access,
|
||||
launcherSessionId: result.body.launcherSessionId ?? null,
|
||||
});
|
||||
return redirect(response, nextPath);
|
||||
} catch {
|
||||
return sendText(response, 401, "Launcher handoff failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function validatedSession(request, response) {
|
||||
if (!authRequired) {
|
||||
return attachSession(request, {
|
||||
user: {
|
||||
id: "local-device-admin",
|
||||
email: "local-device-admin@nodedc.local",
|
||||
name: "Local Device Admin",
|
||||
avatarUrl: null,
|
||||
groups: ["nodedc:superadmin"],
|
||||
},
|
||||
});
|
||||
}
|
||||
const session = currentSession(request);
|
||||
if (!session) {
|
||||
clearCookie(response);
|
||||
return null;
|
||||
}
|
||||
if (now() - session.validatedAt <= validationTtlMs) {
|
||||
return attachSession(request, session);
|
||||
}
|
||||
if (!session.validationInFlight) {
|
||||
session.validationInFlight = launcherRequest("/api/internal/session/validate", {
|
||||
serviceSlug,
|
||||
launcherSessionId: session.launcherSessionId,
|
||||
}).finally(() => {
|
||||
session.validationInFlight = null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
const { response: upstream, body } = await session.validationInFlight;
|
||||
if (upstream.ok && body?.ok === true && body.active === true) {
|
||||
session.user = body.user || session.user;
|
||||
session.access = body.access;
|
||||
session.validatedAt = now();
|
||||
return attachSession(request, session);
|
||||
}
|
||||
if (upstream.ok && body?.ok === true && body.active === false) {
|
||||
sessions.delete(session.id);
|
||||
clearCookie(response);
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// Read-only grace is resolved below; mutations always fail closed.
|
||||
}
|
||||
const readOnly = request.method === "GET" || request.method === "HEAD";
|
||||
if (readOnly && now() - session.validatedAt <= validationTtlMs + validationGraceMs) {
|
||||
return attachSession(request, session);
|
||||
}
|
||||
request.nodedcDeviceManagerAuthUnavailable = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function authorize(request, response, url) {
|
||||
if (
|
||||
url.pathname === "/healthz"
|
||||
|| url.pathname === "/auth/nodedc/handoff"
|
||||
|| url.pathname === "/auth/logout"
|
||||
) return false;
|
||||
const session = await validatedSession(request, response);
|
||||
if (!session) {
|
||||
if (request.nodedcDeviceManagerAuthUnavailable) {
|
||||
sendJson(response, 503, { ok: false, error: "device_manager_auth_unavailable" });
|
||||
return true;
|
||||
}
|
||||
const loginUrl = new URL("/auth/login", launcherBaseUrl);
|
||||
const launch = new URL(`/api/services/${encodeURIComponent(serviceSlug)}/launch`, launcherBaseUrl);
|
||||
launch.searchParams.set("returnTo", safeReturnTo(`${url.pathname}${url.search}`));
|
||||
loginUrl.searchParams.set("returnTo", `${launch.pathname}${launch.search}`);
|
||||
if (isHtmlRequest(request, url)) {
|
||||
redirect(response, loginUrl.toString());
|
||||
return true;
|
||||
}
|
||||
sendJson(response, 401, {
|
||||
ok: false,
|
||||
error: "device_manager_auth_required",
|
||||
loginUrl: loginUrl.toString(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const access = resolveAccess(session.user, session.access, { allowLegacy: !authRequired });
|
||||
if (!access.allowed) {
|
||||
sendJson(response, 403, {
|
||||
ok: false,
|
||||
error: access.blocked
|
||||
? "device_manager_access_blocked"
|
||||
: "device_manager_access_denied",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
request.nodedcDeviceManagerAccess = access;
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleLogout(request, response) {
|
||||
const id = parseCookies(request.headers.cookie)[sessionCookie];
|
||||
if (id) sessions.delete(id);
|
||||
clearCookie(response);
|
||||
redirect(response, "/");
|
||||
}
|
||||
|
||||
function currentContext(request) {
|
||||
const user = request.nodedcDeviceManagerSession?.user;
|
||||
const trustedAccess = request.nodedcDeviceManagerSession?.access;
|
||||
const access = request.nodedcDeviceManagerAccess
|
||||
?? resolveAccess(user, trustedAccess, { allowLegacy: !authRequired });
|
||||
if (!user || !access.allowed) return null;
|
||||
const id = cleanOpaque(user.id || user.subject || user.sub);
|
||||
if (!id) return null;
|
||||
const email = String(user.email || "").trim().slice(0, 240);
|
||||
const displayName = String(user.name || user.displayName || email || "NODE.DC")
|
||||
.trim()
|
||||
.slice(0, 240);
|
||||
const avatar = String(user.avatarUrl || user.avatar_url || user.picture || "").trim();
|
||||
const userRef = `user:${id}`;
|
||||
return {
|
||||
user: {
|
||||
id,
|
||||
email,
|
||||
displayName,
|
||||
avatarUrl: /^https:\/\//i.test(avatar) || avatar.startsWith("/") ? avatar : null,
|
||||
initials: initials(displayName),
|
||||
},
|
||||
actor: {
|
||||
userRef,
|
||||
hubRole: access.hubRole,
|
||||
groupRefs: access.groups.map((group) => `group:${group}`),
|
||||
ownerScopes: access.ownerScopes,
|
||||
},
|
||||
profileUrl: new URL("/profile", launcherBaseUrl).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
function clearCookie(response) {
|
||||
appendCookie(response, buildCookie("", 0));
|
||||
}
|
||||
|
||||
function pruneSessions() {
|
||||
const current = now();
|
||||
for (const [id, session] of sessions) {
|
||||
if (session.expiresAt <= current) sessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authRequired,
|
||||
internalAccessConfigured: Boolean(internalToken),
|
||||
serviceSlug,
|
||||
authorize,
|
||||
currentContext,
|
||||
handleHandoff,
|
||||
handleLogout,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAccess(user, trustedAccess, { allowLegacy = false } = {}) {
|
||||
if (!user || typeof user !== "object") {
|
||||
return deniedAccess();
|
||||
}
|
||||
const groups = normalizedGroups(user);
|
||||
if (groups.includes("nodedc:device-core:blocked")) {
|
||||
return { ...deniedAccess(groups), blocked: true };
|
||||
}
|
||||
const id = cleanOpaque(user.id || user.subject || user.sub);
|
||||
if (!id) return deniedAccess(groups);
|
||||
const claims = normalizeTrustedAccess(trustedAccess, id);
|
||||
if (claims) return { ...claims, blocked: false, groups };
|
||||
if (!allowLegacy) return deniedAccess(groups);
|
||||
const hubRole = id === "user_root" || groups.includes("nodedc:superadmin")
|
||||
? "owner"
|
||||
: groups.includes("nodedc:device-core:admin") || groups.includes("nodedc:launcher:admin")
|
||||
? "admin"
|
||||
: groups.includes("nodedc:device-core:viewer")
|
||||
? "viewer"
|
||||
: "member";
|
||||
const ownerScopes = ["admin", "owner"].includes(hubRole)
|
||||
? [{
|
||||
scopeKind: "personal",
|
||||
ownerRef: `user:${id}`,
|
||||
displayName: String(user.name || user.displayName || user.email || id).trim().slice(0, 240),
|
||||
}]
|
||||
: [];
|
||||
return { allowed: true, blocked: false, hubRole, groups, ownerScopes };
|
||||
}
|
||||
|
||||
function normalizeTrustedAccess(input, userId) {
|
||||
if (!input || typeof input !== "object" || input.allowed !== true) return null;
|
||||
const hubRole = ["viewer", "member", "admin", "owner"].includes(input.hubRole)
|
||||
? input.hubRole
|
||||
: null;
|
||||
if (!hubRole || !Array.isArray(input.ownerScopes)) return null;
|
||||
const ownerScopes = [];
|
||||
for (const item of input.ownerScopes) {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const scopeKind = item.scopeKind === "company" || item.scopeKind === "personal"
|
||||
? item.scopeKind
|
||||
: null;
|
||||
const ownerRef = cleanOpaque(item.ownerRef);
|
||||
const validOwner = scopeKind === "personal"
|
||||
? ownerRef === `user:${userId}`
|
||||
: ownerRef?.startsWith("client:") && ownerRef.length > "client:".length;
|
||||
if (!scopeKind || !validOwner) return null;
|
||||
ownerScopes.push({
|
||||
scopeKind,
|
||||
ownerRef,
|
||||
displayName: String(item.displayName || ownerRef).trim().slice(0, 240),
|
||||
});
|
||||
}
|
||||
return {
|
||||
allowed: true,
|
||||
hubRole,
|
||||
ownerScopes: [...new Map(ownerScopes.map((scope) => [
|
||||
`${scope.scopeKind}\0${scope.ownerRef}`,
|
||||
scope,
|
||||
])).values()],
|
||||
};
|
||||
}
|
||||
|
||||
function deniedAccess(groups = []) {
|
||||
return {
|
||||
allowed: false,
|
||||
blocked: false,
|
||||
hubRole: "viewer",
|
||||
groups,
|
||||
ownerScopes: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedGroups(user) {
|
||||
const values = [user.groups, user.roles, user.roleKeys, user.permissions];
|
||||
const groups = [];
|
||||
for (const value of values) {
|
||||
const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
||||
for (const item of items) {
|
||||
const raw = typeof item === "string" ? item : item?.name || item?.key || item?.slug;
|
||||
const normalized = String(raw || "").trim().toLowerCase();
|
||||
if (/^[a-z0-9][a-z0-9._:-]{1,127}$/.test(normalized)) groups.push(normalized);
|
||||
}
|
||||
}
|
||||
return [...new Set(groups)].sort();
|
||||
}
|
||||
|
||||
function attachSession(request, session) {
|
||||
request.nodedcDeviceManagerSession = session;
|
||||
return session;
|
||||
}
|
||||
|
||||
function cleanOpaque(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._:-]{2,255}$/.test(normalized)
|
||||
? normalized
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseCookies(header = "") {
|
||||
const values = {};
|
||||
for (const part of String(header).split(";")) {
|
||||
const index = part.indexOf("=");
|
||||
if (index < 1) continue;
|
||||
const key = part.slice(0, index).trim();
|
||||
try {
|
||||
values[key] = decodeURIComponent(part.slice(index + 1).trim());
|
||||
} catch {
|
||||
values[key] = part.slice(index + 1).trim();
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function appendCookie(response, value) {
|
||||
const current = response.getHeader("Set-Cookie");
|
||||
response.setHeader("Set-Cookie", current ? [current, value].flat() : value);
|
||||
}
|
||||
|
||||
function safeReturnTo(value) {
|
||||
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//")
|
||||
? value
|
||||
: "/";
|
||||
}
|
||||
|
||||
function isHtmlRequest(request, url) {
|
||||
return request.method === "GET"
|
||||
&& !url.pathname.startsWith("/api/")
|
||||
&& (url.pathname === "/" || String(request.headers.accept || "").includes("text/html"));
|
||||
}
|
||||
|
||||
function redirect(response, location) {
|
||||
response.statusCode = 302;
|
||||
response.setHeader("Location", location);
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end();
|
||||
}
|
||||
|
||||
function sendJson(response, status, body) {
|
||||
response.statusCode = status;
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function sendText(response, status, body) {
|
||||
response.statusCode = status;
|
||||
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function initials(value) {
|
||||
return value.split(/\s+/).filter(Boolean).slice(0, 2)
|
||||
.map((part) => part[0]).join("").toUpperCase() || "DC";
|
||||
}
|
||||
|
||||
function booleanValue(value, fallback) {
|
||||
if (value == null || value === "") return fallback;
|
||||
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
|
||||
}
|
||||
|
||||
function boundedInteger(value, fallback, min, max) {
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
||||
}
|
||||
|
||||
function textValue(value, fallback) {
|
||||
return String(value || fallback).trim();
|
||||
}
|
||||
|
||||
function baseUrl(value, fallback) {
|
||||
return textValue(value, fallback).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function serviceError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
|
||||
|
||||
const internalToken = "launcher-internal-token-must-stay-server-side";
|
||||
const launcherSessionId = "launcher-session-id-must-stay-server-side";
|
||||
|
||||
test("Launcher handoff becomes an opaque Device Manager session and trusted actor", async () => {
|
||||
const calls = [];
|
||||
const auth = createDeviceManagerAuth({
|
||||
env: productionEnv(),
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return jsonResponse(200, {
|
||||
ok: true,
|
||||
launcherSessionId,
|
||||
access: {
|
||||
allowed: true,
|
||||
hubRole: "owner",
|
||||
ownerScopes: [
|
||||
{
|
||||
scopeKind: "company",
|
||||
ownerRef: "client:client_dctouch",
|
||||
displayName: "DC Touch",
|
||||
},
|
||||
{
|
||||
scopeKind: "personal",
|
||||
ownerRef: "user:user_root",
|
||||
displayName: "DC SUDO",
|
||||
},
|
||||
],
|
||||
},
|
||||
user: {
|
||||
id: "user_root",
|
||||
email: "root@example.test",
|
||||
name: "DC SUDO",
|
||||
groups: ["nodedc:superadmin", "nodedc:device-core:admin"],
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const response = mockResponse();
|
||||
await auth.handleHandoff(
|
||||
{ method: "GET", headers: {} },
|
||||
response,
|
||||
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret&next_path=%2F"),
|
||||
);
|
||||
|
||||
assert.equal(response.statusCode, 302);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].init.headers.Authorization, `Bearer ${internalToken}`);
|
||||
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
||||
token: "handoff-secret",
|
||||
serviceSlug: "device-core",
|
||||
});
|
||||
const cookie = String(response.getHeader("set-cookie")).split(";", 1)[0];
|
||||
assert.match(cookie, /^nodedc_device_manager_session=[A-Za-z0-9_-]{40,}$/);
|
||||
assert.equal(cookie.includes("user_root"), false);
|
||||
assert.equal(cookie.includes(launcherSessionId), false);
|
||||
|
||||
const request = { method: "GET", headers: { cookie, accept: "application/json" } };
|
||||
const authorized = await auth.authorize(
|
||||
request,
|
||||
mockResponse(),
|
||||
new URL("https://device.example.test/api/device-manager/session"),
|
||||
);
|
||||
assert.equal(authorized, false);
|
||||
const context = auth.currentContext(request);
|
||||
assert.equal(context.actor.userRef, "user:user_root");
|
||||
assert.equal(context.actor.hubRole, "owner");
|
||||
assert.deepEqual(context.actor.ownerScopes, [
|
||||
{
|
||||
scopeKind: "company",
|
||||
ownerRef: "client:client_dctouch",
|
||||
displayName: "DC Touch",
|
||||
},
|
||||
{
|
||||
scopeKind: "personal",
|
||||
ownerRef: "user:user_root",
|
||||
displayName: "DC SUDO",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(context.actor.groupRefs, [
|
||||
"group:nodedc:device-core:admin",
|
||||
"group:nodedc:superadmin",
|
||||
]);
|
||||
assert.equal(JSON.stringify(context).includes(launcherSessionId), false);
|
||||
assert.equal(JSON.stringify(context).includes(internalToken), false);
|
||||
});
|
||||
|
||||
test("invalid identity and explicit Device Core block never produce an actor", async () => {
|
||||
for (const user of [
|
||||
{ id: "?", groups: ["nodedc:device-core:admin"] },
|
||||
{ id: "valid-user", groups: ["nodedc:superadmin", "nodedc:device-core:blocked"] },
|
||||
]) {
|
||||
const auth = createDeviceManagerAuth({
|
||||
env: productionEnv(),
|
||||
fetchImpl: async () => jsonResponse(200, {
|
||||
ok: true,
|
||||
launcherSessionId,
|
||||
access: {
|
||||
allowed: true,
|
||||
hubRole: "admin",
|
||||
ownerScopes: [],
|
||||
},
|
||||
user,
|
||||
}),
|
||||
});
|
||||
const handoff = mockResponse();
|
||||
await auth.handleHandoff(
|
||||
{ method: "GET", headers: {} },
|
||||
handoff,
|
||||
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"),
|
||||
);
|
||||
const cookie = String(handoff.getHeader("set-cookie")).split(";", 1)[0];
|
||||
const request = { method: "GET", headers: { cookie, accept: "application/json" } };
|
||||
const response = mockResponse();
|
||||
assert.equal(await auth.authorize(
|
||||
request,
|
||||
response,
|
||||
new URL("https://device.example.test/api/device-manager/session"),
|
||||
), true);
|
||||
assert.equal(response.statusCode, 403);
|
||||
}
|
||||
});
|
||||
|
||||
test("production auth fails closed when Launcher omits trusted Device Core access", async () => {
|
||||
const auth = createDeviceManagerAuth({
|
||||
env: productionEnv(),
|
||||
fetchImpl: async () => jsonResponse(200, {
|
||||
ok: true,
|
||||
launcherSessionId,
|
||||
user: {
|
||||
id: "user_root",
|
||||
email: "root@example.test",
|
||||
name: "DC SUDO",
|
||||
groups: ["nodedc:superadmin"],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const handoff = mockResponse();
|
||||
await auth.handleHandoff(
|
||||
{ method: "GET", headers: {} },
|
||||
handoff,
|
||||
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"),
|
||||
);
|
||||
const cookie = String(handoff.getHeader("set-cookie")).split(";", 1)[0];
|
||||
const response = mockResponse();
|
||||
assert.equal(await auth.authorize(
|
||||
{ method: "GET", headers: { cookie, accept: "application/json" } },
|
||||
response,
|
||||
new URL("https://device.example.test/api/device-manager/session"),
|
||||
), true);
|
||||
assert.equal(response.statusCode, 403);
|
||||
assert.equal(JSON.parse(response.body).error, "device_manager_access_denied");
|
||||
});
|
||||
|
||||
test("an injected file-backed token takes precedence over broad platform env tokens", async () => {
|
||||
const calls = [];
|
||||
const auth = createDeviceManagerAuth({
|
||||
env: { ...productionEnv(), NODEDC_INTERNAL_ACCESS_TOKEN: "broad-platform-token" },
|
||||
internalToken: "scoped-file-token",
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return jsonResponse(200, {
|
||||
ok: true,
|
||||
launcherSessionId,
|
||||
access: { allowed: true, hubRole: "member", ownerScopes: [] },
|
||||
user: {
|
||||
id: "device-member",
|
||||
email: "member@example.test",
|
||||
name: "Device Member",
|
||||
groups: ["nodedc:device-core:access"],
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
await auth.handleHandoff(
|
||||
{ method: "GET", headers: {} },
|
||||
mockResponse(),
|
||||
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"),
|
||||
);
|
||||
assert.equal(calls[0].init.headers.Authorization, "Bearer scoped-file-token");
|
||||
});
|
||||
|
||||
function productionEnv() {
|
||||
return {
|
||||
NODE_ENV: "production",
|
||||
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "true",
|
||||
NODEDC_DEVICE_MANAGER_COOKIE_SECURE: "false",
|
||||
NODEDC_LAUNCHER_BASE_URL: "https://launcher.example.test",
|
||||
NODEDC_LAUNCHER_INTERNAL_URL: "http://launcher.internal.test",
|
||||
NODEDC_INTERNAL_ACCESS_TOKEN: internalToken,
|
||||
};
|
||||
}
|
||||
|
||||
function mockResponse() {
|
||||
const headers = new Map();
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: "",
|
||||
setHeader(name, value) { headers.set(String(name).toLowerCase(), value); },
|
||||
getHeader(name) { return headers.get(String(name).toLowerCase()); },
|
||||
end(body = "") { this.body = String(body); },
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(status, body) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
||||
|
||||
const DEFAULT_ACCENT = "#b9ff4a";
|
||||
|
||||
export function createDeviceManagerPresentationStore({
|
||||
layoutPath,
|
||||
uploadRoot,
|
||||
} = {}) {
|
||||
const resolvedLayoutPath = resolve(layoutPath || "runtime-data/device-manager-presentation.json");
|
||||
const resolvedUploadRoot = resolve(uploadRoot || "runtime-data/device-manager-media");
|
||||
|
||||
return {
|
||||
mediaRoot: resolvedUploadRoot,
|
||||
async read() {
|
||||
const raw = await readFile(resolvedLayoutPath, "utf8").catch((error) => {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!raw) return defaultPresentation();
|
||||
try {
|
||||
return normalizePresentation(JSON.parse(raw));
|
||||
} catch {
|
||||
throw serviceError("device_manager_presentation_invalid", 500);
|
||||
}
|
||||
},
|
||||
async write(next) {
|
||||
const normalized = normalizePresentation(next);
|
||||
await mkdir(dirname(resolvedLayoutPath), { recursive: true });
|
||||
const temporaryPath = `${resolvedLayoutPath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporaryPath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o640 });
|
||||
await rename(temporaryPath, resolvedLayoutPath);
|
||||
return normalized;
|
||||
},
|
||||
async saveMedia({ bytes, contentType, originalName, kind }) {
|
||||
const extension = allowedExtension(contentType, originalName, kind);
|
||||
await mkdir(resolvedUploadRoot, { recursive: true });
|
||||
const fileName = `${kind}-${randomUUID()}${extension}`;
|
||||
await writeFile(join(resolvedUploadRoot, fileName), bytes, { flag: "wx", mode: 0o640 });
|
||||
return {
|
||||
fileName: String(originalName || fileName).slice(0, 180),
|
||||
fileSrc: `/device-manager-media/${fileName}`,
|
||||
};
|
||||
},
|
||||
resolveMedia(pathname) {
|
||||
const encodedName = pathname.match(/^\/device-manager-media\/([^/]+)$/)?.[1];
|
||||
if (!encodedName) return null;
|
||||
const name = basename(decodeURIComponent(encodedName));
|
||||
if (!/^[a-z]+-[0-9a-f-]+\.(?:png|jpe?g|webp|gif|avif|mp4|webm|mov)$/i.test(name)) return null;
|
||||
const candidate = resolve(resolvedUploadRoot, name);
|
||||
return candidate.startsWith(`${resolvedUploadRoot}${sep}`) ? candidate : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultPresentation() {
|
||||
return {
|
||||
environment: {
|
||||
theme: "dark",
|
||||
accentHex: DEFAULT_ACCENT,
|
||||
overview: defaultOverview(),
|
||||
},
|
||||
projects: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeProjectPresentation(value) {
|
||||
return {
|
||||
icon: normalizeMedia(value?.icon),
|
||||
teaser: normalizeMedia(value?.teaser),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeEnvironmentPresentation(value) {
|
||||
const legacyTeaser = normalizeMedia(value?.defaultTeaser);
|
||||
return {
|
||||
theme: value?.theme === "light" ? "light" : "dark",
|
||||
accentHex: /^#[0-9a-f]{6}$/i.test(String(value?.accentHex || ""))
|
||||
? String(value.accentHex).toLowerCase()
|
||||
: DEFAULT_ACCENT,
|
||||
overview: normalizeOverview(value?.overview, legacyTeaser),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultOverview() {
|
||||
return {
|
||||
headerLabel: "Device Core",
|
||||
eyebrow: "NODEDC / DEVICE CORE",
|
||||
title: "Device Core",
|
||||
description: "Единый контур подключения, учёта и управления устройствами.",
|
||||
primarySection: "devices",
|
||||
secondarySection: null,
|
||||
background: {
|
||||
enabled: false,
|
||||
imageDurationSeconds: 10,
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOverview(value, legacyTeaser) {
|
||||
const fallback = defaultOverview();
|
||||
const legacySource = mediaSource(legacyTeaser);
|
||||
const legacyItems = legacySource ? [{
|
||||
id: "legacy-overview-media",
|
||||
...legacyTeaser,
|
||||
mediaKind: inferMediaKind(legacySource),
|
||||
}] : [];
|
||||
const sourceItems = Array.isArray(value?.background?.items)
|
||||
? value.background.items.slice(0, 24)
|
||||
: legacyItems;
|
||||
const items = sourceItems
|
||||
.map(normalizeEnvironmentMediaItem)
|
||||
.filter(Boolean);
|
||||
return {
|
||||
headerLabel: normalizeCopy(value?.headerLabel, fallback.headerLabel, 40),
|
||||
eyebrow: normalizeCopy(value?.eyebrow, fallback.eyebrow, 80),
|
||||
title: normalizeCopy(value?.title, fallback.title, 120),
|
||||
description: normalizeCopy(value?.description, fallback.description, 500),
|
||||
primarySection: normalizeSection(value?.primarySection, fallback.primarySection),
|
||||
secondarySection: normalizeSection(value?.secondarySection, fallback.secondarySection),
|
||||
background: {
|
||||
enabled: value?.background
|
||||
? Boolean(value.background.enabled)
|
||||
: legacyItems.length > 0,
|
||||
imageDurationSeconds: clampInteger(value?.background?.imageDurationSeconds, 1, 60, 10),
|
||||
items,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEnvironmentMediaItem(value) {
|
||||
const media = normalizeMedia(value);
|
||||
const source = mediaSource(media);
|
||||
if (!source && !value?.url && !value?.fileSrc) return null;
|
||||
return {
|
||||
id: /^[a-z0-9][a-z0-9._:-]{0,127}$/i.test(String(value?.id || ""))
|
||||
? String(value.id)
|
||||
: randomUUID(),
|
||||
...media,
|
||||
mediaKind: value?.mediaKind === "image" || value?.mediaKind === "video"
|
||||
? value.mediaKind
|
||||
: inferMediaKind(source),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCopy(value, fallback, maxLength) {
|
||||
const normalized = String(value || "").trim();
|
||||
return (normalized || fallback).slice(0, maxLength);
|
||||
}
|
||||
|
||||
function normalizeSection(value, fallback) {
|
||||
const allowed = new Set(["overview", "devices", "infrastructure", "management", "administration"]);
|
||||
if (value === undefined) return fallback;
|
||||
if (value === null || value === "none") return null;
|
||||
return allowed.has(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function clampInteger(value, minimum, maximum, fallback) {
|
||||
const normalized = Number.parseInt(String(value), 10);
|
||||
return Number.isInteger(normalized)
|
||||
? Math.min(maximum, Math.max(minimum, normalized))
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function normalizePresentation(value) {
|
||||
const projects = {};
|
||||
if (value?.projects && typeof value.projects === "object" && !Array.isArray(value.projects)) {
|
||||
for (const [projectRef, presentation] of Object.entries(value.projects)) {
|
||||
if (/^project:[0-9a-f-]{36}$/i.test(projectRef)) {
|
||||
projects[projectRef.toLowerCase()] = normalizeProjectPresentation(presentation);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
environment: normalizeEnvironmentPresentation(value?.environment),
|
||||
projects,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMedia(value) {
|
||||
const source = value?.source === "url" ? "url" : "file";
|
||||
const url = source === "url" ? safeExternalUrl(value?.url) : "";
|
||||
const fileSrc = source === "file" && /^\/device-manager-media\/[a-z0-9._-]+$/i.test(String(value?.fileSrc || ""))
|
||||
? String(value.fileSrc)
|
||||
: null;
|
||||
return {
|
||||
source,
|
||||
url,
|
||||
fileName: fileSrc ? String(value?.fileName || basename(fileSrc)).slice(0, 180) : null,
|
||||
fileSrc,
|
||||
};
|
||||
}
|
||||
|
||||
function safeExternalUrl(value) {
|
||||
const candidate = String(value || "").trim();
|
||||
if (!candidate) return "";
|
||||
try {
|
||||
const url = new URL(candidate);
|
||||
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function emptyMedia() {
|
||||
return { source: "file", url: "", fileName: null, fileSrc: null };
|
||||
}
|
||||
|
||||
function mediaSource(value) {
|
||||
if (!value) return null;
|
||||
return value.source === "url" ? value.url || null : value.fileSrc;
|
||||
}
|
||||
|
||||
function inferMediaKind(value) {
|
||||
const pathname = (() => {
|
||||
try { return new URL(String(value || ""), "http://localhost").pathname; }
|
||||
catch { return String(value || ""); }
|
||||
})();
|
||||
return /\.(?:png|jpe?g|webp|gif|avif)$/i.test(pathname) ? "image" : "video";
|
||||
}
|
||||
|
||||
function allowedExtension(contentType, originalName, kind) {
|
||||
const normalized = String(contentType || "").split(";", 1)[0].trim().toLowerCase();
|
||||
const imageTypes = new Map([["image/png", ".png"], ["image/jpeg", ".jpg"], ["image/webp", ".webp"], ["image/gif", ".gif"], ["image/avif", ".avif"]]);
|
||||
const videoTypes = new Map([
|
||||
["video/mp4", ".mp4"],
|
||||
["video/webm", ".webm"],
|
||||
["video/quicktime", ".mov"],
|
||||
["video/x-quicktime", ".mov"],
|
||||
]);
|
||||
const allowed = kind === "icon"
|
||||
? imageTypes
|
||||
: kind === "teaser"
|
||||
? videoTypes
|
||||
: new Map([...imageTypes, ...videoTypes]);
|
||||
const suppliedExtension = extname(String(originalName || "")).toLowerCase();
|
||||
const extensionFallback = new Map([
|
||||
[".png", ".png"], [".jpg", ".jpg"], [".jpeg", ".jpg"], [".webp", ".webp"],
|
||||
[".gif", ".gif"], [".avif", ".avif"], [".mp4", ".mp4"], [".webm", ".webm"], [".mov", ".mov"],
|
||||
]);
|
||||
const extension = allowed.get(normalized)
|
||||
|| (!normalized || normalized === "application/octet-stream"
|
||||
? extensionFallback.get(suppliedExtension)
|
||||
: null);
|
||||
if (!extension) throw serviceError("device_manager_media_type_forbidden", 415);
|
||||
if (suppliedExtension && kind === "icon" && ![".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"].includes(suppliedExtension)) {
|
||||
throw serviceError("device_manager_media_extension_forbidden", 415);
|
||||
}
|
||||
if (suppliedExtension && kind === "teaser" && ![".mp4", ".webm", ".mov"].includes(suppliedExtension)) {
|
||||
throw serviceError("device_manager_media_extension_forbidden", 415);
|
||||
}
|
||||
return extension;
|
||||
}
|
||||
|
||||
function serviceError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createDeviceManagerPresentationStore,
|
||||
defaultPresentation,
|
||||
normalizeEnvironmentPresentation,
|
||||
} from "./device-manager-presentation.mjs";
|
||||
|
||||
test("Device Core environment defaults to the product-level canonical identity", () => {
|
||||
const presentation = defaultPresentation();
|
||||
assert.deepEqual(presentation.environment.overview, {
|
||||
headerLabel: "Device Core",
|
||||
eyebrow: "NODEDC / DEVICE CORE",
|
||||
title: "Device Core",
|
||||
description: "Единый контур подключения, учёта и управления устройствами.",
|
||||
primarySection: "devices",
|
||||
secondarySection: null,
|
||||
background: {
|
||||
enabled: false,
|
||||
imageDurationSeconds: 10,
|
||||
items: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("legacy single teaser migrates into the environment media playlist", () => {
|
||||
const environment = normalizeEnvironmentPresentation({
|
||||
defaultTeaser: {
|
||||
source: "file",
|
||||
fileName: "legacy.mov",
|
||||
fileSrc: "/device-manager-media/background-00000000-0000-4000-8000-000000000000.mov",
|
||||
},
|
||||
});
|
||||
assert.equal(environment.overview.background.enabled, true);
|
||||
assert.equal(environment.overview.background.items.length, 1);
|
||||
assert.equal(environment.overview.background.items[0].mediaKind, "video");
|
||||
});
|
||||
|
||||
test("environment media accepts MOV even when the browser omits or varies its MIME", async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "nodedc-device-presentation-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const store = createDeviceManagerPresentationStore({
|
||||
layoutPath: join(root, "presentation.json"),
|
||||
uploadRoot: join(root, "media"),
|
||||
});
|
||||
|
||||
for (const [index, contentType] of ["video/quicktime", "video/x-quicktime", ""].entries()) {
|
||||
const uploaded = await store.saveMedia({
|
||||
bytes: Buffer.from(`mov-${index}`),
|
||||
contentType,
|
||||
originalName: `background-${index}.mov`,
|
||||
kind: "background",
|
||||
});
|
||||
assert.match(uploaded.fileSrc, /^\/device-manager-media\/background-[0-9a-f-]+\.mov$/);
|
||||
assert.equal(await readFile(store.resolveMedia(uploaded.fileSrc), "utf8"), `mov-${index}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("environment presentation persists ordered mixed media and image duration", async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "nodedc-device-presentation-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const store = createDeviceManagerPresentationStore({
|
||||
layoutPath: join(root, "presentation.json"),
|
||||
uploadRoot: join(root, "media"),
|
||||
});
|
||||
const next = defaultPresentation();
|
||||
next.environment.overview.background = {
|
||||
enabled: true,
|
||||
imageDurationSeconds: 17,
|
||||
items: [
|
||||
{
|
||||
id: "video-first",
|
||||
source: "url",
|
||||
url: "https://media.example/device.mov",
|
||||
fileName: null,
|
||||
fileSrc: null,
|
||||
mediaKind: "video",
|
||||
},
|
||||
{
|
||||
id: "image-second",
|
||||
source: "url",
|
||||
url: "https://media.example/device.webp",
|
||||
fileName: null,
|
||||
fileSrc: null,
|
||||
mediaKind: "image",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await store.write(next);
|
||||
const restored = await store.read();
|
||||
assert.equal(restored.environment.overview.background.imageDurationSeconds, 17);
|
||||
assert.deepEqual(
|
||||
restored.environment.overview.background.items.map((item) => item.id),
|
||||
["video-first", "image-second"],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { dirname, extname, resolve, sep } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
|
||||
import {
|
||||
createDeviceCoreClient,
|
||||
createLocalPreviewDeviceCore,
|
||||
} from "./device-core-client.mjs";
|
||||
import {
|
||||
createDeviceManagerPresentationStore,
|
||||
normalizeEnvironmentPresentation,
|
||||
normalizeProjectPresentation,
|
||||
} from "./device-manager-presentation.mjs";
|
||||
|
||||
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const mutationRoutes = new Map([
|
||||
["/api/device-manager/owner-scopes:ensure", "owner-scopes:ensure"],
|
||||
["/api/device-manager/projects:ensure", "projects:ensure"],
|
||||
["/api/device-manager/collections:ensure", "collections:ensure"],
|
||||
["/api/device-manager/project-grants:upsert", "project-grants:upsert"],
|
||||
["/api/device-manager/adapter-packages:ensure", "adapter-packages:ensure"],
|
||||
["/api/device-manager/adapter-versions:register", "adapter-versions:register"],
|
||||
["/api/device-manager/model-profiles:register", "model-profiles:register"],
|
||||
["/api/device-manager/edges:ensure", "edges:ensure"],
|
||||
["/api/device-manager/routes:ensure", "routes:ensure"],
|
||||
["/api/device-manager/enrollment-intents:ensure", "enrollment-intents:ensure"],
|
||||
["/api/device-manager/devices:claim", "devices:claim"],
|
||||
["/api/device-manager/devices:update", "devices:update"],
|
||||
["/api/device-manager/device-bindings:ensure", "device-bindings:ensure"],
|
||||
["/api/device-manager/device-bindings:revoke", "device-bindings:revoke"],
|
||||
[
|
||||
"/api/device-manager/device-configuration-revisions:create",
|
||||
"device-configuration-revisions:create",
|
||||
],
|
||||
[
|
||||
"/api/device-manager/device-configurations:set-desired",
|
||||
"device-configurations:set-desired",
|
||||
],
|
||||
["/api/device-manager/commands:service-ping", "commands:service-ping"],
|
||||
]);
|
||||
|
||||
export function createDeviceManagerServer({
|
||||
auth,
|
||||
coreClient,
|
||||
distRoot = resolve(appRoot, "dist"),
|
||||
presentationStore = createDeviceManagerPresentationStore({
|
||||
layoutPath: resolve(appRoot, "runtime-data/device-manager-presentation.json"),
|
||||
uploadRoot: resolve(appRoot, "runtime-data/device-manager-media"),
|
||||
}),
|
||||
} = {}) {
|
||||
if (!auth || typeof auth.authorize !== "function") {
|
||||
throw new TypeError("device_manager_auth_required");
|
||||
}
|
||||
if (!coreClient || typeof coreClient.listProjects !== "function") {
|
||||
throw new TypeError("device_manager_core_client_required");
|
||||
}
|
||||
|
||||
return createServer(async (request, response) => {
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.setHeader("Referrer-Policy", "same-origin");
|
||||
response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||
try {
|
||||
const url = new URL(
|
||||
request.url || "/",
|
||||
`http://${request.headers.host || "127.0.0.1"}`,
|
||||
);
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/healthz") {
|
||||
return sendJson(response, 200, {
|
||||
ok: true,
|
||||
service: "nodedc-device-manager",
|
||||
authRequired: auth.authRequired,
|
||||
deviceCoreConfigured: coreClient.configured === true,
|
||||
});
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/auth/nodedc/handoff") {
|
||||
return auth.handleHandoff(request, response, url);
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/auth/logout") {
|
||||
return auth.handleLogout(request, response);
|
||||
}
|
||||
if (await auth.authorize(request, response, url)) return;
|
||||
const context = auth.currentContext(request);
|
||||
if (!context) return sendJson(response, 401, {
|
||||
ok: false,
|
||||
error: "device_manager_auth_required",
|
||||
});
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/device-manager/session") {
|
||||
return sendJson(response, 200, { ok: true, session: context });
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/api/device-manager/projects") {
|
||||
const projects = await coreClient.listProjects(context.actor);
|
||||
return sendJson(response, 200, { ok: true, projects });
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/api/device-manager/presentation") {
|
||||
const [presentation, projects] = await Promise.all([
|
||||
presentationStore.read(),
|
||||
coreClient.listProjects(context.actor),
|
||||
]);
|
||||
const allowed = new Set(projects.map((project) => project.projectRef));
|
||||
return sendJson(response, 200, {
|
||||
ok: true,
|
||||
presentation: {
|
||||
environment: presentation.environment,
|
||||
projects: Object.fromEntries(
|
||||
Object.entries(presentation.projects).filter(([ref]) => allowed.has(ref)),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (request.method === "PUT" && url.pathname === "/api/device-manager/presentation/project") {
|
||||
const input = await readJsonBody(request, 128 * 1024);
|
||||
const projectRef = validProjectRef(input.projectRef);
|
||||
await requireProjectManage(coreClient, context.actor, projectRef);
|
||||
const current = await presentationStore.read();
|
||||
current.projects[projectRef] = normalizeProjectPresentation(input.presentation);
|
||||
const presentation = await presentationStore.write(current);
|
||||
return sendJson(response, 200, { ok: true, presentation });
|
||||
}
|
||||
if (request.method === "PUT" && url.pathname === "/api/device-manager/presentation/environment") {
|
||||
requireSuperAdmin(context.actor);
|
||||
const input = await readJsonBody(request, 128 * 1024);
|
||||
const current = await presentationStore.read();
|
||||
current.environment = normalizeEnvironmentPresentation(input.environment);
|
||||
const presentation = await presentationStore.write(current);
|
||||
return sendJson(response, 200, { ok: true, presentation });
|
||||
}
|
||||
if (request.method === "PUT" && url.pathname === "/api/device-manager/presentation/media") {
|
||||
const scope = url.searchParams.get("scope");
|
||||
const kind = url.searchParams.get("kind");
|
||||
if (kind !== "icon" && kind !== "teaser" && kind !== "background") {
|
||||
throw serviceError("device_manager_media_kind_invalid", 400);
|
||||
}
|
||||
if (scope === "environment") {
|
||||
requireSuperAdmin(context.actor);
|
||||
if (kind !== "background") throw serviceError("device_manager_media_kind_invalid", 400);
|
||||
} else if (scope === "project") {
|
||||
if (kind === "background") throw serviceError("device_manager_media_kind_invalid", 400);
|
||||
await requireProjectManage(coreClient, context.actor, validProjectRef(url.searchParams.get("projectRef")));
|
||||
} else {
|
||||
throw serviceError("device_manager_media_scope_invalid", 400);
|
||||
}
|
||||
const bytes = await readBody(request, kind === "icon" ? 8 * 1024 * 1024 : 256 * 1024 * 1024);
|
||||
const media = await presentationStore.saveMedia({
|
||||
bytes,
|
||||
contentType: request.headers["content-type"],
|
||||
originalName: singleOptionalHeader(request.headers["x-file-name"]),
|
||||
kind,
|
||||
});
|
||||
return sendJson(response, 200, { ok: true, ...media });
|
||||
}
|
||||
if ((request.method === "GET" || request.method === "HEAD") && url.pathname.startsWith("/device-manager-media/")) {
|
||||
const mediaPath = presentationStore.resolveMedia(url.pathname);
|
||||
if (!mediaPath) return sendJson(response, 404, { ok: false, error: "device_manager_media_not_found" });
|
||||
return serveFile(request, response, mediaPath, "private, max-age=300");
|
||||
}
|
||||
const projectRef = workspaceProjectRef(url.pathname);
|
||||
if (request.method === "GET" && projectRef) {
|
||||
const workspace = await coreClient.getWorkspace(context.actor, projectRef);
|
||||
return sendJson(response, 200, { ok: true, workspace });
|
||||
}
|
||||
const command = mutationRoutes.get(url.pathname);
|
||||
if (request.method === "POST" && command) {
|
||||
const idempotencyKey = singleHeader(request.headers["idempotency-key"]);
|
||||
const input = await readJsonBody(request, 64 * 1024);
|
||||
const execution = await coreClient.execute(
|
||||
command,
|
||||
context.actor,
|
||||
input,
|
||||
idempotencyKey,
|
||||
);
|
||||
response.setHeader("Idempotency-Key", idempotencyKey);
|
||||
response.setHeader(
|
||||
"Idempotency-Replayed",
|
||||
execution.replayed ? "true" : "false",
|
||||
);
|
||||
return sendJson(response, 200, { ok: true, ...execution });
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return sendJson(response, 404, { ok: false, error: "device_manager_route_not_found" });
|
||||
}
|
||||
return serveStatic(request, response, url, distRoot);
|
||||
} catch (error) {
|
||||
if (response.headersSent) {
|
||||
response.destroy();
|
||||
return;
|
||||
}
|
||||
const statusCode = normalizeStatus(error?.statusCode);
|
||||
return sendJson(response, statusCode, {
|
||||
ok: false,
|
||||
error: safeError(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function createConfiguredDeviceManagerServer({ env = process.env } = {}) {
|
||||
const localPreview = booleanValue(env.NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW, false);
|
||||
const launcherTokenFile = String(env.NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE || "").trim();
|
||||
const launcherInternalToken = launcherTokenFile
|
||||
? (await readFile(launcherTokenFile, "utf8")).trim()
|
||||
: undefined;
|
||||
const auth = createDeviceManagerAuth({ env, internalToken: launcherInternalToken });
|
||||
if (auth.authRequired && !auth.internalAccessConfigured) {
|
||||
throw new Error("device_manager_auth_token_file_required");
|
||||
}
|
||||
let coreClient;
|
||||
if (localPreview) {
|
||||
if (String(env.NODE_ENV || "").toLowerCase() === "production") {
|
||||
throw new Error("device_manager_local_preview_forbidden");
|
||||
}
|
||||
coreClient = createLocalPreviewDeviceCore({
|
||||
fixture: String(env.NODEDC_DEVICE_MANAGER_PREVIEW_FIXTURE || "").trim() || null,
|
||||
});
|
||||
} else {
|
||||
const tokenFile = String(env.NODEDC_DEVICE_CORE_TOKEN_FILE || "").trim();
|
||||
if (!tokenFile) throw new Error("device_core_token_file_required");
|
||||
const token = (await readFile(tokenFile, "utf8")).trim();
|
||||
coreClient = createDeviceCoreClient({
|
||||
baseUrl: env.NODEDC_DEVICE_CORE_INTERNAL_URL,
|
||||
token,
|
||||
});
|
||||
}
|
||||
const presentationStore = createDeviceManagerPresentationStore({
|
||||
layoutPath: String(env.NODEDC_DEVICE_MANAGER_PRESENTATION_PATH || resolve(appRoot, "runtime-data/device-manager-presentation.json")),
|
||||
uploadRoot: String(env.NODEDC_DEVICE_MANAGER_MEDIA_ROOT || resolve(appRoot, "runtime-data/device-manager-media")),
|
||||
});
|
||||
return createDeviceManagerServer({ auth, coreClient, presentationStore });
|
||||
}
|
||||
|
||||
async function serveStatic(request, response, url, root) {
|
||||
const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname;
|
||||
const candidate = resolve(root, `.${decodeURIComponent(requestedPath)}`);
|
||||
const normalizedRoot = resolve(root);
|
||||
if (candidate !== normalizedRoot && !candidate.startsWith(`${normalizedRoot}${sep}`)) {
|
||||
return sendJson(response, 404, { ok: false, error: "device_manager_asset_not_found" });
|
||||
}
|
||||
let filePath = candidate;
|
||||
let info = await stat(filePath).catch(() => null);
|
||||
if ((!info || !info.isFile()) && !extname(requestedPath)) {
|
||||
filePath = resolve(root, "index.html");
|
||||
info = await stat(filePath).catch(() => null);
|
||||
}
|
||||
if (!info?.isFile()) {
|
||||
return sendJson(response, 404, { ok: false, error: "device_manager_asset_not_found" });
|
||||
}
|
||||
return serveFile(request, response, filePath, filePath.endsWith("index.html") ? "no-store" : "private, max-age=300", info);
|
||||
}
|
||||
|
||||
async function serveFile(request, response, filePath, cacheControl, existingInfo = null) {
|
||||
const info = existingInfo || await stat(filePath).catch(() => null);
|
||||
if (!info?.isFile()) return sendJson(response, 404, { ok: false, error: "device_manager_media_not_found" });
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", contentType(filePath));
|
||||
response.setHeader("Cache-Control", cacheControl);
|
||||
response.setHeader("Content-Length", info.size);
|
||||
if (request.method === "HEAD") return response.end();
|
||||
createReadStream(filePath).pipe(response);
|
||||
}
|
||||
|
||||
function workspaceProjectRef(pathname) {
|
||||
const match = pathname.match(/^\/api\/device-manager\/projects\/([^/]+)\/workspace$/);
|
||||
if (!match) return null;
|
||||
const projectRef = decodeURIComponent(match[1]);
|
||||
return /^project:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(projectRef)
|
||||
? projectRef.toLowerCase()
|
||||
: null;
|
||||
}
|
||||
|
||||
async function readJsonBody(request, maxBytes) {
|
||||
const bytes = await readBody(request, maxBytes);
|
||||
try {
|
||||
const body = JSON.parse(bytes.toString("utf8") || "{}");
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error();
|
||||
return body;
|
||||
} catch {
|
||||
throw serviceError("device_manager_json_invalid", 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function readBody(request, maxBytes) {
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) throw serviceError("device_manager_request_too_large", 413);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function validProjectRef(value) {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
if (!/^project:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) {
|
||||
throw serviceError("device_project_ref_invalid", 400);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function requireProjectManage(coreClient, actor, projectRef) {
|
||||
const workspace = await coreClient.getWorkspace(actor, projectRef);
|
||||
if (!workspace.project.access.capabilities.includes("project.manage")) {
|
||||
throw serviceError("device_project_capability_denied", 403);
|
||||
}
|
||||
}
|
||||
|
||||
function requireSuperAdmin(actor) {
|
||||
const groups = new Set(actor.groupRefs || []);
|
||||
if (!groups.has("group:nodedc:superadmin") && !groups.has("nodedc:superadmin")) {
|
||||
throw serviceError("device_environment_settings_denied", 403);
|
||||
}
|
||||
}
|
||||
|
||||
function singleHeader(value) {
|
||||
if (Array.isArray(value) || typeof value !== "string") {
|
||||
throw serviceError("device_idempotency_key_invalid", 400);
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (!/^[\x21-\x7e]{8,256}$/.test(normalized)) {
|
||||
throw serviceError("device_idempotency_key_invalid", 400);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function singleOptionalHeader(value) {
|
||||
if (value == null) return "";
|
||||
if (Array.isArray(value) || typeof value !== "string") throw serviceError("device_manager_header_invalid", 400);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, body) {
|
||||
response.statusCode = statusCode;
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function contentType(pathname) {
|
||||
return ({
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".avif": "image/avif",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mov": "video/quicktime",
|
||||
".ico": "image/x-icon",
|
||||
})[extname(pathname).toLowerCase()] || "application/octet-stream";
|
||||
}
|
||||
|
||||
function normalizeStatus(value) {
|
||||
const status = Number(value || 500);
|
||||
return Number.isInteger(status) && status >= 400 && status < 600 ? status : 500;
|
||||
}
|
||||
|
||||
function safeError(error) {
|
||||
const value = String(error?.message || "");
|
||||
return /^(?:device|nodedc)_[a-z0-9._:-]{2,160}$/.test(value)
|
||||
? value
|
||||
: "device_manager_internal_error";
|
||||
}
|
||||
|
||||
function booleanValue(value, fallback) {
|
||||
if (value == null || value === "") return fallback;
|
||||
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
|
||||
}
|
||||
|
||||
function serviceError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const server = await createConfiguredDeviceManagerServer();
|
||||
const port = Number.parseInt(process.env.PORT || "3335", 10);
|
||||
const host = String(process.env.HOST || "127.0.0.1");
|
||||
server.listen(port, host, () => {
|
||||
console.log(JSON.stringify({
|
||||
event: "device_manager_started",
|
||||
host,
|
||||
port,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { createLocalPreviewDeviceCore } from "./device-core-client.mjs";
|
||||
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
|
||||
import {
|
||||
createConfiguredDeviceManagerServer,
|
||||
createDeviceManagerServer,
|
||||
} from "./device-manager-server.mjs";
|
||||
|
||||
test("production configuration starts with runner-owned file tokens", async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "nodedc-device-manager-production-"));
|
||||
const launcherTokenFile = join(root, "launcher-token");
|
||||
const coreTokenFile = join(root, "core-token");
|
||||
await writeFile(launcherTokenFile, `${"a".repeat(48)}\n`, { mode: 0o640 });
|
||||
await writeFile(coreTokenFile, `${"b".repeat(48)}\n`, { mode: 0o640 });
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
|
||||
const server = await createConfiguredDeviceManagerServer({
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "true",
|
||||
NODEDC_DEVICE_MANAGER_COOKIE_SECURE: "true",
|
||||
NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW: "false",
|
||||
NODEDC_DEVICE_MANAGER_SERVICE_SLUG: "device-core",
|
||||
NODEDC_LAUNCHER_BASE_URL: "https://hub.nodedc.ru",
|
||||
NODEDC_LAUNCHER_INTERNAL_URL: "http://launcher:5173",
|
||||
NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: launcherTokenFile,
|
||||
NODEDC_DEVICE_CORE_INTERNAL_URL: "http://device-control-core:18120",
|
||||
NODEDC_DEVICE_CORE_TOKEN_FILE: coreTokenFile,
|
||||
},
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
const address = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}/healthz`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
ok: true,
|
||||
service: "nodedc-device-manager",
|
||||
authRequired: true,
|
||||
deviceCoreConfigured: true,
|
||||
});
|
||||
|
||||
const rootResponse = await fetch(`http://127.0.0.1:${address.port}/`, {
|
||||
redirect: "manual",
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
assert.equal(rootResponse.status, 302);
|
||||
assert.equal(
|
||||
rootResponse.headers.get("location"),
|
||||
"https://hub.nodedc.ru/auth/login?returnTo=%2Fapi%2Fservices%2Fdevice-core%2Flaunch%3FreturnTo%3D%252F",
|
||||
);
|
||||
|
||||
const healthAfterRedirect = await fetch(`http://127.0.0.1:${address.port}/healthz`);
|
||||
assert.equal(healthAfterRedirect.status, 200);
|
||||
assert.equal((await healthAfterRedirect.json()).ok, true);
|
||||
});
|
||||
|
||||
test("Device Manager BFF exposes an empty, mutation-driven project workspace", async (t) => {
|
||||
const auth = createDeviceManagerAuth({
|
||||
env: { NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "false" },
|
||||
});
|
||||
const coreClient = createLocalPreviewDeviceCore();
|
||||
const server = createDeviceManagerServer({ auth, coreClient });
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
const address = server.address();
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
const session = await getJson(`${baseUrl}/api/device-manager/session`);
|
||||
assert.equal(session.session.actor.hubRole, "owner");
|
||||
assert.equal(session.session.actor.userRef, "user:local-device-admin");
|
||||
assert.deepEqual((await getJson(`${baseUrl}/api/device-manager/projects`)).projects, []);
|
||||
|
||||
await postJson(`${baseUrl}/api/device-manager/owner-scopes:ensure`, {
|
||||
scopeKind: "personal",
|
||||
ownerRef: "user:local-device-admin",
|
||||
displayName: "Local Device Admin",
|
||||
}, {
|
||||
"X-NODEDC-Hub-Role": "viewer",
|
||||
"X-NODEDC-User-Ref": "user:spoofed-browser",
|
||||
});
|
||||
const created = await postJson(`${baseUrl}/api/device-manager/projects:ensure`, {
|
||||
scopeKind: "personal",
|
||||
ownerRef: "user:local-device-admin",
|
||||
projectKey: "device-sandbox",
|
||||
name: "Device sandbox",
|
||||
description: "Created only through the canonical command path",
|
||||
});
|
||||
const projectRef = created.result.project.projectRef;
|
||||
|
||||
const projects = (await getJson(`${baseUrl}/api/device-manager/projects`)).projects;
|
||||
assert.equal(projects.length, 1);
|
||||
assert.equal(projects[0].projectKey, "device-sandbox");
|
||||
assert.equal(projects[0].ownerScope.ownerRef, "user:local-device-admin");
|
||||
|
||||
await postJson(`${baseUrl}/api/device-manager/collections:ensure`, {
|
||||
projectRef,
|
||||
collectionKey: "pilot-devices",
|
||||
name: "Pilot devices",
|
||||
description: null,
|
||||
});
|
||||
await postJson(`${baseUrl}/api/device-manager/adapter-packages:ensure`, {
|
||||
packageKey: "generic-sensor",
|
||||
displayName: "Generic sensor",
|
||||
publisherRef: "publisher:nodedc",
|
||||
lifecycleState: "active",
|
||||
});
|
||||
await postJson(`${baseUrl}/api/device-manager/edges:ensure`, {
|
||||
edgeKey: "preview-edge",
|
||||
displayName: "Preview Edge",
|
||||
deploymentRef: "deployment:preview-edge",
|
||||
lifecycleState: "provisioning",
|
||||
});
|
||||
await postJson(`${baseUrl}/api/device-manager/project-grants:upsert`, {
|
||||
projectRef,
|
||||
principalKind: "group",
|
||||
principalRef: "group:preview-viewers",
|
||||
projectRole: "viewer",
|
||||
capabilityAllow: [],
|
||||
capabilityDeny: [],
|
||||
lifecycleState: "active",
|
||||
});
|
||||
const workspace = await getJson(
|
||||
`${baseUrl}/api/device-manager/projects/${encodeURIComponent(projectRef)}/workspace`,
|
||||
);
|
||||
assert.equal(workspace.workspace.project.projectRef, projectRef);
|
||||
assert.equal(workspace.workspace.collections[0].collectionKey, "pilot-devices");
|
||||
assert.equal(workspace.workspace.adapterPackages[0].packageKey, "generic-sensor");
|
||||
assert.equal(workspace.workspace.edges[0].edgeKey, "preview-edge");
|
||||
assert.equal(workspace.workspace.grants.length, 2);
|
||||
assert.equal(workspace.workspace.policies.commandTransport, "disabled");
|
||||
assert.deepEqual(workspace.workspace.devices, []);
|
||||
|
||||
const missingKey = await fetch(`${baseUrl}/api/device-manager/projects:ensure`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
assert.equal(missingKey.status, 400);
|
||||
assert.equal((await missingKey.json()).error, "device_idempotency_key_invalid");
|
||||
});
|
||||
|
||||
async function getJson(url) {
|
||||
const response = await fetch(url, { headers: { accept: "application/json" } });
|
||||
const body = await response.json();
|
||||
assert.equal(response.status, 200, JSON.stringify(body));
|
||||
assert.equal(body.ok, true);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function postJson(url, body, headers = {}) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/json",
|
||||
"idempotency-key": `device-manager-test-${crypto.randomUUID()}`,
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await response.json();
|
||||
assert.equal(response.status, 200, JSON.stringify(payload));
|
||||
assert.equal(payload.ok, true);
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
Select,
|
||||
SettingsCard,
|
||||
StatusBadge,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
createConfigurationRevision,
|
||||
ensureAdapterPackage,
|
||||
ensureDeviceBinding,
|
||||
ensureEdge,
|
||||
ensureRoute,
|
||||
registerAdapterVersion,
|
||||
registerModelProfile,
|
||||
revokeDeviceBinding,
|
||||
sendServicePing,
|
||||
setDesiredConfiguration,
|
||||
upsertProjectGrant,
|
||||
} from "./api";
|
||||
import type {
|
||||
AdapterPackageView,
|
||||
AdapterVersionView,
|
||||
BindingView,
|
||||
DeviceManagerSession,
|
||||
EdgeView,
|
||||
ModelProfileView,
|
||||
ProjectWorkspace,
|
||||
} from "./types";
|
||||
|
||||
export type ControlViewId =
|
||||
| "catalog"
|
||||
| "infrastructure"
|
||||
| "sessions"
|
||||
| "bindings"
|
||||
| "commands"
|
||||
| "audit"
|
||||
| "access"
|
||||
| "settings";
|
||||
|
||||
type DialogId =
|
||||
| "adapter-package"
|
||||
| "adapter-version"
|
||||
| "model-profile"
|
||||
| "edge"
|
||||
| "route"
|
||||
| "binding"
|
||||
| "grant"
|
||||
| "configuration"
|
||||
| null;
|
||||
|
||||
export function DeviceControlView({
|
||||
view,
|
||||
workspace,
|
||||
session,
|
||||
onRefresh,
|
||||
onError,
|
||||
}: {
|
||||
view: ControlViewId;
|
||||
workspace: ProjectWorkspace;
|
||||
session: DeviceManagerSession;
|
||||
onRefresh: () => Promise<void>;
|
||||
onError: (reason: unknown) => void;
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<DialogId>(null);
|
||||
const capabilities = new Set(workspace.project.access.capabilities);
|
||||
const platformOwner = session.actor.hubRole === "owner";
|
||||
const close = () => setDialog(null);
|
||||
const completed = async () => {
|
||||
close();
|
||||
await onRefresh();
|
||||
};
|
||||
const mutateAndRefresh = async (mutation: () => Promise<unknown>) => {
|
||||
try {
|
||||
await mutation();
|
||||
await onRefresh();
|
||||
} catch (reason) {
|
||||
onError(reason);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{view === "catalog" ? (
|
||||
<CatalogView
|
||||
workspace={workspace}
|
||||
canManage={platformOwner}
|
||||
onCreatePackage={() => setDialog("adapter-package")}
|
||||
onCreateVersion={() => setDialog("adapter-version")}
|
||||
onCreateProfile={() => setDialog("model-profile")}
|
||||
onActivateVersion={(version) => mutateAndRefresh(() => registerAdapterVersion({
|
||||
adapterPackageRef: version.adapterPackageRef,
|
||||
version: version.version,
|
||||
runtimePackageRef: version.runtimePackageRef,
|
||||
contentDigest: version.contentDigest,
|
||||
contractVersion: version.contractVersion,
|
||||
capabilities: version.capabilities,
|
||||
lifecycleState: "active",
|
||||
}))}
|
||||
onActivateProfile={(profile) => mutateAndRefresh(() => registerModelProfile({
|
||||
adapterVersionRef: profile.adapterVersionRef || "",
|
||||
profileRef: profile.modelProfileRef,
|
||||
schemaVersion: profile.schemaVersion,
|
||||
vendor: profile.vendor,
|
||||
model: profile.model,
|
||||
deviceType: profile.deviceType,
|
||||
protocol: profile.protocol,
|
||||
schemaArtifactRef: profile.schemaArtifactRef || "",
|
||||
profileDigest: profile.profileDigest || "",
|
||||
capabilities: profile.capabilities,
|
||||
lifecycleState: "active",
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
{view === "infrastructure" ? (
|
||||
<InfrastructureView
|
||||
workspace={workspace}
|
||||
canManageCatalog={platformOwner}
|
||||
canManageRoutes={capabilities.has("route.manage")}
|
||||
onCreateEdge={() => setDialog("edge")}
|
||||
onCreateRoute={() => setDialog("route")}
|
||||
onActivateEdge={(edge) => mutateAndRefresh(() => ensureEdge({
|
||||
edgeKey: edge.edgeKey,
|
||||
displayName: edge.displayName,
|
||||
deploymentRef: edge.deploymentRef,
|
||||
lifecycleState: "active",
|
||||
}))}
|
||||
onActivateRoute={(route) => mutateAndRefresh(() => ensureRoute({
|
||||
projectRef: workspace.project.projectRef,
|
||||
routeKey: route.routeKey,
|
||||
displayName: route.displayName,
|
||||
edgeRef: route.edgeRef,
|
||||
modelProfileRef: route.modelProfileRef,
|
||||
listenerRef: route.listenerRef,
|
||||
protocol: route.protocol,
|
||||
direction: route.direction,
|
||||
lifecycleState: "active",
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
{view === "sessions" ? <SessionsView workspace={workspace} /> : null}
|
||||
{view === "bindings" ? (
|
||||
<BindingsView
|
||||
workspace={workspace}
|
||||
canManage={capabilities.has("binding.manage")}
|
||||
onCreate={() => setDialog("binding")}
|
||||
onRevoke={(binding) => revokeDeviceBinding({
|
||||
projectRef: workspace.project.projectRef,
|
||||
bindingRef: binding.bindingRef,
|
||||
resolutionCode: "operator.revoked",
|
||||
}).then(onRefresh).catch(onError)}
|
||||
/>
|
||||
) : null}
|
||||
{view === "commands" ? (
|
||||
<CommandsView
|
||||
workspace={workspace}
|
||||
canDispatch={capabilities.has("command.plan") && capabilities.has("command.dispatch")}
|
||||
onRefresh={onRefresh}
|
||||
onError={onError}
|
||||
/>
|
||||
) : null}
|
||||
{view === "audit" ? <AuditView workspace={workspace} /> : null}
|
||||
{view === "access" ? (
|
||||
<AccessView
|
||||
workspace={workspace}
|
||||
canManage={capabilities.has("access.manage")}
|
||||
onCreate={() => setDialog("grant")}
|
||||
/>
|
||||
) : null}
|
||||
{view === "settings" ? (
|
||||
<SettingsView
|
||||
workspace={workspace}
|
||||
canConfigure={capabilities.has("configuration.manage")}
|
||||
onCreateConfiguration={() => setDialog("configuration")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AdapterPackageDialog
|
||||
open={dialog === "adapter-package"}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
<AdapterVersionDialog
|
||||
open={dialog === "adapter-version"}
|
||||
packages={workspace.adapterPackages}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
<ModelProfileDialog
|
||||
open={dialog === "model-profile"}
|
||||
versions={workspace.adapterVersions}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
<EdgeDialog
|
||||
open={dialog === "edge"}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
<RouteDialog
|
||||
open={dialog === "route"}
|
||||
workspace={workspace}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
<BindingDialog
|
||||
open={dialog === "binding"}
|
||||
workspace={workspace}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
<GrantDialog
|
||||
open={dialog === "grant"}
|
||||
projectRef={workspace.project.projectRef}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
<ConfigurationDialog
|
||||
open={dialog === "configuration"}
|
||||
workspace={workspace}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, onCreateProfile, onActivateVersion, onActivateProfile }: {
|
||||
workspace: ProjectWorkspace;
|
||||
canManage: boolean;
|
||||
onCreatePackage: () => void;
|
||||
onCreateVersion: () => void;
|
||||
onCreateProfile: () => void;
|
||||
onActivateVersion: (version: AdapterVersionView) => void;
|
||||
onActivateProfile: (profile: ModelProfileView) => void;
|
||||
}) {
|
||||
return (
|
||||
<ControlStack>
|
||||
<ControlToolbar
|
||||
copy="Adapter packages и model profiles — глобальный versioned каталог. B2 здесь не является отдельным продуктом."
|
||||
actions={canManage ? <>
|
||||
<Button size="compact" onClick={onCreatePackage}>Пакет</Button>
|
||||
<Button size="compact" onClick={onCreateVersion} disabled={!workspace.adapterPackages.length}>Версия</Button>
|
||||
<Button size="compact" variant="primary" onClick={onCreateProfile} disabled={!workspace.adapterVersions.length}>Профиль</Button>
|
||||
</> : null}
|
||||
/>
|
||||
<ControlSection title="Model profiles" count={workspace.modelProfiles.length}>
|
||||
<ResourceGrid empty="В доступном каталоге пока нет model profiles.">
|
||||
{workspace.modelProfiles.map((profile) => (
|
||||
<ResourceCard
|
||||
key={profile.modelProfileRef}
|
||||
eyebrow={`${profile.vendor} · ${profile.deviceType}`}
|
||||
title={`${profile.model}`}
|
||||
description={`${profile.protocol} · ${profile.modelProfileRef}`}
|
||||
status={profile.lifecycleState}
|
||||
meta={profile.capabilities}
|
||||
action={canManage && profile.lifecycleState === "draft" && profile.adapterVersionRef && profile.schemaArtifactRef && profile.profileDigest ? (
|
||||
<Button size="compact" variant="primary" onClick={() => onActivateProfile(profile)}>Активировать</Button>
|
||||
) : null}
|
||||
/>
|
||||
))}
|
||||
</ResourceGrid>
|
||||
</ControlSection>
|
||||
<ControlSection title="Adapter versions" count={workspace.adapterVersions.length}>
|
||||
<ResourceGrid empty="Версии адаптеров не зарегистрированы.">
|
||||
{workspace.adapterVersions.map((version) => (
|
||||
<ResourceCard
|
||||
key={version.adapterVersionRef}
|
||||
eyebrow={version.contractVersion}
|
||||
title={version.version}
|
||||
description={version.runtimePackageRef}
|
||||
status={version.lifecycleState}
|
||||
meta={[shortDigest(version.contentDigest), ...version.capabilities]}
|
||||
action={canManage && version.lifecycleState === "draft" ? (
|
||||
<Button size="compact" variant="primary" onClick={() => onActivateVersion(version)}>Активировать</Button>
|
||||
) : null}
|
||||
/>
|
||||
))}
|
||||
</ResourceGrid>
|
||||
</ControlSection>
|
||||
<ControlSection title="Adapter packages" count={workspace.adapterPackages.length}>
|
||||
<ResourceGrid empty="Adapter packages не зарегистрированы.">
|
||||
{workspace.adapterPackages.map((adapterPackage) => (
|
||||
<ResourceCard
|
||||
key={adapterPackage.adapterPackageRef}
|
||||
eyebrow={adapterPackage.publisherRef}
|
||||
title={adapterPackage.displayName}
|
||||
description={adapterPackage.packageKey}
|
||||
status={adapterPackage.lifecycleState}
|
||||
meta={workspace.adapterVersions
|
||||
.filter((version) => version.adapterPackageRef === adapterPackage.adapterPackageRef)
|
||||
.map((version) => `${version.version} · ${version.lifecycleState}`)}
|
||||
/>
|
||||
))}
|
||||
</ResourceGrid>
|
||||
</ControlSection>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCreateEdge, onCreateRoute, onActivateEdge, onActivateRoute }: {
|
||||
workspace: ProjectWorkspace;
|
||||
canManageCatalog: boolean;
|
||||
canManageRoutes: boolean;
|
||||
onCreateEdge: () => void;
|
||||
onCreateRoute: () => void;
|
||||
onActivateEdge: (edge: EdgeView) => void;
|
||||
onActivateRoute: (route: ProjectWorkspace["routes"][number]) => void;
|
||||
}) {
|
||||
return (
|
||||
<ControlStack>
|
||||
<ControlToolbar
|
||||
copy="Edge — зарегистрированная внешняя роль. Route связывает проект, Edge, profile и логический listener без credentials."
|
||||
actions={<>
|
||||
{canManageCatalog ? <Button size="compact" onClick={onCreateEdge}>Новый Edge</Button> : null}
|
||||
{canManageRoutes ? <Button size="compact" variant="primary" onClick={onCreateRoute} disabled={!workspace.edges.length || !workspace.modelProfiles.length}>Новый маршрут</Button> : null}
|
||||
</>}
|
||||
/>
|
||||
<ControlSection title="Routes" count={workspace.routes.length}>
|
||||
<ResourceGrid empty="Маршрутов в проекте пока нет.">
|
||||
{workspace.routes.map((route) => (
|
||||
<ResourceCard
|
||||
key={route.routeRef}
|
||||
eyebrow={`${route.protocol} · ${route.direction}`}
|
||||
title={route.displayName}
|
||||
description={`${route.edgeName} → ${route.profileName}`}
|
||||
status={route.lifecycleState}
|
||||
meta={[
|
||||
route.listenerRef,
|
||||
`${route.activeSessionCount}/${route.sessionCount} активных сессий`,
|
||||
]}
|
||||
action={canManageRoutes && ["draft", "suspended"].includes(route.lifecycleState) ? (
|
||||
<Button size="compact" variant="primary" onClick={() => onActivateRoute(route)}>Активировать</Button>
|
||||
) : null}
|
||||
/>
|
||||
))}
|
||||
</ResourceGrid>
|
||||
</ControlSection>
|
||||
<ControlSection title="Edges" count={workspace.edges.length}>
|
||||
<ResourceGrid empty="Доступных Edge registrations нет.">
|
||||
{workspace.edges.map((edge) => (
|
||||
<ResourceCard
|
||||
key={edge.edgeRef}
|
||||
eyebrow="DEVICE GATEWAY EDGE"
|
||||
title={edge.displayName}
|
||||
description={edge.edgeKey}
|
||||
status={edge.lifecycleState}
|
||||
meta={edge.deploymentRef ? [edge.deploymentRef] : []}
|
||||
action={canManageCatalog && ["provisioning", "suspended"].includes(edge.lifecycleState) ? (
|
||||
<Button size="compact" variant="primary" onClick={() => onActivateEdge(edge)}>Активировать</Button>
|
||||
) : null}
|
||||
/>
|
||||
))}
|
||||
</ResourceGrid>
|
||||
</ControlSection>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsView({ workspace }: { workspace: ProjectWorkspace }) {
|
||||
return (
|
||||
<ControlStack>
|
||||
<ControlToolbar copy="Сессии принадлежат Gateway runtime. Device Manager только читает bounded presence/counter projection." />
|
||||
<ResourceList empty="Gateway sessions пока не наблюдались.">
|
||||
{workspace.sessions.map((session) => (
|
||||
<ResourceRow
|
||||
key={session.sessionRef}
|
||||
title={session.deviceName || "Неидентифицированная сессия"}
|
||||
description={`${session.routeName} · ${session.protocol} · ${formatDate(session.lastSeenAt)}`}
|
||||
status={session.lifecycleState}
|
||||
trailing={`${session.frameCount} frames · ${formatBytes(session.byteCount)}`}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function BindingsView({ workspace, canManage, onCreate, onRevoke }: {
|
||||
workspace: ProjectWorkspace;
|
||||
canManage: boolean;
|
||||
onCreate: () => void;
|
||||
onRevoke: (binding: BindingView) => void;
|
||||
}) {
|
||||
return (
|
||||
<ControlStack>
|
||||
<ControlToolbar
|
||||
copy="Binding создаёт только source approval. Active появится лишь после отдельного external proof от целевой системы."
|
||||
actions={canManage ? <Button variant="primary" onClick={onCreate} disabled={!workspace.collections.length && !workspace.devices.length}>Новый binding</Button> : null}
|
||||
/>
|
||||
<ResourceList empty="Data bindings пока не создавались.">
|
||||
{workspace.bindings.map((binding) => (
|
||||
<ResourceRow
|
||||
key={binding.bindingRef}
|
||||
title={binding.displayName}
|
||||
description={`${binding.source.displayName} → ${binding.target.kind}:${binding.target.ref}`}
|
||||
status={binding.lifecycleState}
|
||||
trailing={binding.lifecycleState !== "revoked" && canManage ? (
|
||||
<Button size="compact" variant="danger" onClick={() => onRevoke(binding)}>Отозвать</Button>
|
||||
) : binding.capabilities.join(", ")}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandsView({ workspace, canDispatch, onRefresh, onError }: {
|
||||
workspace: ProjectWorkspace;
|
||||
canDispatch: boolean;
|
||||
onRefresh: () => Promise<void>;
|
||||
onError: (reason: unknown) => void;
|
||||
}) {
|
||||
const supportedDevices = workspace.devices.filter(
|
||||
(device) => device.modelProfileRef === "arusnavi.b2.internal.v1"
|
||||
&& !["suspended", "retired"].includes(device.lifecycleState),
|
||||
);
|
||||
const [deviceRef, setDeviceRef] = useState(supportedDevices[0]?.deviceRef ?? "");
|
||||
const [accessCode, setAccessCode] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const enabled = workspace.policies.commandTransport === "typed-service-ping-v1";
|
||||
useEffect(() => {
|
||||
if (!supportedDevices.some((device) => device.deviceRef === deviceRef)) {
|
||||
setDeviceRef(supportedDevices[0]?.deviceRef ?? "");
|
||||
}
|
||||
}, [deviceRef, supportedDevices]);
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!enabled || !canDispatch || !deviceRef || !/^\d{6}$/.test(accessCode)) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await sendServicePing({
|
||||
projectRef: workspace.project.projectRef,
|
||||
deviceRef,
|
||||
accessCode,
|
||||
expiresInSeconds: 300,
|
||||
});
|
||||
setAccessCode("");
|
||||
await onRefresh();
|
||||
} catch (reason) {
|
||||
onError(reason);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ControlStack>
|
||||
<GlassSurface className="device-control-command-policy" padding="md" tone="soft">
|
||||
<Icon name={enabled ? "check" : "lock"} />
|
||||
<div>
|
||||
<strong>{enabled ? "Типизированный командный канал активен" : "Command transport выключен"}</strong>
|
||||
<p>{enabled
|
||||
? "Доступна только безопасная проверка сервиса. Произвольные команды, прошивка, очистка памяти и перезагрузка отсутствуют. Код устройства существует только в памяти Core до отправки или истечения TTL."
|
||||
: "Ни UI, ни BFF не имеют raw command builder. acknowledged означает подтверждение протокола, verified — отдельное доказательство состояния."}</p>
|
||||
</div>
|
||||
<StatusBadge tone={enabled ? "success" : "warning"}>{workspace.policies.commandTransport}</StatusBadge>
|
||||
</GlassSurface>
|
||||
{enabled ? (
|
||||
<GlassSurface padding="md" tone="soft">
|
||||
<form className="device-control-command-form" onSubmit={submit}>
|
||||
<Select
|
||||
label="B2 трекер"
|
||||
value={deviceRef}
|
||||
onChange={setDeviceRef}
|
||||
options={supportedDevices.map((device) => ({
|
||||
value: device.deviceRef,
|
||||
label: device.displayName,
|
||||
description: device.session?.state || device.lifecycleState,
|
||||
}))}
|
||||
disabled={!canDispatch || supportedDevices.length === 0 || submitting}
|
||||
/>
|
||||
<TextField
|
||||
label="Код устройства"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={accessCode}
|
||||
onChange={(event) => setAccessCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
pattern="[0-9]{6}"
|
||||
minLength={6}
|
||||
maxLength={6}
|
||||
required
|
||||
disabled={!canDispatch || submitting}
|
||||
description="Ровно 6 цифр. Код не сохраняется и не попадает в журнал. Команда истечёт через 5 минут."
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!canDispatch || !deviceRef || accessCode.length !== 6 || submitting}
|
||||
>
|
||||
{submitting ? "Ставим в очередь…" : "Проверить сервис"}
|
||||
</Button>
|
||||
</form>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
<ResourceList empty="Command intents отсутствуют. Это не означает, что транспорт доступен.">
|
||||
{workspace.commands.map((command) => (
|
||||
<ResourceRow
|
||||
key={command.commandRef}
|
||||
title={`${command.commandType} · ${command.deviceName}`}
|
||||
description={`${command.riskClass} · expires ${formatDate(command.expiresAt)}`}
|
||||
status={command.lifecycleState}
|
||||
trailing={command.terminalReasonCode || command.commandKey}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditView({ workspace }: { workspace: ProjectWorkspace }) {
|
||||
return (
|
||||
<ControlStack>
|
||||
<ControlToolbar copy="Показывается immutable metadata projection. Audit payload намеренно не выдаётся в браузер." />
|
||||
<ResourceList empty="Audit events для проекта отсутствуют.">
|
||||
{workspace.auditEvents.map((event) => (
|
||||
<ResourceRow
|
||||
key={event.auditEventRef}
|
||||
title={event.eventType}
|
||||
description={`${event.actorRef} · ${formatDate(event.occurredAt)}`}
|
||||
status="recorded"
|
||||
trailing={event.deviceRef || event.discoveryRef || "project"}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessView({ workspace, canManage, onCreate }: {
|
||||
workspace: ProjectWorkspace;
|
||||
canManage: boolean;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ControlStack>
|
||||
<ControlToolbar
|
||||
copy="Hub задаёт потолок, а Device Project grant — конкретную роль. Direct user grant имеет приоритет над group grants."
|
||||
actions={canManage ? <Button variant="primary" onClick={onCreate}>Добавить доступ</Button> : null}
|
||||
/>
|
||||
<ResourceList empty="Project grants недоступны или ещё не созданы.">
|
||||
{workspace.grants.map((grant) => (
|
||||
<ResourceRow
|
||||
key={grant.grantRef}
|
||||
title={grant.principalRef}
|
||||
description={`${grant.principalKind} · ${grant.projectRole}`}
|
||||
status={grant.lifecycleState}
|
||||
trailing={grant.capabilityDeny.length ? `deny: ${grant.capabilityDeny.join(", ")}` : "role capabilities"}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsView({ workspace, canConfigure, onCreateConfiguration }: {
|
||||
workspace: ProjectWorkspace;
|
||||
canConfigure: boolean;
|
||||
onCreateConfiguration: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ControlStack>
|
||||
<div className="device-control-policy-grid">
|
||||
<PolicyCard label="Identifiers" value={workspace.policies.identifierProjection} />
|
||||
<PolicyCard label="Audit payload" value={workspace.policies.auditPayloadProjection} />
|
||||
<PolicyCard label="Command API" value={workspace.policies.commandPlanningApi} />
|
||||
</div>
|
||||
<ControlToolbar
|
||||
copy="Configuration revisions immutable. Desired и applied — разные указатели; создание desired не означает применение устройством."
|
||||
actions={canConfigure ? <Button variant="primary" onClick={onCreateConfiguration} disabled={!workspace.devices.length}>Новая desired revision</Button> : null}
|
||||
/>
|
||||
<ResourceList empty="Configuration state пока отсутствует.">
|
||||
{workspace.configurationStates.map((state) => (
|
||||
<ResourceRow
|
||||
key={state.deviceRef}
|
||||
title={state.deviceName}
|
||||
description={`desired: ${shortRef(state.desiredConfigurationRevisionRef)} · applied: ${shortRef(state.appliedConfigurationRevisionRef)}`}
|
||||
status={state.appliedConfigurationRevisionRef === state.desiredConfigurationRevisionRef ? "applied" : "pending"}
|
||||
trailing={formatDate(state.updatedAt)}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
<ControlSection title="Immutable revisions" count={workspace.configurationRevisions.length}>
|
||||
<ResourceList empty="Configuration revisions отсутствуют.">
|
||||
{workspace.configurationRevisions.map((revision) => (
|
||||
<ResourceRow
|
||||
key={revision.configurationRevisionRef}
|
||||
title={`${revision.deviceName} · revision ${revision.revisionNumber}`}
|
||||
description={revision.changeSummary || revision.modelProfileRef}
|
||||
status="immutable"
|
||||
trailing={shortDigest(revision.configurationDigest)}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
</ControlSection>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function AdapterPackageDialog(props: DialogBaseProps) {
|
||||
const [packageKey, setPackageKey] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [publisherRef, setPublisherRef] = useState("");
|
||||
return <FormWindow {...props} id="adapter-package-form" title="Adapter package" submit={async () => {
|
||||
await ensureAdapterPackage({ packageKey, displayName, publisherRef, lifecycleState: "active" });
|
||||
}}>
|
||||
<KeyField label="Ключ пакета" value={packageKey} onChange={setPackageKey} />
|
||||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
||||
<TextField label="Publisher ref" value={publisherRef} onChange={(event) => setPublisherRef(event.target.value)} required />
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
function AdapterVersionDialog({ packages, ...props }: DialogBaseProps & { packages: AdapterPackageView[] }) {
|
||||
const [packageRef, setPackageRef] = useState(packages[0]?.adapterPackageRef ?? "");
|
||||
const [version, setVersion] = useState("");
|
||||
const [runtimeRef, setRuntimeRef] = useState("");
|
||||
const [digest, setDigest] = useState("");
|
||||
const [contractVersion, setContractVersion] = useState("");
|
||||
const [capabilities, setCapabilities] = useState("");
|
||||
useEffect(() => {
|
||||
if (!packages.some((item) => item.adapterPackageRef === packageRef)) {
|
||||
setPackageRef(packages[0]?.adapterPackageRef ?? "");
|
||||
}
|
||||
}, [packageRef, packages]);
|
||||
return <FormWindow {...props} id="adapter-version-form" title="Версия адаптера" disabled={!packageRef} submit={async () => {
|
||||
await registerAdapterVersion({
|
||||
adapterPackageRef: packageRef,
|
||||
version,
|
||||
runtimePackageRef: runtimeRef,
|
||||
contentDigest: digest,
|
||||
contractVersion,
|
||||
capabilities: commaList(capabilities),
|
||||
lifecycleState: "draft",
|
||||
});
|
||||
}}>
|
||||
<Select label="Adapter package" value={packageRef} onChange={setPackageRef} options={packages.map((item) => ({ value: item.adapterPackageRef, label: item.displayName }))} />
|
||||
<TextField label="SemVer" value={version} onChange={(event) => setVersion(event.target.value)} required placeholder="1.0.0" />
|
||||
<TextField label="Runtime artifact ref" value={runtimeRef} onChange={(event) => setRuntimeRef(event.target.value)} required />
|
||||
<TextField label="Content digest" value={digest} onChange={(event) => setDigest(event.target.value)} required placeholder="sha256:…" />
|
||||
<TextField label="Contract version" value={contractVersion} onChange={(event) => setContractVersion(event.target.value)} required />
|
||||
<TextField label="Capabilities" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} description="Через запятую" />
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
function ModelProfileDialog({ versions, ...props }: DialogBaseProps & { versions: AdapterVersionView[] }) {
|
||||
const [versionRef, setVersionRef] = useState(versions[0]?.adapterVersionRef ?? "");
|
||||
const [profileRef, setProfileRef] = useState("");
|
||||
const [schemaVersion, setSchemaVersion] = useState("");
|
||||
const [vendor, setVendor] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [deviceType, setDeviceType] = useState("");
|
||||
const [protocol, setProtocol] = useState("");
|
||||
const [schemaRef, setSchemaRef] = useState("");
|
||||
const [digest, setDigest] = useState("");
|
||||
const [capabilities, setCapabilities] = useState("");
|
||||
useEffect(() => {
|
||||
if (!versions.some((item) => item.adapterVersionRef === versionRef)) {
|
||||
setVersionRef(versions[0]?.adapterVersionRef ?? "");
|
||||
}
|
||||
}, [versionRef, versions]);
|
||||
return <FormWindow {...props} id="model-profile-form" title="Model profile" disabled={!versionRef} submit={async () => {
|
||||
await registerModelProfile({
|
||||
adapterVersionRef: versionRef,
|
||||
profileRef,
|
||||
schemaVersion,
|
||||
vendor,
|
||||
model,
|
||||
deviceType,
|
||||
protocol: protocol.toUpperCase(),
|
||||
schemaArtifactRef: schemaRef,
|
||||
profileDigest: digest,
|
||||
capabilities: commaList(capabilities),
|
||||
lifecycleState: "draft",
|
||||
});
|
||||
}}>
|
||||
<Select label="Adapter version" value={versionRef} onChange={setVersionRef} options={versions.map((item) => ({ value: item.adapterVersionRef, label: item.version, description: item.runtimePackageRef }))} />
|
||||
<TextField label="Profile ref" value={profileRef} onChange={(event) => setProfileRef(event.target.value)} required />
|
||||
<TextField label="Schema version" value={schemaVersion} onChange={(event) => setSchemaVersion(event.target.value)} required />
|
||||
<TextField label="Vendor" value={vendor} onChange={(event) => setVendor(event.target.value)} required />
|
||||
<TextField label="Model" value={model} onChange={(event) => setModel(event.target.value)} required />
|
||||
<KeyField label="Device type" value={deviceType} onChange={setDeviceType} />
|
||||
<TextField label="Protocol" value={protocol} onChange={(event) => setProtocol(event.target.value)} required />
|
||||
<TextField label="Schema artifact ref" value={schemaRef} onChange={(event) => setSchemaRef(event.target.value)} required />
|
||||
<TextField label="Profile digest" value={digest} onChange={(event) => setDigest(event.target.value)} required placeholder="sha256:…" />
|
||||
<TextField label="Capabilities" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} />
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
function EdgeDialog(props: DialogBaseProps) {
|
||||
const [edgeKey, setEdgeKey] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [deploymentRef, setDeploymentRef] = useState("");
|
||||
return <FormWindow {...props} id="edge-form" title="Новый Edge" submit={async () => {
|
||||
await ensureEdge({ edgeKey, displayName, deploymentRef: deploymentRef || null, lifecycleState: "provisioning" });
|
||||
}}>
|
||||
<KeyField label="Edge key" value={edgeKey} onChange={setEdgeKey} />
|
||||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
||||
<TextField label="Deployment ref" value={deploymentRef} onChange={(event) => setDeploymentRef(event.target.value)} description="Opaque artifact/deployment reference, не адрес и не credential." />
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
function RouteDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
|
||||
const [routeKey, setRouteKey] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [edgeRef, setEdgeRef] = useState(workspace.edges[0]?.edgeRef ?? "");
|
||||
const [profileRef, setProfileRef] = useState(workspace.modelProfiles[0]?.modelProfileRef ?? "");
|
||||
const [listenerRef, setListenerRef] = useState("");
|
||||
const profile = workspace.modelProfiles.find((item) => item.modelProfileRef === profileRef);
|
||||
useEffect(() => {
|
||||
if (!workspace.edges.some((item) => item.edgeRef === edgeRef)) {
|
||||
setEdgeRef(workspace.edges[0]?.edgeRef ?? "");
|
||||
}
|
||||
if (!workspace.modelProfiles.some((item) => item.modelProfileRef === profileRef)) {
|
||||
setProfileRef(workspace.modelProfiles[0]?.modelProfileRef ?? "");
|
||||
}
|
||||
}, [edgeRef, profileRef, workspace.edges, workspace.modelProfiles]);
|
||||
return <FormWindow {...props} id="route-form" title="Новый маршрут" disabled={!edgeRef || !profileRef} submit={async () => {
|
||||
await ensureRoute({
|
||||
projectRef: workspace.project.projectRef,
|
||||
routeKey,
|
||||
displayName,
|
||||
edgeRef,
|
||||
modelProfileRef: profileRef,
|
||||
listenerRef,
|
||||
protocol: profile?.protocol || "INTERNAL",
|
||||
direction: "telemetry",
|
||||
lifecycleState: "draft",
|
||||
});
|
||||
}}>
|
||||
<KeyField label="Route key" value={routeKey} onChange={setRouteKey} />
|
||||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
||||
<Select label="Edge" value={edgeRef} onChange={setEdgeRef} options={workspace.edges.map((item) => ({ value: item.edgeRef, label: item.displayName, description: item.lifecycleState }))} />
|
||||
<Select label="Model profile" value={profileRef} onChange={setProfileRef} options={workspace.modelProfiles.map((item) => ({ value: item.modelProfileRef, label: `${item.vendor} ${item.model}`, description: item.protocol }))} />
|
||||
<TextField label="Listener ref" value={listenerRef} onChange={(event) => setListenerRef(event.target.value)} required />
|
||||
<p className="device-manager-card-copy">Маршрут создаётся draft. Его activation остаётся отдельным осознанным изменением данных.</p>
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
function BindingDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
|
||||
const sources = useMemo(() => [
|
||||
...workspace.collections.map((item) => ({ value: `collection|${item.collectionRef}`, label: item.name })),
|
||||
...workspace.devices.map((item) => ({ value: `device|${item.deviceRef}`, label: item.displayName })),
|
||||
], [workspace]);
|
||||
const [sourceValue, setSourceValue] = useState(sources[0]?.value ?? "");
|
||||
const [bindingKey, setBindingKey] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [targetKind, setTargetKind] = useState("");
|
||||
const [targetRef, setTargetRef] = useState("");
|
||||
const [capabilities, setCapabilities] = useState("observe");
|
||||
useEffect(() => {
|
||||
if (!sources.some((item) => item.value === sourceValue)) {
|
||||
setSourceValue(sources[0]?.value ?? "");
|
||||
}
|
||||
}, [sourceValue, sources]);
|
||||
return <FormWindow {...props} id="binding-form" title="Новый data binding" disabled={!sourceValue} submit={async () => {
|
||||
const [kind, ref] = sourceValue.split("|");
|
||||
await ensureDeviceBinding({
|
||||
projectRef: workspace.project.projectRef,
|
||||
bindingKey,
|
||||
displayName,
|
||||
source: { kind: kind as "device" | "collection", ref },
|
||||
targetKind,
|
||||
targetRef,
|
||||
capabilities: commaList(capabilities),
|
||||
});
|
||||
}}>
|
||||
<Select label="Source" value={sourceValue} onChange={setSourceValue} options={sources} />
|
||||
<KeyField label="Binding key" value={bindingKey} onChange={setBindingKey} />
|
||||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
||||
<TextField label="Target kind" value={targetKind} onChange={(event) => setTargetKind(event.target.value)} required placeholder="foundry.application" />
|
||||
<TextField label="Target ref" value={targetRef} onChange={(event) => setTargetRef(event.target.value)} required />
|
||||
<TextField label="Capabilities" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} description="observe, inspect, configure, command" required />
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
function GrantDialog({ projectRef, ...props }: DialogBaseProps & { projectRef: string }) {
|
||||
const [principalKind, setPrincipalKind] = useState<"user" | "group">("user");
|
||||
const [principalRef, setPrincipalRef] = useState("");
|
||||
const [role, setRole] = useState("viewer");
|
||||
const [allow, setAllow] = useState("");
|
||||
const [deny, setDeny] = useState("");
|
||||
return <FormWindow {...props} id="grant-form" title="Project access" submit={async () => {
|
||||
await upsertProjectGrant({
|
||||
projectRef,
|
||||
principalKind,
|
||||
principalRef,
|
||||
projectRole: role,
|
||||
capabilityAllow: commaList(allow),
|
||||
capabilityDeny: commaList(deny),
|
||||
lifecycleState: "active",
|
||||
});
|
||||
}}>
|
||||
<Select label="Principal type" value={principalKind} onChange={setPrincipalKind} options={[{ value: "user", label: "User" }, { value: "group", label: "Group" }]} />
|
||||
<TextField label="Principal ref" value={principalRef} onChange={(event) => setPrincipalRef(event.target.value)} required />
|
||||
<Select label="Project role" value={role} onChange={setRole} options={["viewer", "operator", "engineer", "admin", "owner"].map((value) => ({ value, label: value, disabled: value === "owner" && principalKind === "group" }))} />
|
||||
<TextField label="Capability allow" value={allow} onChange={(event) => setAllow(event.target.value)} description="Опциональные точечные добавления" />
|
||||
<TextField label="Capability deny" value={deny} onChange={(event) => setDeny(event.target.value)} description="Deny имеет приоритет" />
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
function ConfigurationDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
|
||||
const [deviceRef, setDeviceRef] = useState(workspace.devices[0]?.deviceRef ?? "");
|
||||
const [configuration, setConfiguration] = useState("{\n \"reporting_interval_seconds\": 30\n}");
|
||||
const [summary, setSummary] = useState("");
|
||||
useEffect(() => {
|
||||
if (!workspace.devices.some((item) => item.deviceRef === deviceRef)) {
|
||||
setDeviceRef(workspace.devices[0]?.deviceRef ?? "");
|
||||
}
|
||||
}, [deviceRef, workspace.devices]);
|
||||
return <FormWindow {...props} id="configuration-form" title="Новая desired configuration" disabled={!deviceRef} submit={async () => {
|
||||
const parsed = JSON.parse(configuration) as Record<string, unknown>;
|
||||
const created = await createConfigurationRevision({
|
||||
projectRef: workspace.project.projectRef,
|
||||
deviceRef,
|
||||
configuration: parsed,
|
||||
changeSummary: summary || null,
|
||||
});
|
||||
await setDesiredConfiguration({
|
||||
projectRef: workspace.project.projectRef,
|
||||
deviceRef,
|
||||
configurationRevisionRef: created.result.configurationRevision.configurationRevisionRef,
|
||||
});
|
||||
}}>
|
||||
<Select label="Device" value={deviceRef} onChange={setDeviceRef} options={workspace.devices.map((item) => ({ value: item.deviceRef, label: item.displayName, description: item.modelProfileRef }))} />
|
||||
<TextAreaField label="Configuration JSON" value={configuration} onChange={(event) => setConfiguration(event.target.value)} required />
|
||||
<TextAreaField label="Change summary" value={summary} onChange={(event) => setSummary(event.target.value)} />
|
||||
<p className="device-manager-card-copy">Secret-like keys и значения будут отклонены Core. Сохранение desired не выставляет applied.</p>
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
interface DialogBaseProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => Promise<void>;
|
||||
onError: (reason: unknown) => void;
|
||||
}
|
||||
|
||||
function FormWindow({ open, onClose, onCreated, onError, id, title, submit, disabled = false, children }: DialogBaseProps & {
|
||||
id: string;
|
||||
title: string;
|
||||
submit: () => Promise<void>;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [pending, setPending] = useState(false);
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
try {
|
||||
await submit();
|
||||
await onCreated();
|
||||
} catch (reason) {
|
||||
onError(reason);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Window open={open} title={title} onClose={onClose} footer={
|
||||
<WindowFooterActions>
|
||||
<Button variant="ghost" onClick={onClose}>Отмена</Button>
|
||||
<Button type="submit" form={id} variant="primary" disabled={disabled || pending}>{pending ? "Сохраняем…" : "Сохранить"}</Button>
|
||||
</WindowFooterActions>
|
||||
}>
|
||||
<form id={id} className="device-manager-form device-control-form" onSubmit={handleSubmit}>{children}</form>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return <TextField label={label} value={value} onChange={(event) => onChange(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />;
|
||||
}
|
||||
|
||||
function ControlStack({ children }: { children: ReactNode }) {
|
||||
return <div className="device-manager-stack device-control-stack">{children}</div>;
|
||||
}
|
||||
|
||||
function ControlToolbar({ copy, actions }: { copy: string; actions?: ReactNode }) {
|
||||
return <div className="device-manager-panel-toolbar device-control-toolbar"><p>{copy}</p>{actions ? <div className="device-control-toolbar__actions">{actions}</div> : null}</div>;
|
||||
}
|
||||
|
||||
function ControlSection({ title, count, children }: { title: string; count: number; children: ReactNode }) {
|
||||
return <section className="device-control-section"><div className="device-control-section__title"><h3>{title}</h3><StatusBadge>{count}</StatusBadge></div>{children}</section>;
|
||||
}
|
||||
|
||||
function ResourceGrid({ children, empty }: { children: ReactNode; empty: string }) {
|
||||
const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
|
||||
return hasChildren ? <div className="device-control-resource-grid">{children}</div> : <div className="device-manager-panel-empty">{empty}</div>;
|
||||
}
|
||||
|
||||
function ResourceCard({ eyebrow, title, description, status, meta, action = null }: { eyebrow: string; title: string; description: string; status: string; meta: string[]; action?: ReactNode }) {
|
||||
return <SettingsCard eyebrow={eyebrow} title={title} description={description} actions={<><StatusBadge tone={statusTone(status)}>{status}</StatusBadge>{action}</>}>
|
||||
{meta.length ? <div className="device-manager-capabilities">{meta.map((item) => <StatusBadge key={item}>{item}</StatusBadge>)}</div> : <p className="device-manager-card-copy">Metadata-only projection</p>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
|
||||
function ResourceList({ children, empty }: { children: ReactNode; empty: string }) {
|
||||
const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
|
||||
return hasChildren ? <div className="device-manager-entity-list">{children}</div> : <div className="device-manager-panel-empty">{empty}</div>;
|
||||
}
|
||||
|
||||
function ResourceRow({ title, description, status, trailing }: { title: string; description: string; status: string; trailing: ReactNode }) {
|
||||
return <GlassSurface className="device-manager-entity device-control-row" padding="md" tone="soft">
|
||||
<span className="device-manager-entity__icon"><Icon name="circle" /></span>
|
||||
<span className="device-manager-entity__body"><strong>{title}</strong><small>{description}</small></span>
|
||||
<span className="device-control-row__status"><StatusBadge tone={statusTone(status)}>{status}</StatusBadge>{typeof trailing === "string" ? <small>{trailing}</small> : trailing}</span>
|
||||
</GlassSurface>;
|
||||
}
|
||||
|
||||
function PolicyCard({ label, value }: { label: string; value: string }) {
|
||||
return <GlassSurface padding="md" tone="soft"><small>{label}</small><strong>{value}</strong></GlassSurface>;
|
||||
}
|
||||
|
||||
function commaList(value: string) {
|
||||
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
function statusTone(status: string): "neutral" | "success" | "warning" | "danger" {
|
||||
if (["active", "online", "verified", "applied", "recorded", "immutable"].includes(status)) return "success";
|
||||
if (["failed", "rejected", "revoked", "retired"].includes(status)) return "danger";
|
||||
if (["draft", "provisioning", "pending", "pending_external_approval", "unknown", "disabled"].includes(status)) return "warning";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return "—";
|
||||
return new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function shortRef(value: string | null) {
|
||||
return value ? `${value.slice(0, 18)}…` : "—";
|
||||
}
|
||||
|
||||
function shortDigest(value: string) {
|
||||
return `${value.slice(0, 15)}…${value.slice(-8)}`;
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
IconButton,
|
||||
Select,
|
||||
SettingsCard,
|
||||
StatusBadge,
|
||||
Switch,
|
||||
TextField,
|
||||
} from "@nodedc/ui-react";
|
||||
import {
|
||||
createConfigurationRevision,
|
||||
setDesiredConfiguration,
|
||||
updateDevice,
|
||||
} from "./api";
|
||||
import {
|
||||
accessLabel,
|
||||
getDeviceProfileCatalog,
|
||||
type DeviceFieldAccess,
|
||||
type DeviceProfileField,
|
||||
} from "./deviceProfileCatalog";
|
||||
import type {
|
||||
DeviceView,
|
||||
ProjectWorkspace,
|
||||
SessionView,
|
||||
} from "./types";
|
||||
|
||||
export type DeviceInventoryDetailState = {
|
||||
deviceRef: string;
|
||||
sectionId: string;
|
||||
editing: boolean;
|
||||
};
|
||||
|
||||
export function DeviceDetailHeaderTools({
|
||||
device,
|
||||
detail,
|
||||
canEdit,
|
||||
onDetailChange,
|
||||
}: {
|
||||
device: DeviceView;
|
||||
detail: DeviceInventoryDetailState;
|
||||
canEdit: boolean;
|
||||
onDetailChange: (detail: DeviceInventoryDetailState | null) => void;
|
||||
}) {
|
||||
const catalog = getDeviceProfileCatalog(device.modelProfileRef);
|
||||
const activeSection = catalog.sections.find((section) => section.id === detail.sectionId)
|
||||
?? catalog.sections[0];
|
||||
|
||||
return (
|
||||
<div className="device-detail-header-tools">
|
||||
<Select
|
||||
className="device-detail-section-select"
|
||||
label="Раздел устройства"
|
||||
value={activeSection?.id ?? catalog.sections[0]?.id ?? "passport"}
|
||||
options={catalog.sections.map((section) => ({
|
||||
value: section.id,
|
||||
label: section.label,
|
||||
}))}
|
||||
onChange={(sectionId) => onDetailChange({ ...detail, sectionId })}
|
||||
placement="bottom-end"
|
||||
minMenuWidth={320}
|
||||
menuWidth="anchor"
|
||||
variant="split"
|
||||
/>
|
||||
<IconButton
|
||||
label={detail.editing ? "Завершить редактирование" : "Редактировать устройство"}
|
||||
disabled={!canEdit}
|
||||
data-active={detail.editing || undefined}
|
||||
onClick={() => onDetailChange({
|
||||
...detail,
|
||||
editing: !detail.editing,
|
||||
})}
|
||||
>
|
||||
<Icon name="edit" size={17} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceInventoryView({
|
||||
workspace,
|
||||
canClaim,
|
||||
canConfigure,
|
||||
canManageProject,
|
||||
detail,
|
||||
onClaim,
|
||||
onPoll,
|
||||
onError,
|
||||
onDetailChange,
|
||||
}: {
|
||||
workspace: ProjectWorkspace;
|
||||
canClaim: boolean;
|
||||
canConfigure: boolean;
|
||||
canManageProject: boolean;
|
||||
detail: DeviceInventoryDetailState | null;
|
||||
onClaim: (enrollment: ProjectWorkspace["enrollments"][number]) => void;
|
||||
onPoll: () => Promise<void>;
|
||||
onError: (reason: unknown) => void;
|
||||
onDetailChange: (detail: DeviceInventoryDetailState | null) => void;
|
||||
}) {
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [sortOrder, setSortOrder] = useState("activity");
|
||||
const selectedDevice = workspace.devices.find(
|
||||
(device) => device.deviceRef === detail?.deviceRef,
|
||||
) ?? null;
|
||||
const pendingEnrollments = workspace.enrollments.filter(
|
||||
(enrollment) => enrollment.lifecycleState !== "claimed",
|
||||
);
|
||||
const deviceRows = useMemo(() => workspace.devices
|
||||
.map((device) => {
|
||||
const session = latestSession(workspace, device);
|
||||
const online = session?.lifecycleState === "online" || device.session?.state === "online";
|
||||
const lastSeenAt = session?.lastSeenAt || device.session?.lastSeenAt || device.updatedAt;
|
||||
return { device, session, online, lastSeenAt };
|
||||
})
|
||||
.filter((row) => {
|
||||
if (statusFilter === "active") return row.online;
|
||||
if (statusFilter === "inactive") return !row.online;
|
||||
return statusFilter !== "pending";
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (sortOrder === "name") {
|
||||
return left.device.displayName.localeCompare(right.device.displayName, "ru");
|
||||
}
|
||||
if (sortOrder === "activity" && left.online !== right.online) {
|
||||
return left.online ? -1 : 1;
|
||||
}
|
||||
return String(right.lastSeenAt || "").localeCompare(String(left.lastSeenAt || ""));
|
||||
}), [sortOrder, statusFilter, workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detail?.deviceRef && !selectedDevice) onDetailChange(null);
|
||||
}, [detail?.deviceRef, onDetailChange, selectedDevice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail?.deviceRef) return undefined;
|
||||
const poll = () => {
|
||||
if (document.visibilityState === "visible") onPoll().catch(onError);
|
||||
};
|
||||
const timer = window.setInterval(poll, 5_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [detail?.deviceRef, onError, onPoll]);
|
||||
|
||||
if (selectedDevice && detail) {
|
||||
return (
|
||||
<DeviceDetailView
|
||||
device={selectedDevice}
|
||||
detail={detail}
|
||||
workspace={workspace}
|
||||
canConfigure={canConfigure}
|
||||
canManageProject={canManageProject}
|
||||
onDetailChange={onDetailChange}
|
||||
onSaved={onPoll}
|
||||
onError={onError}
|
||||
onBack={() => onDetailChange(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="device-inventory">
|
||||
<div className="device-manager-panel-toolbar device-inventory__toolbar">
|
||||
<div>
|
||||
<strong>Реестр устройств</strong>
|
||||
<p>{workspace.devices.length} зарегистрировано · {pendingEnrollments.length} ожидают подключения</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="device-inventory__filters" aria-label="Фильтры устройств">
|
||||
<Select
|
||||
label="Состояние"
|
||||
value={statusFilter}
|
||||
options={[
|
||||
{ value: "all", label: "Все устройства" },
|
||||
{ value: "active", label: "Активные" },
|
||||
{ value: "inactive", label: "Неактивные" },
|
||||
{ value: "pending", label: "Ожидают подключения" },
|
||||
]}
|
||||
onChange={setStatusFilter}
|
||||
/>
|
||||
<Select
|
||||
label="Сортировка"
|
||||
value={sortOrder}
|
||||
options={[
|
||||
{ value: "activity", label: "Сначала активные" },
|
||||
{ value: "last-seen", label: "По последней активности" },
|
||||
{ value: "name", label: "По имени" },
|
||||
]}
|
||||
onChange={setSortOrder}
|
||||
disabled={statusFilter === "pending"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{statusFilter !== "pending" && !deviceRows.length ? (
|
||||
<GlassSurface className="device-manager-empty device-inventory__empty" padding="lg" tone="soft">
|
||||
<Icon name="inbox" size={24} />
|
||||
<h3>{workspace.devices.length ? "Устройств с таким состоянием нет" : "В проекте пока нет устройств"}</h3>
|
||||
<p>Добавьте разрешённый трекер через «плюс». Идентификатор попадёт в Device Core по защищённому процессу подключения.</p>
|
||||
</GlassSurface>
|
||||
) : statusFilter !== "pending" ? (
|
||||
<GlassSurface className="device-inventory-table" padding="sm" tone="soft" role="table" aria-label="Устройства проекта">
|
||||
<div className="device-inventory-table__head" role="row">
|
||||
<span role="columnheader">Устройство</span>
|
||||
<span role="columnheader">Профиль</span>
|
||||
<span role="columnheader">IMEI</span>
|
||||
<span role="columnheader">ID интеграционного устройства</span>
|
||||
<span role="columnheader">Канал</span>
|
||||
<span role="columnheader">Последний пакет</span>
|
||||
<span aria-hidden="true" />
|
||||
</div>
|
||||
{deviceRows.map(({ device, session, online, lastSeenAt }) => {
|
||||
return (
|
||||
<Button
|
||||
key={device.deviceRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="device-inventory-row"
|
||||
role="row"
|
||||
onClick={() => onDetailChange({
|
||||
deviceRef: device.deviceRef,
|
||||
sectionId: getDeviceProfileCatalog(device.modelProfileRef).sections[0]?.id ?? "passport",
|
||||
editing: false,
|
||||
})}
|
||||
>
|
||||
<span className="device-inventory-row__device" role="cell">
|
||||
<span className="device-inventory-row__icon"><Icon name="apps" size={17} /></span>
|
||||
<span><strong>{device.displayName}</strong><small>{device.deviceKey || "ключ не назначен"}</small></span>
|
||||
</span>
|
||||
<span role="cell">{profileLabel(workspace, device)}</span>
|
||||
<span role="cell">{deviceIdentifierDisplayValue(device) || "не назначен"}</span>
|
||||
<span role="cell">{device.integrationDeviceId || "не назначен"}</span>
|
||||
<span role="cell"><StatusBadge tone={online ? "success" : "neutral"}>{online ? "Онлайн" : session?.lifecycleState || device.lifecycleState}</StatusBadge></span>
|
||||
<span role="cell">{formatDate(lastSeenAt)}</span>
|
||||
<span className="device-inventory-row__open" role="cell" aria-hidden="true"><Icon name="chevron-right" size={16} /></span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
{(statusFilter === "all" || statusFilter === "pending") ? (
|
||||
<section className="device-inventory__pending" aria-label="Ожидают подключения">
|
||||
<div className="device-inventory__section-heading">
|
||||
<div>
|
||||
<strong>Ожидают подключения</strong>
|
||||
<p>Разрешённые идентификаторы и обнаруженные устройства.</p>
|
||||
</div>
|
||||
<StatusBadge tone={pendingEnrollments.length ? "warning" : "neutral"}>{pendingEnrollments.length}</StatusBadge>
|
||||
</div>
|
||||
{pendingEnrollments.length ? pendingEnrollments.map((enrollment) => (
|
||||
<SettingsCard
|
||||
key={enrollment.enrollmentIntentRef}
|
||||
eyebrow={enrollment.lifecycleState}
|
||||
title={enrollment.displayName}
|
||||
description={`${enrollment.modelProfileRef} · ${enrollment.expectedIdentifier.masked}`}
|
||||
actions={enrollment.lifecycleState === "observed" && enrollment.observedDiscoveryRef ? (
|
||||
<Button size="compact" variant="primary" disabled={!canClaim} onClick={() => onClaim(enrollment)}>
|
||||
Принять устройство
|
||||
</Button>
|
||||
) : <StatusBadge>{enrollment.lifecycleState}</StatusBadge>}
|
||||
>
|
||||
<p className="device-manager-card-copy">После первого пакета устройство можно принять в реестр. Исходный идентификатор в интерфейсе не раскрывается.</p>
|
||||
</SettingsCard>
|
||||
)) : (
|
||||
<GlassSurface className="device-manager-panel-empty device-inventory__pending-empty" padding="md" tone="soft">
|
||||
Нет ожидающих подключений.
|
||||
</GlassSurface>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceDetailView({
|
||||
device,
|
||||
detail,
|
||||
workspace,
|
||||
canConfigure,
|
||||
canManageProject,
|
||||
onDetailChange,
|
||||
onSaved,
|
||||
onError,
|
||||
onBack,
|
||||
}: {
|
||||
device: DeviceView;
|
||||
detail: DeviceInventoryDetailState;
|
||||
workspace: ProjectWorkspace;
|
||||
canConfigure: boolean;
|
||||
canManageProject: boolean;
|
||||
onDetailChange: (detail: DeviceInventoryDetailState | null) => void;
|
||||
onSaved: () => Promise<void>;
|
||||
onError: (reason: unknown) => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const catalog = getDeviceProfileCatalog(device.modelProfileRef);
|
||||
const detailRef = useRef<HTMLDivElement>(null);
|
||||
const [draftValues, setDraftValues] = useState<Record<string, string | boolean>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const profile = workspace.modelProfiles.find(
|
||||
(item) => item.modelProfileRef === device.modelProfileRef,
|
||||
) ?? null;
|
||||
const session = latestSession(workspace, device);
|
||||
const configurationState = workspace.configurationStates.find(
|
||||
(item) => item.deviceRef === device.deviceRef,
|
||||
) ?? null;
|
||||
const identifierDisplayValue = deviceIdentifierDisplayValue(device);
|
||||
const context = useMemo(() => ({
|
||||
device: {
|
||||
...device,
|
||||
identifier: device.identifier ? {
|
||||
...device.identifier,
|
||||
displayValue: identifierDisplayValue,
|
||||
} : null,
|
||||
},
|
||||
profile,
|
||||
session,
|
||||
configurationState,
|
||||
reported: device.reported ?? {},
|
||||
policies: {
|
||||
...workspace.policies,
|
||||
firmwareUpdate: "blocked",
|
||||
},
|
||||
}), [configurationState, device, identifierDisplayValue, profile, session, workspace.policies]);
|
||||
const activeSection = catalog.sections.find(
|
||||
(section) => section.id === detail.sectionId,
|
||||
) ?? catalog.sections[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!catalog.sections.some((section) => section.id === detail.sectionId)) {
|
||||
onDetailChange({
|
||||
...detail,
|
||||
sectionId: catalog.sections[0]?.id ?? "passport",
|
||||
});
|
||||
}
|
||||
}, [catalog.sections, detail, onDetailChange]);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftValues({});
|
||||
}, [detail.editing, device.deviceRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const panelBody = detailRef.current?.closest<HTMLElement>(".nodedc-application-panel__body");
|
||||
if (panelBody) panelBody.scrollTop = 0;
|
||||
}, [detail.sectionId, device.deviceRef]);
|
||||
|
||||
if (!activeSection) return null;
|
||||
|
||||
const saveDeviceChanges = async () => {
|
||||
if (!(canConfigure || canManageProject) || !Object.keys(draftValues).length) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const displayNameDraft = draftValues["device.displayName"];
|
||||
const integrationDeviceIdDraft = draftValues["device.integrationDeviceId"];
|
||||
const nextDisplayName = typeof displayNameDraft === "string"
|
||||
? displayNameDraft.trim()
|
||||
: device.displayName;
|
||||
const nextIntegrationDeviceId = typeof integrationDeviceIdDraft === "string"
|
||||
? integrationDeviceIdDraft.trim() || null
|
||||
: device.integrationDeviceId;
|
||||
if (
|
||||
canManageProject
|
||||
&& nextDisplayName
|
||||
&& (
|
||||
nextDisplayName !== device.displayName
|
||||
|| nextIntegrationDeviceId !== device.integrationDeviceId
|
||||
)
|
||||
) {
|
||||
await updateDevice({
|
||||
projectRef: workspace.project.projectRef,
|
||||
deviceRef: device.deviceRef,
|
||||
displayName: nextDisplayName,
|
||||
integrationDeviceId: nextIntegrationDeviceId,
|
||||
});
|
||||
}
|
||||
|
||||
const configurationDrafts = Object.entries(draftValues).filter(([path]) =>
|
||||
path.startsWith("reported.configuration."),
|
||||
);
|
||||
if (configurationDrafts.length) {
|
||||
const nextConfiguration = cloneConfiguration(device.reported?.configuration);
|
||||
for (const [path, value] of configurationDrafts) {
|
||||
const field = catalog.sections.flatMap((section) => section.fields)
|
||||
.find((item) => item.path === path);
|
||||
if (!field || !isDeviceFieldEditable(field, field.access ?? activeSection.access, {
|
||||
canConfigure,
|
||||
canManageProject,
|
||||
})) continue;
|
||||
writePath(
|
||||
nextConfiguration,
|
||||
path.replace(/^reported\.configuration\./, ""),
|
||||
normalizeDraftValue(value, field),
|
||||
);
|
||||
}
|
||||
const created = await createConfigurationRevision({
|
||||
projectRef: workspace.project.projectRef,
|
||||
deviceRef: device.deviceRef,
|
||||
configuration: nextConfiguration,
|
||||
changeSummary: `Device Manager · ${activeSection.title}`,
|
||||
});
|
||||
await setDesiredConfiguration({
|
||||
projectRef: workspace.project.projectRef,
|
||||
deviceRef: device.deviceRef,
|
||||
configurationRevisionRef: created.result.configurationRevision.configurationRevisionRef,
|
||||
});
|
||||
}
|
||||
setDraftValues({});
|
||||
onDetailChange({ ...detail, editing: false });
|
||||
await onSaved();
|
||||
} catch (reason) {
|
||||
onError(reason);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={detailRef} className="device-detail">
|
||||
<div className="device-detail__header">
|
||||
<IconButton label="Вернуться к списку устройств" onClick={onBack}>
|
||||
<Icon name="chevron-left" size={18} />
|
||||
</IconButton>
|
||||
<div className="device-detail__identity">
|
||||
<small>{catalog.vendor} · {catalog.model}</small>
|
||||
<h2>{device.displayName}</h2>
|
||||
<p>{identifierDisplayValue || "Идентификатор не назначен"} · {device.modelProfileRef}</p>
|
||||
</div>
|
||||
<StatusBadge tone={session?.lifecycleState === "online" ? "success" : "neutral"}>
|
||||
{session?.lifecycleState === "online" ? "Онлайн" : session?.lifecycleState || device.lifecycleState}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
<div className="device-detail__legend" aria-label="Режимы доступа">
|
||||
<AccessBadge access="read-only" />
|
||||
<AccessBadge access="managed" />
|
||||
<AccessBadge access="protected" />
|
||||
</div>
|
||||
|
||||
<GlassSurface className="device-detail__connection" padding="md" tone="soft">
|
||||
<div><span>Состояние связи</span><strong>{session?.lifecycleState || device.session?.state || "Нет сессии"}</strong></div>
|
||||
<div><span>Маршрут</span><strong>{session?.routeName || "Не определён"}</strong></div>
|
||||
<div><span>Протокол</span><strong>{session?.protocol || profile?.protocol || "Нет данных"}</strong></div>
|
||||
<div><span>Последняя активность</span><strong>{formatDate(session?.lastSeenAt || device.session?.lastSeenAt)}</strong></div>
|
||||
<div><span>Пакеты</span><strong>{session?.frameCount ?? 0}</strong></div>
|
||||
<div><span>Подключено</span><strong>{formatDate(session?.connectedAt)}</strong></div>
|
||||
</GlassSurface>
|
||||
|
||||
<div className="device-detail__layout">
|
||||
<section className="device-detail-section">
|
||||
<div className="device-detail-section__heading">
|
||||
<div>
|
||||
<span>{catalog.title}</span>
|
||||
<h3>{activeSection.title}</h3>
|
||||
<p>{activeSection.description}</p>
|
||||
</div>
|
||||
<AccessBadge access={activeSection.access} />
|
||||
</div>
|
||||
|
||||
<AccessNotice
|
||||
access={activeSection.access}
|
||||
commandTransport={workspace.policies.commandTransport}
|
||||
/>
|
||||
|
||||
<div className="device-detail-fields">
|
||||
{activeSection.fields.map((item) => {
|
||||
const access = item.access ?? activeSection.access;
|
||||
const editable = detail.editing
|
||||
&& isDeviceFieldEditable(item, access, {
|
||||
canConfigure,
|
||||
canManageProject,
|
||||
});
|
||||
const value = Object.prototype.hasOwnProperty.call(draftValues, item.path)
|
||||
? draftValues[item.path]
|
||||
: readPath(context, item.path);
|
||||
return (
|
||||
<GlassSurface key={item.key} className="device-detail-field" padding="sm" tone="soft" data-access={access} data-editing={editable || undefined}>
|
||||
{editable && item.valueKind === "boolean" ? (
|
||||
<Switch
|
||||
checked={Boolean(value)}
|
||||
label={item.label}
|
||||
disabled={saving}
|
||||
onChange={(checked) => setDraftValues((current) => ({ ...current, [item.path]: checked }))}
|
||||
/>
|
||||
) : editable ? (
|
||||
<TextField
|
||||
label={item.label}
|
||||
hint={item.unit}
|
||||
description={item.description}
|
||||
type={item.valueKind === "number" ? "number" : "text"}
|
||||
value={value === undefined || value === null ? "" : String(value)}
|
||||
disabled={saving}
|
||||
onChange={(event) => setDraftValues((current) => ({ ...current, [item.path]: event.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="device-detail-field__label">
|
||||
<span>{item.label}</span>
|
||||
{access !== activeSection.access ? <AccessBadge access={access} compact /> : null}
|
||||
</div>
|
||||
<strong>{formatFieldValue(value, item)}</strong>
|
||||
{item.description ? <small>{item.description}</small> : null}
|
||||
</>
|
||||
)}
|
||||
</GlassSurface>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{detail.editing ? (
|
||||
<div className="device-detail-edit-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setDraftValues({});
|
||||
onDetailChange({ ...detail, editing: false });
|
||||
}}
|
||||
>
|
||||
Отменить
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
icon={<Icon name="save" />}
|
||||
disabled={saving || !Object.keys(draftValues).length}
|
||||
onClick={() => void saveDeviceChanges()}
|
||||
>
|
||||
{saving ? "Сохраняем…" : "Сохранить"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="device-detail-section__state">
|
||||
<span>Desired</span>
|
||||
<strong>{configurationState?.desiredConfigurationRevisionRef || "Не задано"}</strong>
|
||||
<span>Applied</span>
|
||||
<strong>{configurationState?.appliedConfigurationRevisionRef || "Не подтверждено"}</strong>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isDeviceFieldEditable(
|
||||
field: DeviceProfileField,
|
||||
access: DeviceFieldAccess,
|
||||
capabilities: { canConfigure: boolean; canManageProject: boolean } = {
|
||||
canConfigure: true,
|
||||
canManageProject: true,
|
||||
},
|
||||
) {
|
||||
return access === "managed"
|
||||
&& (
|
||||
(["device.displayName", "device.integrationDeviceId"].includes(field.path) && capabilities.canManageProject)
|
||||
|| (field.path.startsWith("reported.configuration.") && capabilities.canConfigure)
|
||||
)
|
||||
&& !field.sensitive;
|
||||
}
|
||||
|
||||
function deviceIdentifierDisplayValue(device: DeviceView) {
|
||||
if (!device.identifier) return null;
|
||||
if (device.identifier.value) return device.identifier.value;
|
||||
const reportedImei = device.reported?.identity?.imei;
|
||||
if (typeof reportedImei === "string" && reportedImei.trim()) return reportedImei;
|
||||
return device.identifier.masked;
|
||||
}
|
||||
|
||||
function cloneConfiguration(configuration: Record<string, unknown> | null | undefined) {
|
||||
if (!configuration) return {};
|
||||
return JSON.parse(JSON.stringify(configuration)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function writePath(target: Record<string, unknown>, path: string, value: unknown) {
|
||||
const keys = path.split(".");
|
||||
let cursor: Record<string, unknown> | unknown[] = target;
|
||||
keys.forEach((key, index) => {
|
||||
if (index === keys.length - 1) {
|
||||
if (Array.isArray(cursor)) cursor[Number(key)] = value;
|
||||
else cursor[key] = value;
|
||||
return;
|
||||
}
|
||||
const nextKey = keys[index + 1];
|
||||
const nextValue = Array.isArray(cursor) ? cursor[Number(key)] : cursor[key];
|
||||
if (!nextValue || typeof nextValue !== "object") {
|
||||
const created: Record<string, unknown> | unknown[] = /^\d+$/.test(nextKey) ? [] : {};
|
||||
if (Array.isArray(cursor)) cursor[Number(key)] = created;
|
||||
else cursor[key] = created;
|
||||
cursor = created;
|
||||
} else {
|
||||
cursor = nextValue as Record<string, unknown> | unknown[];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDraftValue(value: string | boolean, field: DeviceProfileField) {
|
||||
if (field.valueKind === "number") return value === "" ? null : Number(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function AccessBadge({ access, compact = false }: { access: DeviceFieldAccess; compact?: boolean }) {
|
||||
const tone = access === "managed" ? "accent" : access === "protected" ? "warning" : "neutral";
|
||||
return <StatusBadge className={compact ? "device-access-badge--compact" : undefined} tone={tone}>{accessLabel(access)}</StatusBadge>;
|
||||
}
|
||||
|
||||
function AccessNotice({
|
||||
access,
|
||||
commandTransport,
|
||||
}: {
|
||||
access: DeviceFieldAccess;
|
||||
commandTransport: ProjectWorkspace["policies"]["commandTransport"];
|
||||
}) {
|
||||
if (access === "read-only") {
|
||||
return <GlassSurface className="device-detail-notice" padding="sm" tone="soft"><Icon name="lock" size={15} /><span>Этот блок отражает фактическое состояние устройства и не редактируется.</span></GlassSurface>;
|
||||
}
|
||||
if (access === "protected") {
|
||||
return <GlassSurface className="device-detail-notice" padding="sm" tone="soft"><Icon name="shield" size={15} /><span>Операция требует отдельного подтверждения. Обновление прошивки пилотного B2 запрещено.</span></GlassSurface>;
|
||||
}
|
||||
return <GlassSurface className="device-detail-notice" padding="sm" tone="soft"><Icon name="settings" size={15} /><span>{commandTransport === "typed-service-ping-v1" ? "Изменение создаёт новую desired-ревизию. Статус Applied появится только после подтверждения устройством." : "Настройка поддерживается моделью, но запись включится только после запуска двустороннего командного канала."}</span></GlassSurface>;
|
||||
}
|
||||
|
||||
function latestSession(workspace: ProjectWorkspace, device: DeviceView): SessionView | null {
|
||||
const sessions = workspace.sessions.filter((item) => item.deviceRef === device.deviceRef);
|
||||
return sessions.sort((left, right) => {
|
||||
if (left.lifecycleState === "online" && right.lifecycleState !== "online") return -1;
|
||||
if (right.lifecycleState === "online" && left.lifecycleState !== "online") return 1;
|
||||
return String(right.lastSeenAt || right.connectedAt || "").localeCompare(String(left.lastSeenAt || left.connectedAt || ""));
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
function profileLabel(workspace: ProjectWorkspace, device: DeviceView) {
|
||||
const profile = workspace.modelProfiles.find(
|
||||
(item) => item.modelProfileRef === device.modelProfileRef,
|
||||
);
|
||||
return profile ? `${profile.vendor} ${profile.model}` : device.modelProfileRef;
|
||||
}
|
||||
|
||||
function readPath(input: unknown, path: string): unknown {
|
||||
return path.split(".").reduce<unknown>((value, key) => {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
return (value as Record<string, unknown>)[key];
|
||||
}, input);
|
||||
}
|
||||
|
||||
function formatFieldValue(value: unknown, item: DeviceProfileField) {
|
||||
if (value === undefined || value === null || value === "") return "Нет данных";
|
||||
if (item.sensitive) return "Задано · значение скрыто";
|
||||
if (item.valueKind === "date") return formatDate(String(value));
|
||||
if (item.valueKind === "boolean" || typeof value === "boolean") return value ? "Включено" : "Выключено";
|
||||
if (value === "blocked") return "Запрещено";
|
||||
if (Array.isArray(value)) return value.length ? value.join(", ") : "Нет данных";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return `${String(value)}${item.unit ? ` ${item.unit}` : ""}`;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) return "Нет данных";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export const __deviceInventoryTestables = {
|
||||
formatFieldValue,
|
||||
readPath,
|
||||
} as const;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
MediaSourceField,
|
||||
RangeControl,
|
||||
SortableList,
|
||||
} from "@nodedc/ui-react";
|
||||
import type {
|
||||
DeviceManagerEnvironmentMediaItem,
|
||||
DeviceManagerEnvironmentOverview,
|
||||
DeviceManagerMediaKind,
|
||||
DeviceManagerMediaSource,
|
||||
} from "./types";
|
||||
|
||||
type EnvironmentBackground = DeviceManagerEnvironmentOverview["background"];
|
||||
|
||||
interface EnvironmentMediaPlaylistEditorProps {
|
||||
background: EnvironmentBackground;
|
||||
disabled: boolean;
|
||||
error: string | null;
|
||||
onChange: (background: EnvironmentBackground) => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
onUpload: (itemId: string, file: File) => Promise<{ fileName: string; fileSrc: string }>;
|
||||
}
|
||||
|
||||
const acceptedEnvironmentMedia = [
|
||||
"image/png", "image/jpeg", "image/gif", "image/webp", "image/avif",
|
||||
"video/mp4", "video/webm", "video/quicktime", "video/x-quicktime",
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".mp4", ".webm", ".mov",
|
||||
].join(",");
|
||||
|
||||
const maxEnvironmentMediaItems = 24;
|
||||
|
||||
function inferMediaKind(value: string): DeviceManagerMediaKind {
|
||||
const pathname = (() => {
|
||||
try { return new URL(value, window.location.origin).pathname; }
|
||||
catch { return value; }
|
||||
})();
|
||||
return /\.(?:png|jpe?g|gif|webp|avif)$/i.test(pathname) ? "image" : "video";
|
||||
}
|
||||
|
||||
function createMediaItem(): DeviceManagerEnvironmentMediaItem {
|
||||
return {
|
||||
id: globalThis.crypto?.randomUUID?.() ?? `media-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
source: "file",
|
||||
url: "",
|
||||
fileName: null,
|
||||
fileSrc: null,
|
||||
mediaKind: null,
|
||||
};
|
||||
}
|
||||
|
||||
function patchItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
patch: Partial<DeviceManagerEnvironmentMediaItem>,
|
||||
): EnvironmentBackground {
|
||||
return {
|
||||
...background,
|
||||
items: background.items.map((item) => item.id === itemId ? { ...item, ...patch } : item),
|
||||
};
|
||||
}
|
||||
|
||||
function mediaSource(item: DeviceManagerEnvironmentMediaItem) {
|
||||
return item.source === "url" ? item.url || null : item.fileSrc;
|
||||
}
|
||||
|
||||
export function EnvironmentMediaPlaylistEditor({
|
||||
background,
|
||||
disabled,
|
||||
error,
|
||||
onChange,
|
||||
onBusyChange,
|
||||
onUpload,
|
||||
}: EnvironmentMediaPlaylistEditorProps) {
|
||||
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
|
||||
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
||||
const backgroundRef = useRef(background);
|
||||
backgroundRef.current = background;
|
||||
const displayedItems = useMemo(() => [...background.items].reverse(), [background.items]);
|
||||
|
||||
useEffect(() => onBusyChange(uploadingIds.size > 0), [onBusyChange, uploadingIds.size]);
|
||||
useEffect(() => () => onBusyChange(false), [onBusyChange]);
|
||||
|
||||
const setItemError = (itemId: string, message?: string) => {
|
||||
setItemErrors((current) => {
|
||||
const next = { ...current };
|
||||
if (message) next[itemId] = message;
|
||||
else delete next[itemId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFile = async (itemId: string, file?: File) => {
|
||||
if (!file) return;
|
||||
setUploadingIds((current) => new Set(current).add(itemId));
|
||||
setItemError(itemId);
|
||||
try {
|
||||
const stored = await onUpload(itemId, file);
|
||||
onChange(patchItem(backgroundRef.current, itemId, {
|
||||
source: "file",
|
||||
url: "",
|
||||
fileName: stored.fileName,
|
||||
fileSrc: stored.fileSrc,
|
||||
mediaKind: inferMediaKind(file.name),
|
||||
}));
|
||||
} catch (reason) {
|
||||
setItemError(itemId, reason instanceof Error ? reason.message : "Не удалось загрузить медиаконтент.");
|
||||
} finally {
|
||||
setUploadingIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="environment-media-playlist">
|
||||
<div className="environment-media-playlist__head">
|
||||
<div>
|
||||
<span>Видео / картинка</span>
|
||||
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
|
||||
</div>
|
||||
<IconButton
|
||||
label="Добавить медиаконтент"
|
||||
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
|
||||
onClick={() => onChange({ ...background, items: [...background.items, createMediaItem()] })}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{displayedItems.length ? (
|
||||
<SortableList
|
||||
items={displayedItems}
|
||||
getId={(item) => item.id}
|
||||
className="environment-media-playlist__items"
|
||||
onReorder={(items) => onChange({ ...background, items: [...items].reverse() })}
|
||||
>
|
||||
{(item, { handle }) => {
|
||||
const playbackIndex = background.items.findIndex((candidate) => candidate.id === item.id);
|
||||
const preview = mediaSource(item);
|
||||
return (
|
||||
<div className="environment-media-playlist__item">
|
||||
<MediaSourceField
|
||||
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
|
||||
kindLabel={item.mediaKind ?? "media"}
|
||||
source={item.source}
|
||||
url={item.url}
|
||||
fileName={item.fileName}
|
||||
uploading={uploadingIds.has(item.id)}
|
||||
previewSrc={preview}
|
||||
previewKind={item.mediaKind}
|
||||
accept={acceptedEnvironmentMedia}
|
||||
path={`overview.background.items[${playbackIndex}] → Device Core media`}
|
||||
hint="Файл сохраняется в data root Device Core. URL должен вести прямо на media по HTTP(S)."
|
||||
error={itemErrors[item.id] ?? (playbackIndex === background.items.length - 1 ? error : null)}
|
||||
onSourceChange={(source: DeviceManagerMediaSource) => {
|
||||
if (source === item.source) return;
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source,
|
||||
url: "",
|
||||
fileName: null,
|
||||
fileSrc: null,
|
||||
mediaKind: null,
|
||||
}));
|
||||
}}
|
||||
onUrlChange={(url) => {
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source: "url",
|
||||
url,
|
||||
fileName: null,
|
||||
fileSrc: null,
|
||||
mediaKind: url ? inferMediaKind(url) : null,
|
||||
}));
|
||||
}}
|
||||
onFileChange={(file) => void uploadFile(item.id, file)}
|
||||
/>
|
||||
<div className="environment-media-playlist__item-actions">
|
||||
<IconButton
|
||||
label={`Удалить медиаконтент ${playbackIndex + 1}`}
|
||||
disabled={disabled || uploadingIds.has(item.id)}
|
||||
onClick={() => {
|
||||
setItemError(item.id);
|
||||
onChange({ ...background, items: background.items.filter((candidate) => candidate.id !== item.id) });
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</IconButton>
|
||||
{handle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</SortableList>
|
||||
) : (
|
||||
<>
|
||||
<p className="environment-media-playlist__empty">Добавьте первый файл или прямую ссылку на медиаконтент.</p>
|
||||
{error ? <p className="environment-media-playlist__error" role="alert">{error}</p> : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="environment-media-playlist__timing">
|
||||
<RangeControl
|
||||
label="Показывать изображение"
|
||||
value={background.imageDurationSeconds}
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
formatValue={(value) => `${value} с`}
|
||||
onChange={(imageDurationSeconds) => onChange({ ...background, imageDurationSeconds })}
|
||||
/>
|
||||
<span>Новые элементы появляются сверху. Воспроизведение начинается снизу; перетаскивание меняет порядок.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import type {
|
||||
AdapterPackageView,
|
||||
AdapterVersionView,
|
||||
BindingView,
|
||||
ConfigurationRevisionView,
|
||||
EdgeView,
|
||||
ModelProfileView,
|
||||
ProjectGrantView,
|
||||
DeviceManagerSession,
|
||||
DeviceManagerPresentation,
|
||||
DeviceManagerProjectPresentation,
|
||||
DeviceView,
|
||||
ProjectSummary,
|
||||
ProjectWorkspace,
|
||||
RouteView,
|
||||
ScopeKind,
|
||||
} from "./types";
|
||||
|
||||
export async function loadPresentation(): Promise<DeviceManagerPresentation> {
|
||||
return requestJson<{ ok: true; presentation: DeviceManagerPresentation }>(
|
||||
"/api/device-manager/presentation",
|
||||
).then((value) => value.presentation);
|
||||
}
|
||||
|
||||
export async function saveProjectPresentation(
|
||||
projectRef: string,
|
||||
presentation: DeviceManagerProjectPresentation,
|
||||
) {
|
||||
return putJson<{ presentation: DeviceManagerPresentation }>(
|
||||
"/api/device-manager/presentation/project",
|
||||
{ projectRef, presentation },
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveEnvironmentPresentation(
|
||||
environment: DeviceManagerPresentation["environment"],
|
||||
) {
|
||||
return putJson<{ presentation: DeviceManagerPresentation }>(
|
||||
"/api/device-manager/presentation/environment",
|
||||
{ environment },
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadPresentationMedia(input: {
|
||||
file: File;
|
||||
scope: "project" | "environment";
|
||||
projectRef?: string;
|
||||
kind: "icon" | "teaser" | "background";
|
||||
}) {
|
||||
const params = new URLSearchParams({ scope: input.scope, kind: input.kind });
|
||||
if (input.projectRef) params.set("projectRef", input.projectRef);
|
||||
const response = await fetch(`/api/device-manager/presentation/media?${params}`, {
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"content-type": input.file.type || "application/octet-stream",
|
||||
"x-file-name": input.file.name,
|
||||
},
|
||||
body: input.file,
|
||||
});
|
||||
const body = await response.json().catch(() => null) as { ok?: boolean; fileName?: string; fileSrc?: string; error?: string } | null;
|
||||
if (!response.ok || body?.ok !== true || !body.fileSrc) {
|
||||
throw new Error(body?.error || `device_manager_http_${response.status}`);
|
||||
}
|
||||
return { fileName: body.fileName || input.file.name, fileSrc: body.fileSrc };
|
||||
}
|
||||
|
||||
export async function loadSession(): Promise<DeviceManagerSession> {
|
||||
return requestJson<{ ok: true; session: DeviceManagerSession }>(
|
||||
"/api/device-manager/session",
|
||||
).then((value) => value.session);
|
||||
}
|
||||
|
||||
export async function loadProjects(): Promise<ProjectSummary[]> {
|
||||
return requestJson<{ ok: true; projects: ProjectSummary[] }>(
|
||||
"/api/device-manager/projects",
|
||||
).then((value) => value.projects);
|
||||
}
|
||||
|
||||
export async function loadWorkspace(projectRef: string): Promise<ProjectWorkspace> {
|
||||
return requestJson<{ ok: true; workspace: ProjectWorkspace }>(
|
||||
`/api/device-manager/projects/${encodeURIComponent(projectRef)}/workspace`,
|
||||
).then((value) => value.workspace);
|
||||
}
|
||||
|
||||
export async function ensureOwnerScope(input: {
|
||||
scopeKind: ScopeKind;
|
||||
ownerRef: string;
|
||||
displayName: string;
|
||||
}) {
|
||||
return mutate("/api/device-manager/owner-scopes:ensure", input);
|
||||
}
|
||||
|
||||
export async function ensureProject(input: {
|
||||
scopeKind: ScopeKind;
|
||||
ownerRef: string;
|
||||
projectKey: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}) {
|
||||
return mutate("/api/device-manager/projects:ensure", input);
|
||||
}
|
||||
|
||||
export async function ensureCollection(input: {
|
||||
projectRef: string;
|
||||
collectionKey: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}) {
|
||||
return mutate("/api/device-manager/collections:ensure", input);
|
||||
}
|
||||
|
||||
export async function claimDevice(input: {
|
||||
projectRef: string;
|
||||
enrollmentIntentRef: string;
|
||||
discoveryRef: string;
|
||||
deviceKey: string;
|
||||
displayName: string;
|
||||
}) {
|
||||
return mutate("/api/device-manager/devices:claim", input);
|
||||
}
|
||||
|
||||
export async function updateDevice(input: {
|
||||
projectRef: string;
|
||||
deviceRef: string;
|
||||
displayName: string;
|
||||
integrationDeviceId: string | null;
|
||||
}) {
|
||||
return mutate<{ updated: boolean; device: DeviceView }>(
|
||||
"/api/device-manager/devices:update",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureEnrollmentIntent(input: {
|
||||
projectRef: string;
|
||||
enrollmentKey: string;
|
||||
routeRef: string;
|
||||
modelProfileRef: string;
|
||||
displayName: string;
|
||||
identifier: { kind: "imei"; value: string };
|
||||
expiresAt: string | null;
|
||||
}) {
|
||||
return mutate("/api/device-manager/enrollment-intents:ensure", input);
|
||||
}
|
||||
|
||||
export async function upsertProjectGrant(input: {
|
||||
projectRef: string;
|
||||
principalKind: "user" | "group";
|
||||
principalRef: string;
|
||||
projectRole: string;
|
||||
capabilityAllow: string[];
|
||||
capabilityDeny: string[];
|
||||
lifecycleState: "active" | "revoked";
|
||||
}) {
|
||||
return mutate<{ created: boolean; grant: ProjectGrantView }>(
|
||||
"/api/device-manager/project-grants:upsert",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureAdapterPackage(input: {
|
||||
packageKey: string;
|
||||
displayName: string;
|
||||
publisherRef: string;
|
||||
lifecycleState: "active" | "retired";
|
||||
}) {
|
||||
return mutate<{ created: boolean; adapterPackage: AdapterPackageView }>(
|
||||
"/api/device-manager/adapter-packages:ensure",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function registerAdapterVersion(input: {
|
||||
adapterPackageRef: string;
|
||||
version: string;
|
||||
runtimePackageRef: string;
|
||||
contentDigest: string;
|
||||
contractVersion: string;
|
||||
capabilities: string[];
|
||||
lifecycleState: "draft" | "active" | "retired";
|
||||
}) {
|
||||
return mutate<{ created: boolean; adapterVersion: AdapterVersionView }>(
|
||||
"/api/device-manager/adapter-versions:register",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function registerModelProfile(input: {
|
||||
adapterVersionRef: string;
|
||||
profileRef: string;
|
||||
schemaVersion: string;
|
||||
vendor: string;
|
||||
model: string;
|
||||
deviceType: string;
|
||||
protocol: string;
|
||||
schemaArtifactRef: string;
|
||||
profileDigest: string;
|
||||
capabilities: string[];
|
||||
lifecycleState: "draft" | "active" | "retired";
|
||||
}) {
|
||||
return mutate<{ created: boolean; modelProfile: ModelProfileView }>(
|
||||
"/api/device-manager/model-profiles:register",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureEdge(input: {
|
||||
edgeKey: string;
|
||||
displayName: string;
|
||||
deploymentRef: string | null;
|
||||
lifecycleState: "provisioning" | "active" | "suspended" | "retired";
|
||||
}) {
|
||||
return mutate<{ created: boolean; edge: EdgeView }>(
|
||||
"/api/device-manager/edges:ensure",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureRoute(input: {
|
||||
projectRef: string;
|
||||
routeKey: string;
|
||||
displayName: string;
|
||||
edgeRef: string;
|
||||
modelProfileRef: string;
|
||||
listenerRef: string;
|
||||
protocol: string;
|
||||
direction: "telemetry" | "bidirectional";
|
||||
lifecycleState: "draft" | "active" | "suspended" | "retired";
|
||||
}) {
|
||||
return mutate<{ created: boolean; route: RouteView }>(
|
||||
"/api/device-manager/routes:ensure",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureDeviceBinding(input: {
|
||||
projectRef: string;
|
||||
bindingKey: string;
|
||||
displayName: string;
|
||||
source: { kind: "device" | "collection"; ref: string };
|
||||
targetKind: string;
|
||||
targetRef: string;
|
||||
capabilities: string[];
|
||||
}) {
|
||||
return mutate<{ created: boolean; binding: BindingView }>(
|
||||
"/api/device-manager/device-bindings:ensure",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function revokeDeviceBinding(input: {
|
||||
projectRef: string;
|
||||
bindingRef: string;
|
||||
resolutionCode: string;
|
||||
}) {
|
||||
return mutate<{ revoked: boolean; binding: BindingView }>(
|
||||
"/api/device-manager/device-bindings:revoke",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createConfigurationRevision(input: {
|
||||
projectRef: string;
|
||||
deviceRef: string;
|
||||
configuration: Record<string, unknown>;
|
||||
changeSummary: string | null;
|
||||
}) {
|
||||
return mutate<{
|
||||
created: boolean;
|
||||
configurationRevision: ConfigurationRevisionView;
|
||||
}>("/api/device-manager/device-configuration-revisions:create", input);
|
||||
}
|
||||
|
||||
export async function setDesiredConfiguration(input: {
|
||||
projectRef: string;
|
||||
deviceRef: string;
|
||||
configurationRevisionRef: string;
|
||||
}) {
|
||||
return mutate("/api/device-manager/device-configurations:set-desired", input);
|
||||
}
|
||||
|
||||
export async function sendServicePing(input: {
|
||||
projectRef: string;
|
||||
deviceRef: string;
|
||||
accessCode: string;
|
||||
expiresInSeconds: number;
|
||||
}) {
|
||||
return mutate("/api/device-manager/commands:service-ping", input);
|
||||
}
|
||||
|
||||
async function mutate<T = unknown>(path: string, input: unknown) {
|
||||
return requestJson<{ ok: true; replayed: boolean; result: T }>(path, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": `device-manager-${crypto.randomUUID()}`,
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
async function putJson<T = unknown>(path: string, input: unknown) {
|
||||
return requestJson<{ ok: true } & T>(path, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json", ...(init?.headers ?? {}) },
|
||||
...init,
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok || !body?.ok) {
|
||||
const error = new Error(body?.error || `device_manager_request_failed:${response.status}`);
|
||||
Object.assign(error, { status: response.status });
|
||||
throw error;
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
export type DeviceFieldAccess = "read-only" | "managed" | "protected";
|
||||
|
||||
export type DeviceFieldValueKind = "text" | "number" | "boolean" | "date";
|
||||
|
||||
export interface DeviceProfileField {
|
||||
key: string;
|
||||
label: string;
|
||||
path: string;
|
||||
access?: DeviceFieldAccess;
|
||||
valueKind?: DeviceFieldValueKind;
|
||||
unit?: string;
|
||||
description?: string;
|
||||
sensitive?: boolean;
|
||||
}
|
||||
|
||||
export interface DeviceProfileSection {
|
||||
id: string;
|
||||
label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
access: DeviceFieldAccess;
|
||||
fields: DeviceProfileField[];
|
||||
}
|
||||
|
||||
export interface DeviceProfileCatalog {
|
||||
profileRef: string;
|
||||
vendor: string;
|
||||
model: string;
|
||||
title: string;
|
||||
sections: DeviceProfileSection[];
|
||||
}
|
||||
|
||||
const field = (
|
||||
key: string,
|
||||
label: string,
|
||||
path: string,
|
||||
options: Omit<DeviceProfileField, "key" | "label" | "path"> = {},
|
||||
): DeviceProfileField => ({ key, label, path, ...options });
|
||||
|
||||
const managed = (
|
||||
key: string,
|
||||
label: string,
|
||||
path: string,
|
||||
options: Omit<DeviceProfileField, "key" | "label" | "path" | "access"> = {},
|
||||
) => field(key, label, path, { ...options, access: "managed" });
|
||||
|
||||
const protectedField = (
|
||||
key: string,
|
||||
label: string,
|
||||
path: string,
|
||||
options: Omit<DeviceProfileField, "key" | "label" | "path" | "access"> = {},
|
||||
) => field(key, label, path, { ...options, access: "protected" });
|
||||
|
||||
const serverFields = (slot: number) => [
|
||||
managed(`server-${slot}-host`, `Сервер ${slot}: DNS / IP`, `reported.configuration.monitoring.servers.${slot - 1}.host`),
|
||||
managed(`server-${slot}-port`, `Сервер ${slot}: порт`, `reported.configuration.monitoring.servers.${slot - 1}.port`, { valueKind: "number" }),
|
||||
managed(`server-${slot}-protocol`, `Сервер ${slot}: протокол`, `reported.configuration.monitoring.servers.${slot - 1}.protocol`),
|
||||
managed(`server-${slot}-identity`, `Сервер ${slot}: ID (SN)`, `reported.configuration.monitoring.servers.${slot - 1}.identity`),
|
||||
managed(`server-${slot}-password`, `Сервер ${slot}: пароль`, `reported.configuration.monitoring.servers.${slot - 1}.password`, { sensitive: true }),
|
||||
];
|
||||
|
||||
const managedIndexedFields = (
|
||||
count: number,
|
||||
prefix: string,
|
||||
label: string,
|
||||
path: string,
|
||||
options: Omit<DeviceProfileField, "key" | "label" | "path" | "access"> = {},
|
||||
) => Array.from({ length: count }, (_, index) => managed(
|
||||
`${prefix}-${index + 1}`,
|
||||
`${label} ${index + 1}`,
|
||||
`${path}.${index}`,
|
||||
options,
|
||||
));
|
||||
|
||||
const phoneFields = Array.from({ length: 5 }, (_, index) => [
|
||||
managed(`phone-${index + 1}-number`, `Телефон ${index + 1}: номер`, `reported.configuration.phones.${index}.number`, { sensitive: true }),
|
||||
managed(`phone-${index + 1}-mode`, `Телефон ${index + 1}: режим`, `reported.configuration.phones.${index}.mode`),
|
||||
]).flat();
|
||||
|
||||
const simFields = (slot: number) => [
|
||||
managed(`sim-${slot}-gprs`, `SIM ${slot}: передача данных`, `reported.configuration.simCards.${slot - 1}.gprsEnabled`, { valueKind: "boolean" }),
|
||||
managed(`sim-${slot}-apn`, `SIM ${slot}: APN оператора`, `reported.configuration.simCards.${slot - 1}.apn`),
|
||||
managed(`sim-${slot}-login`, `SIM ${slot}: логин APN`, `reported.configuration.simCards.${slot - 1}.login`, { sensitive: true }),
|
||||
managed(`sim-${slot}-password`, `SIM ${slot}: пароль APN`, `reported.configuration.simCards.${slot - 1}.password`, { sensitive: true }),
|
||||
managed(`sim-${slot}-roaming`, `SIM ${slot}: роуминг`, `reported.configuration.simCards.${slot - 1}.roamingEnabled`, { valueKind: "boolean" }),
|
||||
managed(`sim-${slot}-operator`, `SIM ${slot}: приоритетный оператор`, `reported.configuration.simCards.${slot - 1}.preferredOperatorCode`),
|
||||
managed(`sim-${slot}-pin`, `SIM ${slot}: PIN`, `reported.configuration.simCards.${slot - 1}.pin`, { sensitive: true }),
|
||||
managed(`sim-${slot}-ussd`, `SIM ${slot}: USSD запроса баланса`, `reported.configuration.simCards.${slot - 1}.balanceUssd`, { sensitive: true }),
|
||||
managed(`sim-${slot}-poll`, `SIM ${slot}: период запроса баланса`, `reported.configuration.simCards.${slot - 1}.balancePollHours`, { valueKind: "number", unit: "ч" }),
|
||||
];
|
||||
|
||||
const motionEventFields = ["acceleration", "braking", "cornering", "vertical"].flatMap((event) => {
|
||||
const labels: Record<string, string> = {
|
||||
acceleration: "Разгон",
|
||||
braking: "Торможение",
|
||||
cornering: "Угловое ускорение",
|
||||
vertical: "Вертикальное ускорение",
|
||||
};
|
||||
return Array.from({ length: 3 }, (_, level) => [
|
||||
managed(`${event}-${level + 1}-threshold`, `${labels[event]} ${level + 1}: порог`, `reported.configuration.drivingStyle.${event}.${level}.thresholdMg`, { valueKind: "number", unit: "mg" }),
|
||||
managed(`${event}-${level + 1}-duration`, `${labels[event]} ${level + 1}: длительность превышения`, `reported.configuration.drivingStyle.${event}.${level}.durationMs`, { valueKind: "number", unit: "мс" }),
|
||||
managed(`${event}-${level + 1}-reset`, `${labels[event]} ${level + 1}: задержка сброса`, `reported.configuration.drivingStyle.${event}.${level}.resetDelayMs`, { valueKind: "number", unit: "мс" }),
|
||||
]).flat();
|
||||
}).flat();
|
||||
|
||||
const violationFields = ["speed", "rpm"].flatMap((kind) => Array.from({ length: 4 }, (_, level) => [
|
||||
managed(`${kind}-${level + 1}-threshold`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: порог`, `reported.configuration.drivingStyle.violations.${kind}.${level}.threshold`, { valueKind: "number", unit: kind === "speed" ? "км/ч" : "об/мин" }),
|
||||
managed(`${kind}-${level + 1}-duration`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: минимальное время`, `reported.configuration.drivingStyle.violations.${kind}.${level}.minimumDurationSeconds`, { valueKind: "number", unit: "с" }),
|
||||
managed(`${kind}-${level + 1}-reset`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: порог сброса`, `reported.configuration.drivingStyle.violations.${kind}.${level}.resetThreshold`, { valueKind: "number", unit: kind === "speed" ? "км/ч" : "об/мин" }),
|
||||
]).flat()).flat();
|
||||
|
||||
const modbusRegisterFields = Array.from({ length: 10 }, (_, index) => [
|
||||
managed(`modbus-register-${index + 1}`, `Регистр ${index + 1}: номер`, `reported.configuration.modbus.registers.${index}.number`, { valueKind: "number" }),
|
||||
managed(`modbus-register-${index + 1}-pair`, `Регистр ${index + 1}: читать два регистра`, `reported.configuration.modbus.registers.${index}.readPair`, { valueKind: "boolean" }),
|
||||
]).flat();
|
||||
|
||||
const bleFields = Array.from({ length: 10 }, (_, index) => [
|
||||
managed(`ble-${index + 1}-mac`, `BLE датчик ${index + 1}: MAC`, `reported.configuration.bluetooth.sensors.${index}.mac`),
|
||||
managed(`ble-${index + 1}-integration`, `BLE датчик ${index + 1}: интеграция`, `reported.configuration.bluetooth.sensors.${index}.integrationExpression`),
|
||||
]).flat();
|
||||
|
||||
export const ARUSNAVI_B2_CATALOG: DeviceProfileCatalog = {
|
||||
profileRef: "arusnavi.b2.internal.v1",
|
||||
vendor: "ARUSNAVI",
|
||||
model: "B2",
|
||||
title: "ARUSNAVI B2",
|
||||
sections: [
|
||||
{
|
||||
id: "passport",
|
||||
label: "Паспорт",
|
||||
title: "Паспорт и состояние устройства",
|
||||
description: "Реестровая идентичность, профиль модели и текущее состояние канала. Исходный идентификатор показывается только в проекции, разрешённой Device Core.",
|
||||
access: "read-only",
|
||||
fields: [
|
||||
managed(
|
||||
"device-display-name",
|
||||
"Название устройства",
|
||||
"device.displayName",
|
||||
{ description: "Произвольное имя, которое Device Core показывает в реестре и карточке устройства." },
|
||||
),
|
||||
managed(
|
||||
"integration-device-id",
|
||||
"ID интеграционного устройства",
|
||||
"device.integrationDeviceId",
|
||||
{ description: "Идентификатор целевого актива во внешней бизнес-системе, например ID трайка." },
|
||||
),
|
||||
field("device-key", "Ключ устройства", "device.deviceKey"),
|
||||
field("device-ref", "Device Core ref", "device.deviceRef"),
|
||||
field("vendor", "Производитель", "profile.vendor"),
|
||||
field("model", "Модель", "profile.model"),
|
||||
field("device-type", "Тип", "profile.deviceType"),
|
||||
field("profile", "Профиль модели", "device.modelProfileRef"),
|
||||
field("identifier-kind", "Тип идентификатора", "device.identifier.kind"),
|
||||
field("imei", "IMEI", "device.identifier.displayValue", {
|
||||
description: "Полное значение отображается только в разрешённой Device Core проекции; иначе показывается защищённая маска.",
|
||||
}),
|
||||
field("iccid-1", "ICCID 1", "reported.identity.iccid1"),
|
||||
field("iccid-2", "ICCID 2", "reported.identity.iccid2"),
|
||||
field("lifecycle", "Состояние реестра", "device.lifecycleState"),
|
||||
field("created", "Зарегистрирован", "device.createdAt", { valueKind: "date" }),
|
||||
field("updated", "Обновлён", "device.updatedAt", { valueKind: "date" }),
|
||||
field("reported-at", "Снимок устройства получен", "reported.observedAt", { valueKind: "date" }),
|
||||
managed("asset-model", "Модель актива", "reported.metadata.model"),
|
||||
managed("registration", "Регистрационный номер", "reported.metadata.registrationNumber"),
|
||||
managed("object", "Объект", "reported.metadata.object"),
|
||||
managed("description", "Описание", "reported.metadata.description"),
|
||||
managed("sim-label-1", "Метка SIM 1", "reported.metadata.simLabel1"),
|
||||
managed("sim-label-2", "Метка SIM 2", "reported.metadata.simLabel2"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "live",
|
||||
label: "Онлайн",
|
||||
title: "Живой канал и телеметрия",
|
||||
description: "Значения обновляются из последней gateway-сессии и безопасного снимка телеметрии. Интерфейс опрашивает Device Core, пока открыта карточка.",
|
||||
access: "read-only",
|
||||
fields: [
|
||||
field("session-state", "Состояние соединения", "session.lifecycleState"),
|
||||
field("session-route", "Маршрут", "session.routeName"),
|
||||
field("session-connected", "Подключён", "session.connectedAt", { valueKind: "date" }),
|
||||
field("session-last-seen", "Последний пакет", "session.lastSeenAt", { valueKind: "date" }),
|
||||
field("session-frames", "Принято пакетов", "session.frameCount", { valueKind: "number" }),
|
||||
field("session-bytes", "Принято данных", "session.byteCount", { valueKind: "number", unit: "байт" }),
|
||||
field("latitude", "Широта", "reported.telemetry.navigation.latitude"),
|
||||
field("longitude", "Долгота", "reported.telemetry.navigation.longitude"),
|
||||
field("speed", "Скорость", "reported.telemetry.navigation.speedKph", { valueKind: "number", unit: "км/ч" }),
|
||||
field("altitude", "Высота", "reported.telemetry.navigation.altitudeMeters", { valueKind: "number", unit: "м" }),
|
||||
field("satellites", "Спутники", "reported.telemetry.navigation.satellites", { valueKind: "number" }),
|
||||
field("course", "Курс", "reported.telemetry.navigation.courseDegrees", { valueKind: "number", unit: "°" }),
|
||||
field("hdop", "HDOP", "reported.telemetry.navigation.hdop"),
|
||||
field("gsm-signal", "Уровень GSM", "reported.telemetry.gsm.signal"),
|
||||
field("gsm-operator", "Оператор", "reported.telemetry.gsm.operator"),
|
||||
field("gsm-lac", "LAC", "reported.telemetry.gsm.lac"),
|
||||
field("gsm-cid", "CID", "reported.telemetry.gsm.cid"),
|
||||
field("external-voltage", "Внешнее напряжение", "reported.telemetry.system.externalVoltageMv", { valueKind: "number", unit: "мВ" }),
|
||||
field("internal-voltage", "Внутреннее напряжение", "reported.telemetry.system.internalVoltageMv", { valueKind: "number", unit: "мВ" }),
|
||||
field("errors", "Ошибки и статусы", "reported.telemetry.system.status"),
|
||||
field("inputs", "Входы и выходы", "reported.telemetry.system.io"),
|
||||
field("modules", "Статусы модулей", "reported.telemetry.system.modules"),
|
||||
field("engine-hours", "Моточасы", "reported.telemetry.can.engineHours"),
|
||||
field("odometer", "Пробег", "reported.telemetry.can.odometer"),
|
||||
field("fuel-total", "Полный расход топлива", "reported.telemetry.can.fuelTotal"),
|
||||
field("fuel-level", "Уровень топлива", "reported.telemetry.can.fuelLevel"),
|
||||
field("rpm", "Обороты двигателя", "reported.telemetry.can.rpm"),
|
||||
field("engine-temp", "Температура двигателя", "reported.telemetry.can.engineTemperature"),
|
||||
field("vehicle-speed", "Скорость по CAN", "reported.telemetry.can.vehicleSpeed"),
|
||||
field("axle-pressure", "Давление на оси", "reported.telemetry.can.axlePressure"),
|
||||
field("crash", "Контроллер аварии", "reported.telemetry.can.crashController"),
|
||||
field("instant-fuel", "Моментальный расход", "reported.telemetry.can.instantFuel"),
|
||||
field("adblue", "Уровень AdBlue", "reported.telemetry.can.adBlueLevel"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "firmware",
|
||||
label: "Прошивка",
|
||||
title: "Версия программного обеспечения",
|
||||
description: "Версию и доступность обновления показываем, но запуск обновления для пилотного B2 запрещён. Этот запрет не снимается включением обычного командного канала.",
|
||||
access: "protected",
|
||||
fields: [
|
||||
field("firmware-current", "Текущая версия", "reported.firmware.currentVersion"),
|
||||
field("firmware-applied", "Версия применена", "reported.firmware.appliedAt", { valueKind: "date" }),
|
||||
field("firmware-available", "Доступная версия", "reported.firmware.availableVersion"),
|
||||
field("firmware-description", "Описание версии", "reported.firmware.description"),
|
||||
protectedField("firmware-action", "Обновление прошивки", "policies.firmwareUpdate", { description: "Заблокировано для пилотного устройства" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "templates",
|
||||
label: "Шаблоны",
|
||||
title: "Шаблоны настроек",
|
||||
description: "Шаблон хранит именованный снимок конфигурации модели. Применение должно создавать новую desired-ревизию, а не менять устройство в обход command ledger.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
field("template-current", "Применённый шаблон", "reported.configurationTemplate.name"),
|
||||
field("template-applied", "Шаблон применён", "reported.configurationTemplate.appliedAt", { valueKind: "date" }),
|
||||
managed("template-select", "Выбранный шаблон", "reported.configurationTemplate.selected"),
|
||||
managed("template-name", "Название нового шаблона", "reported.configurationTemplate.draft.name"),
|
||||
managed("template-description", "Описание нового шаблона", "reported.configurationTemplate.draft.description"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "monitoring",
|
||||
label: "Серверы",
|
||||
title: "Серверы мониторинга",
|
||||
description: "B2 поддерживает четыре серверных слота. Существующий Gelios сохраняется параллельно; новый маршрут не должен его перетирать.",
|
||||
access: "managed",
|
||||
fields: [1, 2, 3, 4].flatMap(serverFields),
|
||||
},
|
||||
{
|
||||
id: "transmission",
|
||||
label: "Передача",
|
||||
title: "Набор передаваемых данных",
|
||||
description: "Флаги определяют состав телеметрии, которую формирует устройство.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
managed("tx-nav-position", "Навигация: широта и долгота", "reported.configuration.transmission.navigation.position", { valueKind: "boolean" }),
|
||||
managed("tx-nav-motion", "Навигация: скорость, высота, спутники и курс", "reported.configuration.transmission.navigation.motion", { valueKind: "boolean" }),
|
||||
managed("tx-nav-hdop", "Навигация: HDOP", "reported.configuration.transmission.navigation.hdop", { valueKind: "boolean" }),
|
||||
managed("tx-gsm-operator", "GSM: сигнал и оператор", "reported.configuration.transmission.gsm.operator", { valueKind: "boolean" }),
|
||||
managed("tx-gsm-cell", "GSM: LAC и CID", "reported.configuration.transmission.gsm.cell", { valueKind: "boolean" }),
|
||||
managed("tx-system-status", "Системные: ошибки и статусы", "reported.configuration.transmission.system.status", { valueKind: "boolean" }),
|
||||
managed("tx-system-io", "Системные: входы, выходы и модули", "reported.configuration.transmission.system.io", { valueKind: "boolean" }),
|
||||
managed("tx-system-voltage", "Системные: напряжения", "reported.configuration.transmission.system.voltage", { valueKind: "boolean" }),
|
||||
...["statuses", "engineHours", "odometer", "fuelTotal", "fuelLevel", "rpm", "engineTemperature", "vehicleSpeed", "axlePressure", "crashController", "instantFuel", "adBlueLevel"].map((key) => managed(`tx-can-${key}`, `CAN: ${({ statuses: "статусы работы", engineHours: "моточасы", odometer: "пробег", fuelTotal: "полный расход топлива", fuelLevel: "уровень топлива", rpm: "обороты двигателя", engineTemperature: "температура двигателя", vehicleSpeed: "скорость", axlePressure: "давление на оси", crashController: "контроллер аварии", instantFuel: "моментальный расход", adBlueLevel: "уровень AdBlue" } as Record<string, string>)[key]}`, `reported.configuration.transmission.can.${key}`, { valueKind: "boolean" })),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "trajectory",
|
||||
label: "Траектория",
|
||||
title: "Отрисовка траектории и датчик движения",
|
||||
description: "Обычные и роуминговые интервалы, заморозка координат и параметры встроенного датчика движения.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
...["normal", "roaming"].flatMap((mode) => {
|
||||
const label = mode === "normal" ? "Основной режим" : "Роуминг";
|
||||
return [
|
||||
managed(`${mode}-course`, `${label}: изменение курса`, `reported.configuration.trajectory.${mode}.courseDeltaDegrees`, { valueKind: "number", unit: "°" }),
|
||||
managed(`${mode}-speed`, `${label}: изменение скорости`, `reported.configuration.trajectory.${mode}.speedDeltaKph`, { valueKind: "number", unit: "км/ч" }),
|
||||
managed(`${mode}-distance`, `${label}: расстояние между точками`, `reported.configuration.trajectory.${mode}.distanceMeters`, { valueKind: "number", unit: "м" }),
|
||||
managed(`${mode}-parking`, `${label}: интервал на стоянке`, `reported.configuration.trajectory.${mode}.parkingIntervalSeconds`, { valueKind: "number", unit: "с" }),
|
||||
];
|
||||
}),
|
||||
managed("freeze-low-speed", "Заморозка координат при скорости ниже 2 км/ч", "reported.configuration.trajectory.freeze.lowSpeed", { valueKind: "boolean" }),
|
||||
managed("freeze-motion", "Заморозка по датчику движения", "reported.configuration.trajectory.freeze.motionSensor", { valueKind: "boolean" }),
|
||||
managed("freeze-ignition", "Заморозка по зажиганию", "reported.configuration.trajectory.freeze.ignition", { valueKind: "boolean" }),
|
||||
managed("freeze-quiet", "Тихоходная техника", "reported.configuration.trajectory.freeze.lowSpeedVehicle", { valueKind: "boolean" }),
|
||||
managed("motion-sensitivity", "Чувствительность датчика движения", "reported.configuration.motionSensor.sensitivity", { valueKind: "number" }),
|
||||
managed("motion-delay", "Задержка срабатывания", "reported.configuration.motionSensor.delaySeconds", { valueKind: "number", unit: "с" }),
|
||||
managed("motion-impact", "Порог удара", "reported.configuration.motionSensor.impact", { valueKind: "number" }),
|
||||
managed("motion-tilt", "Порог наклона", "reported.configuration.motionSensor.tilt", { valueKind: "number" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "io",
|
||||
label: "Входы / выходы",
|
||||
title: "Входы и выходы",
|
||||
description: "Режимы PIN0–PIN7 и пороги. Непосредственное переключение выходов относится к защищённым командам.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
...managedIndexedFields(8, "pin-mode", "Режим PIN", "reported.configuration.io.pinModes"),
|
||||
managed("speed-coefficient", "Коэффициент датчика скорости", "reported.configuration.io.speedSensorCoefficient", { valueKind: "number" }),
|
||||
managed("virtual-ignition", "Порог виртуального зажигания", "reported.configuration.io.virtualIgnitionThresholdMv", { valueKind: "number", unit: "мВ" }),
|
||||
managed("analog-pin-2", "Порог аналогового входа PIN2", "reported.configuration.io.analogThresholds.pin2Mv", { valueKind: "number", unit: "мВ" }),
|
||||
managed("analog-pin-3", "Порог аналогового входа PIN3", "reported.configuration.io.analogThresholds.pin3Mv", { valueKind: "number", unit: "мВ" }),
|
||||
protectedField("output-4", "Команда выхода PIN4", "reported.operations.outputs.pin4"),
|
||||
protectedField("output-5", "Команда выхода PIN5", "reported.operations.outputs.pin5"),
|
||||
protectedField("output-6", "Команда выхода PIN6", "reported.operations.outputs.pin6"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ports",
|
||||
label: "Порты",
|
||||
title: "Цифровые порты и датчики",
|
||||
description: "RS232, RS485, CAN, Wi‑Fi, фотоснимки, 1‑Wire и фильтрация датчиков.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
managed("rs232", "RS232", "reported.configuration.ports.rs232.mode"),
|
||||
managed("rs485", "RS485", "reported.configuration.ports.rs485.mode"),
|
||||
managed("can-program", "Номер программы CAN", "reported.configuration.ports.can.program", { valueKind: "number" }),
|
||||
managed("can-internal", "Активировать внутренний CAN", "reported.configuration.ports.can.internalEnabled", { valueKind: "boolean" }),
|
||||
managed("can-seatbelt", "Контролировать ремень по CAN", "reported.configuration.ports.can.seatbelt", { valueKind: "boolean" }),
|
||||
managed("can-headlight", "Контролировать ближний свет по CAN", "reported.configuration.ports.can.headlight", { valueKind: "boolean" }),
|
||||
managed("wifi-ssid", "Wi‑Fi: имя сети", "reported.configuration.ports.wifi.ssid"),
|
||||
managed("wifi-password", "Wi‑Fi: пароль", "reported.configuration.ports.wifi.password", { sensitive: true }),
|
||||
managed("photo-interval", "Интервал фотоснимков", "reported.configuration.ports.camera.intervalMinutes", { valueKind: "number", unit: "мин" }),
|
||||
managed("photo-resolution", "Разрешение фотоснимков", "reported.configuration.ports.camera.resolution"),
|
||||
managed("one-wire-auto", "Сохранять новые термодатчики", "reported.configuration.ports.oneWire.autoDiscover", { valueKind: "boolean" }),
|
||||
...managedIndexedFields(10, "one-wire", "Адрес термодатчика", "reported.configuration.ports.oneWire.sensorAddresses"),
|
||||
managed("median-filter", "Медианный фильтр датчиков", "reported.configuration.ports.sensorFilter.medianEnabled", { valueKind: "boolean" }),
|
||||
...managedIndexedFields(4, "lls-filter", "Степень фильтрации LLS", "reported.configuration.ports.sensorFilter.lls", { valueKind: "number" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "modbus",
|
||||
label: "Modbus",
|
||||
title: "Параметры Modbus",
|
||||
description: "Последовательный порт, сетевые адреса и до десяти читаемых регистров.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
managed("modbus-baud", "Скорость обмена", "reported.configuration.modbus.baudRate", { valueKind: "number" }),
|
||||
managed("modbus-poll", "Таймер опроса", "reported.configuration.modbus.pollSeconds", { valueKind: "number", unit: "с" }),
|
||||
managed("modbus-parity", "Проверка на чётность", "reported.configuration.modbus.parity"),
|
||||
managed("modbus-stop", "Stop bits", "reported.configuration.modbus.stopBits"),
|
||||
managed("modbus-address-a", "Сетевой адрес датчика для регистров 1–5", "reported.configuration.modbus.addresses.first", { valueKind: "number" }),
|
||||
managed("modbus-address-b", "Сетевой адрес датчика для регистров 6–10", "reported.configuration.modbus.addresses.second", { valueKind: "number" }),
|
||||
...modbusRegisterFields,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bluetooth",
|
||||
label: "Bluetooth",
|
||||
title: "Bluetooth (BLE) датчики",
|
||||
description: "Режим BLE-модуля, код сопряжения и десять датчиков с выражениями универсальной интеграции.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
managed("ble-mode", "Режим работы Bluetooth", "reported.configuration.bluetooth.mode"),
|
||||
managed("ble-pairing", "Код сопряжения", "reported.configuration.bluetooth.pairingCode", { sensitive: true }),
|
||||
...bleFields,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "driving-style",
|
||||
label: "Стиль вождения",
|
||||
title: "Стиль вождения",
|
||||
description: "Пороговые профили акселерометра и превышений скорости/оборотов.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
...motionEventFields,
|
||||
managed("accelerometer-transmit", "Передавать данные акселерометра", "reported.configuration.drivingStyle.transmitAccelerometer", { valueKind: "boolean" }),
|
||||
managed("accelerometer-reset-events", "Передавать события сброса", "reported.configuration.drivingStyle.transmitResetEvents", { valueKind: "boolean" }),
|
||||
managed("accelerometer-bitmask", "Передавать состояния сработок", "reported.configuration.drivingStyle.transmitTriggerMask", { valueKind: "boolean" }),
|
||||
managed("accelerometer-average", "Глубина усреднения акселерометра", "reported.configuration.drivingStyle.averagingDepth", { valueKind: "number" }),
|
||||
...violationFields,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "phones",
|
||||
label: "Телефоны",
|
||||
title: "Разрешённые телефоны",
|
||||
description: "До пяти номеров и индивидуальный режим доступа для SMS-управления.",
|
||||
access: "managed",
|
||||
fields: phoneFields,
|
||||
},
|
||||
{
|
||||
id: "sim",
|
||||
label: "SIM-карты",
|
||||
title: "SIM-карты и мобильная сеть",
|
||||
description: "Параметры двух SIM-профилей. Пароли, PIN и USSD не возвращаются в открытом виде.",
|
||||
access: "managed",
|
||||
fields: [...simFields(1), ...simFields(2)],
|
||||
},
|
||||
{
|
||||
id: "navigation",
|
||||
label: "Навигация",
|
||||
title: "Навигация и фильтрация координат",
|
||||
description: "Источники координат, спутниковые группировки, внешний локатор и фильтры качества.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
managed("nav-satellite", "Спутниковая навигация", "reported.configuration.navigation.sources.satellite", { valueKind: "boolean" }),
|
||||
managed("nav-wifi", "Wi‑Fi локатор", "reported.configuration.navigation.sources.wifi", { valueKind: "boolean" }),
|
||||
managed("nav-lbs", "LBS локатор", "reported.configuration.navigation.sources.lbs", { valueKind: "boolean" }),
|
||||
managed("nav-tag", "Навигационная метка", "reported.configuration.navigation.sources.tag", { valueKind: "boolean" }),
|
||||
...["gps", "glonass", "galileo", "beidou"].map((key) => managed(`nav-${key}`, key.toUpperCase(), `reported.configuration.navigation.constellations.${key}`, { valueKind: "boolean" })),
|
||||
managed("locator-url", "URL локатора", "reported.configuration.navigation.locator.url", { sensitive: true }),
|
||||
managed("locator-moving", "Интервал локатора в движении", "reported.configuration.navigation.locator.movingIntervalSeconds", { valueKind: "number", unit: "с" }),
|
||||
managed("locator-parked", "Интервал локатора на стоянке", "reported.configuration.navigation.locator.parkedIntervalSeconds", { valueKind: "number", unit: "с" }),
|
||||
managed("filter-satellites", "Минимальное число спутников", "reported.configuration.navigation.filter.minimumSatellites", { valueKind: "number" }),
|
||||
managed("filter-hdop", "Максимальный HDOP × 10", "reported.configuration.navigation.filter.maximumHdopTimesTen", { valueKind: "number" }),
|
||||
managed("filter-altitude-min", "Минимальная высота", "reported.configuration.navigation.filter.minimumAltitudeMeters", { valueKind: "number", unit: "м" }),
|
||||
managed("filter-altitude-max", "Максимальная высота", "reported.configuration.navigation.filter.maximumAltitudeMeters", { valueKind: "number", unit: "м" }),
|
||||
managed("filter-speed-min", "Минимальная мгновенная скорость", "reported.configuration.navigation.filter.minimumInstantSpeedKph", { valueKind: "number", unit: "км/ч" }),
|
||||
managed("filter-speed-max", "Максимальная мгновенная скорость", "reported.configuration.navigation.filter.maximumInstantSpeedKph", { valueKind: "number", unit: "км/ч" }),
|
||||
managed("filter-speed-average", "Максимальная средняя скорость", "reported.configuration.navigation.filter.maximumAverageSpeedKph", { valueKind: "number", unit: "км/ч" }),
|
||||
managed("filter-time", "Максимальное время фильтрации", "reported.configuration.navigation.filter.maximumSeconds", { valueKind: "number", unit: "с" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
label: "Системные",
|
||||
title: "Системные параметры",
|
||||
description: "Системные интервалы и энергосбережение. Секретные значения отображаются только как факт наличия.",
|
||||
access: "managed",
|
||||
fields: [
|
||||
managed("sms-password", "Пароль устройства (SMS)", "reported.configuration.system.smsPassword", { sensitive: true }),
|
||||
managed("web-check-hours", "Проверять WEB-конфигуратор каждые", "reported.configuration.system.webConfiguration.checkHours", { valueKind: "number", unit: "ч" }),
|
||||
managed("web-check-start", "Проверять WEB-конфигуратор при старте", "reported.configuration.system.webConfiguration.onStart", { valueKind: "boolean" }),
|
||||
managed("sleep-mode", "Режим сна", "reported.configuration.system.powerSaving.mode"),
|
||||
managed("sleep-wake-interval", "Выходить на связь каждые", "reported.configuration.system.powerSaving.wakeIntervalMinutes", { valueKind: "number", unit: "мин" }),
|
||||
managed("sleep-online", "Время пребывания на связи", "reported.configuration.system.powerSaving.onlineMinutes", { valueKind: "number", unit: "мин" }),
|
||||
managed("sleep-motion", "Выходить из сна по датчику движения", "reported.configuration.system.powerSaving.wakeOnMotion", { valueKind: "boolean" }),
|
||||
managed("sleep-input", "Выходить из сна по изменению входа", "reported.configuration.system.powerSaving.wakeOnInput", { valueKind: "boolean" }),
|
||||
managed("battery-ignition", "Заряжать АКБ только при включённом зажигании", "reported.configuration.system.chargeBatteryOnIgnitionOnly", { valueKind: "boolean" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "diagnostics",
|
||||
label: "Диагностика",
|
||||
title: "Диагностика и операции",
|
||||
description: "Доступные B2 операции отражены полностью, но выполняются только через подтверждённый двусторонний канал и отдельный command ledger.",
|
||||
access: "protected",
|
||||
fields: [
|
||||
field("debug-last-session", "Последняя удалённая отладка", "reported.diagnostics.lastSessionAt", { valueKind: "date" }),
|
||||
field("debug-output", "Результат удалённой отладки", "reported.diagnostics.output"),
|
||||
protectedField("op-packet", "Запросить пакет телеметрии", "reported.operations.requestTelemetry"),
|
||||
protectedField("op-info", "Запросить информацию", "reported.operations.requestInfo"),
|
||||
protectedField("op-coordinates", "Запросить координаты", "reported.operations.requestCoordinates"),
|
||||
protectedField("op-config", "Синхронизировать настройки", "reported.operations.syncConfiguration"),
|
||||
protectedField("op-restart", "Перезапустить устройство", "reported.operations.restart"),
|
||||
protectedField("op-clear", "Очистить память", "reported.operations.clearMemory"),
|
||||
protectedField("op-firmware", "Обновить прошивку", "reported.operations.updateFirmware", { description: "Запрещено для пилотного B2" }),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const genericCatalog: DeviceProfileCatalog = {
|
||||
profileRef: "generic.device.v1",
|
||||
vendor: "NODE.DC",
|
||||
model: "Generic device",
|
||||
title: "Устройство",
|
||||
sections: ARUSNAVI_B2_CATALOG.sections.filter((section) => ["passport", "live"].includes(section.id)),
|
||||
};
|
||||
|
||||
const catalogs = new Map<string, DeviceProfileCatalog>([
|
||||
[ARUSNAVI_B2_CATALOG.profileRef, ARUSNAVI_B2_CATALOG],
|
||||
]);
|
||||
|
||||
export function getDeviceProfileCatalog(profileRef: string): DeviceProfileCatalog {
|
||||
return catalogs.get(profileRef) ?? { ...genericCatalog, profileRef };
|
||||
}
|
||||
|
||||
export function accessLabel(access: DeviceFieldAccess) {
|
||||
return ({
|
||||
"read-only": "Только чтение",
|
||||
managed: "Управляемая настройка",
|
||||
protected: "Защищённая операция",
|
||||
})[access];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@nodedc/ui-core/styles.css";
|
||||
import "./styles.css";
|
||||
import { DeviceManagerApp } from "./DeviceManagerApp";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<DeviceManagerApp />
|
||||
</StrictMode>,
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,332 @@
|
||||
export type HubRole = "viewer" | "member" | "admin" | "owner";
|
||||
export type ScopeKind = "company" | "personal";
|
||||
|
||||
export interface OwnerScopeClaim {
|
||||
scopeKind: ScopeKind;
|
||||
ownerRef: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface DeviceManagerSession {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
initials: string;
|
||||
};
|
||||
actor: {
|
||||
userRef: string;
|
||||
hubRole: HubRole;
|
||||
groupRefs: string[];
|
||||
ownerScopes: OwnerScopeClaim[];
|
||||
};
|
||||
profileUrl: string;
|
||||
}
|
||||
|
||||
export type DeviceManagerTheme = "dark" | "light";
|
||||
export type DeviceManagerMediaSource = "file" | "url";
|
||||
export type DeviceManagerMediaKind = "image" | "video";
|
||||
|
||||
export interface DeviceManagerMediaValue {
|
||||
source: DeviceManagerMediaSource;
|
||||
url: string;
|
||||
fileName: string | null;
|
||||
fileSrc: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceManagerEnvironmentMediaItem extends DeviceManagerMediaValue {
|
||||
id: string;
|
||||
mediaKind: DeviceManagerMediaKind | null;
|
||||
}
|
||||
|
||||
export interface DeviceManagerEnvironmentOverview {
|
||||
headerLabel: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primarySection: string | null;
|
||||
secondarySection: string | null;
|
||||
background: {
|
||||
enabled: boolean;
|
||||
imageDurationSeconds: number;
|
||||
items: DeviceManagerEnvironmentMediaItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeviceManagerProjectPresentation {
|
||||
icon: DeviceManagerMediaValue;
|
||||
teaser: DeviceManagerMediaValue;
|
||||
}
|
||||
|
||||
export interface DeviceManagerPresentation {
|
||||
environment: {
|
||||
theme: DeviceManagerTheme;
|
||||
accentHex: string;
|
||||
overview: DeviceManagerEnvironmentOverview;
|
||||
};
|
||||
projects: Record<string, DeviceManagerProjectPresentation>;
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
projectRef: string;
|
||||
projectKey: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
lifecycleState: string;
|
||||
ownerScope: {
|
||||
ownerScopeRef: string;
|
||||
scopeKind: ScopeKind;
|
||||
ownerRef: string;
|
||||
displayName: string;
|
||||
};
|
||||
access: {
|
||||
projectRole: string | null;
|
||||
capabilities: string[];
|
||||
};
|
||||
counts: { devices: number; collections: number; discoveries: number };
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceView {
|
||||
deviceRef: string;
|
||||
deviceKey: string | null;
|
||||
displayName: string;
|
||||
integrationDeviceId: string | null;
|
||||
modelProfileRef: string;
|
||||
lifecycleState: string;
|
||||
identifier: { kind: string; masked: string; value?: string } | null;
|
||||
session: { state: string; lastSeenAt: string | null } | null;
|
||||
reported?: {
|
||||
observedAt?: string | null;
|
||||
identity?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
firmware?: Record<string, unknown>;
|
||||
configuration?: Record<string, unknown>;
|
||||
telemetry?: Record<string, unknown>;
|
||||
diagnostics?: Record<string, unknown>;
|
||||
operations?: Record<string, unknown>;
|
||||
} | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CollectionView {
|
||||
collectionRef: string;
|
||||
collectionKey: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
lifecycleState: string;
|
||||
memberCount: number;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface DiscoveryView {
|
||||
discoveryRef: string;
|
||||
identifier: { kind: string; masked: string };
|
||||
modelProfileRef: string;
|
||||
protocol: string;
|
||||
lifecycleState: string;
|
||||
enrollmentIntentRef: string | null;
|
||||
claimedDeviceRef: string | null;
|
||||
firstObservedAt: string | null;
|
||||
lastObservedAt: string | null;
|
||||
}
|
||||
|
||||
export interface EnrollmentView {
|
||||
enrollmentIntentRef: string;
|
||||
enrollmentKey: string;
|
||||
displayName: string;
|
||||
modelProfileRef: string;
|
||||
expectedIdentifier: { kind: string; masked: string };
|
||||
lifecycleState: string;
|
||||
observedDiscoveryRef: string | null;
|
||||
claimedDeviceRef: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface AdapterPackageView {
|
||||
adapterPackageRef: string;
|
||||
packageKey: string;
|
||||
displayName: string;
|
||||
publisherRef: string;
|
||||
lifecycleState: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface AdapterVersionView {
|
||||
adapterVersionRef: string;
|
||||
adapterPackageRef: string;
|
||||
version: string;
|
||||
runtimePackageRef: string;
|
||||
contentDigest: string;
|
||||
contractVersion: string;
|
||||
capabilities: string[];
|
||||
lifecycleState: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ModelProfileView {
|
||||
modelProfileRef: string;
|
||||
adapterVersionRef: string | null;
|
||||
schemaVersion: string;
|
||||
vendor: string;
|
||||
model: string;
|
||||
deviceType: string;
|
||||
protocol: string;
|
||||
schemaArtifactRef: string | null;
|
||||
profileDigest: string | null;
|
||||
capabilities: string[];
|
||||
lifecycleState: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface EdgeView {
|
||||
edgeRef: string;
|
||||
edgeKey: string;
|
||||
displayName: string;
|
||||
deploymentRef: string | null;
|
||||
lifecycleState: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RouteView {
|
||||
routeRef: string;
|
||||
routeKey: string;
|
||||
displayName: string;
|
||||
edgeRef: string;
|
||||
edgeName: string;
|
||||
modelProfileRef: string;
|
||||
profileName: string;
|
||||
listenerRef: string;
|
||||
protocol: string;
|
||||
direction: "telemetry" | "bidirectional";
|
||||
lifecycleState: "draft" | "active" | "suspended" | "retired";
|
||||
sessionCount: number;
|
||||
activeSessionCount: number;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface SessionView {
|
||||
sessionRef: string;
|
||||
routeRef: string;
|
||||
routeName: string;
|
||||
deviceRef: string | null;
|
||||
deviceName: string | null;
|
||||
protocol: string;
|
||||
lifecycleState: string;
|
||||
connectedAt: string | null;
|
||||
lastSeenAt: string | null;
|
||||
disconnectedAt: string | null;
|
||||
closeReasonCode: string | null;
|
||||
frameCount: number;
|
||||
byteCount: number;
|
||||
}
|
||||
|
||||
export interface BindingView {
|
||||
bindingRef: string;
|
||||
bindingKey: string;
|
||||
displayName: string;
|
||||
source: { kind: string; ref: string; displayName: string };
|
||||
target: { kind: string; ref: string };
|
||||
capabilities: string[];
|
||||
lifecycleState: string;
|
||||
sourceApprovedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ConfigurationRevisionView {
|
||||
configurationRevisionRef: string;
|
||||
deviceRef: string;
|
||||
deviceName: string;
|
||||
revisionNumber: number;
|
||||
modelProfileRef: string;
|
||||
schemaArtifactRef: string;
|
||||
configurationDigest: string;
|
||||
changeSummary: string | null;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
export interface ConfigurationStateView {
|
||||
deviceRef: string;
|
||||
deviceName: string;
|
||||
desiredConfigurationRevisionRef: string | null;
|
||||
appliedConfigurationRevisionRef: string | null;
|
||||
appliedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CommandView {
|
||||
commandRef: string;
|
||||
deviceRef: string;
|
||||
deviceName: string;
|
||||
commandKey: string;
|
||||
commandCatalogRef: string;
|
||||
commandType: string;
|
||||
riskClass: string;
|
||||
lifecycleState: string;
|
||||
plannedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
confirmedAt: string | null;
|
||||
dispatchedAt: string | null;
|
||||
acknowledgedAt: string | null;
|
||||
terminalAt: string | null;
|
||||
terminalReasonCode: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface AuditEventView {
|
||||
auditEventRef: string;
|
||||
eventType: string;
|
||||
actorRef: string;
|
||||
deviceRef: string | null;
|
||||
discoveryRef: string | null;
|
||||
occurredAt: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectGrantView {
|
||||
grantRef: string;
|
||||
principalKind: "user" | "group";
|
||||
principalRef: string;
|
||||
projectRole: string;
|
||||
capabilityAllow: string[];
|
||||
capabilityDeny: string[];
|
||||
lifecycleState: string;
|
||||
}
|
||||
|
||||
export interface ProjectWorkspace {
|
||||
project: ProjectSummary;
|
||||
devices: DeviceView[];
|
||||
collections: CollectionView[];
|
||||
discoveries: DiscoveryView[];
|
||||
enrollments: EnrollmentView[];
|
||||
adapterPackages: AdapterPackageView[];
|
||||
adapterVersions: AdapterVersionView[];
|
||||
modelProfiles: ModelProfileView[];
|
||||
edges: EdgeView[];
|
||||
routes: RouteView[];
|
||||
sessions: SessionView[];
|
||||
bindings: BindingView[];
|
||||
configurationRevisions: ConfigurationRevisionView[];
|
||||
configurationStates: ConfigurationStateView[];
|
||||
commands: CommandView[];
|
||||
auditEvents: AuditEventView[];
|
||||
grants: ProjectGrantView[];
|
||||
policies: {
|
||||
commandTransport: "disabled" | "typed-service-ping-v1";
|
||||
commandPlanningApi: "disabled" | "enabled";
|
||||
identifierProjection: string;
|
||||
auditPayloadProjection: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: { sourcemap: true },
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.device-control-core-release.v1",
|
||||
"releaseId": "__PATCH_ID__",
|
||||
"action": "upgrade",
|
||||
"predecessor": {
|
||||
"kind": "edge-core-channel-upgrade-v4",
|
||||
"patchId": "device-edge-core-channel-upgrade-v4-20260812-023",
|
||||
"artifactSha256": "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|
||||
},
|
||||
"service": "device-control-core",
|
||||
"composeActivation": "preserve-active-v4-topology",
|
||||
"identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export",
|
||||
"identityRecovery": "forbidden-valid-existing-identity-required",
|
||||
"tlsPurpose": "clientAuth",
|
||||
"direction": "core-initiated",
|
||||
"endpointPolicy": "public-ipv4-standard-https-tcp-443-only",
|
||||
"coreNetworks": [
|
||||
"device-plane-private",
|
||||
"device-plane-egress"
|
||||
],
|
||||
"publicIngress": "none-on-synology",
|
||||
"edgeRegistrations": "preserved",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"preservedServices": [
|
||||
"device-manager",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target"
|
||||
],
|
||||
"healthGate": "bounded-container-grace+core-edge-contract+exact-private-egress-network-boundary",
|
||||
"rollback": "restore-preapply-source-and-core-runtime"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.device-control-core-release.v2",
|
||||
"releaseId": "__PATCH_ID__",
|
||||
"action": "upgrade",
|
||||
"predecessor": {
|
||||
"kind": "release",
|
||||
"patchId": "device-control-core-release-20260812-024",
|
||||
"artifactSha256": "a289e909283109642e6bba3d9822a31f63423cfe0bbcd52705979681bd2bc793"
|
||||
},
|
||||
"service": "device-control-core",
|
||||
"composeActivation": "preserve-active-v4-topology",
|
||||
"identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export",
|
||||
"identityRecovery": "forbidden-valid-existing-identity-required",
|
||||
"tlsPurpose": "clientAuth",
|
||||
"direction": "core-initiated",
|
||||
"endpointPolicy": "public-ipv4-standard-https-tcp-443-only",
|
||||
"coreNetworks": [
|
||||
"device-plane-private",
|
||||
"device-plane-egress"
|
||||
],
|
||||
"publicIngress": "none-on-synology",
|
||||
"edgeRegistrations": "preserved",
|
||||
"commandTransport": "typed-service-ping-v1",
|
||||
"commandCatalog": "allowlisted-adapter-typed-commands-only",
|
||||
"credentialBoundary": "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned",
|
||||
"gelios": "untouched-legacy-only",
|
||||
"preservedServices": [
|
||||
"device-manager",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target"
|
||||
],
|
||||
"healthGate": "bounded-container-grace+core-edge-contract+exact-private-egress-network-boundary",
|
||||
"rollback": "restore-preapply-source-and-core-runtime"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge.admission-gate.v1",
|
||||
"mode": "single-nic-ipvlan-b2-relay-only",
|
||||
"runtimeHost": "ndcmini12",
|
||||
"component": "device-edge",
|
||||
"selectedServices": [
|
||||
"device-edge-relay"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-edge-backhaul",
|
||||
"tailnet"
|
||||
],
|
||||
"composeProject": "nodedc-device-edge",
|
||||
"composeFiles": [
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml"
|
||||
],
|
||||
"parentInterface": "enp1s0f0",
|
||||
"lanSubnet": "192.168.68.0/22",
|
||||
"lanGateway": "192.168.68.1",
|
||||
"ingressIpv4": "192.168.71.253",
|
||||
"ingressIpv4Approval": "approved-outside-dhcp-pool",
|
||||
"ingressNetwork": "nodedc-device-edge-ingress",
|
||||
"deviceTcpListen": "192.168.71.253:9921",
|
||||
"hostPortPublication": "disabled",
|
||||
"healthPublication": "disabled",
|
||||
"privateUpstream": "device-edge-backhaul:19921",
|
||||
"sourceAdmission": "public-ipv4-only",
|
||||
"maxTrackedSourceAddresses": 2048,
|
||||
"maxBytesPerDirection": 262144,
|
||||
"protocolInspection": "gateway-owned",
|
||||
"identityTrust": "claimed-not-ownership-proof",
|
||||
"discoveryLifecycle": "quarantine",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"amneziaHostFullTunnel": "preserved",
|
||||
"routerNatFirewall": "separate-manual-gate",
|
||||
"rollback": "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge.backhaul.v1",
|
||||
"mode": "private-tailnet-ssh-local-forward",
|
||||
"runtimeHost": "ndcmini12",
|
||||
"selectedServices": [
|
||||
"device-edge-backhaul"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-edge-relay",
|
||||
"tailnet"
|
||||
],
|
||||
"tailnetSocksTarget": "nodedc-device-edge-tailnet-1:1055",
|
||||
"sshTarget": "100.109.216.21:2222",
|
||||
"sshUser": "device-backhaul",
|
||||
"localForward": "0.0.0.0:19921",
|
||||
"permittedRemoteTarget": "127.0.0.1:9921",
|
||||
"hostPortPublication": "disabled",
|
||||
"deviceIngress": "disabled",
|
||||
"protocolInspection": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"privateKey": "runtime-only-read-only",
|
||||
"knownHosts": "runner-prepared-exact-ed25519",
|
||||
"routerNatFirewall": "unchanged",
|
||||
"gelios": "untouched"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.device-edge-core-channel-bootstrap.v1",
|
||||
"transitionId": "__PATCH_ID__",
|
||||
"action": "activate",
|
||||
"managerPredecessor": {
|
||||
"patchId": "device-manager-release-20260811-010",
|
||||
"artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e"
|
||||
},
|
||||
"failedPredecessor": {
|
||||
"patchId": "device-manager-release-20260811-016",
|
||||
"artifactSha256": "590405821b95b54088f926e0d3b2cdf9c704b339f6749da500c2bb64fe0e952d",
|
||||
"backupId": "device-plane-device-manager-release-20260811-016-20260811-215941",
|
||||
"invalidCoreCertificateSha256Fingerprint": "56:16:E0:3A:F4:03:85:FD:42:86:85:AF:2A:AF:1E:90:16:C8:F7:91:7C:AD:02:7D:B7:C2:ED:07:06:56:81:F6"
|
||||
},
|
||||
"service": "device-control-core",
|
||||
"composeActivation": "dedicated-additive-override",
|
||||
"identity": "runner-managed-host-local-private-key-public-certificate-export",
|
||||
"identityRecovery": "exact-invalid-unexported-failed-predecessor-only",
|
||||
"tlsPurpose": "clientAuth",
|
||||
"direction": "core-initiated",
|
||||
"publicIngress": "none-on-synology",
|
||||
"edgeRegistrations": "preserved",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"preservedServices": [
|
||||
"device-manager",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target"
|
||||
],
|
||||
"healthGate": "bounded-container-grace+core-edge-contract",
|
||||
"rollback": "restore-source-and-preapply-core-runtime"
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge.core-channel-source.v1",
|
||||
"status": "source-accepted",
|
||||
"authority": "DCPLATFORM-76/ADR-0001",
|
||||
"designContract": "nodedc.device-edge.core-channel.v1",
|
||||
"implementedAt": "2026-08-11",
|
||||
"transport": {
|
||||
"initiator": "device-gateway-core",
|
||||
"listener": "device-edge-channel",
|
||||
"protocol": "http2-bidirectional-ndjson",
|
||||
"tls": "TLSv1.3-mutual-authentication",
|
||||
"bearerAuthentication": false,
|
||||
"genericTcpForwarding": false
|
||||
},
|
||||
"sourceComponents": [
|
||||
"packages/device-edge-channel-contract",
|
||||
"services/device-edge-channel",
|
||||
"services/device-gateway-core"
|
||||
],
|
||||
"identity": {
|
||||
"edgeRegistrationRequired": true,
|
||||
"edgeCertificatePinRequired": true,
|
||||
"coreCertificateAllowlistRequired": true,
|
||||
"unknownOrRevokedIdentity": "reject",
|
||||
"rotation": "one-active-plus-one-staged-generation",
|
||||
"retiredFingerprint": "reject",
|
||||
"privateKeysInSource": false,
|
||||
"privateKeysInArtifact": false
|
||||
},
|
||||
"messageBoundary": {
|
||||
"schema": "nodedc.device-edge.channel-envelope.v1",
|
||||
"maximumEnvelopeBytes": 1048576,
|
||||
"directionLocalSequence": true,
|
||||
"unknownSchemaOrKind": "close-logical-session",
|
||||
"acceptanceWindow": 128,
|
||||
"trackerAckRule": "core-acceptance-required"
|
||||
},
|
||||
"realtime": {
|
||||
"polling": false,
|
||||
"keepaliveSeconds": 15,
|
||||
"deadPeerSeconds": 45,
|
||||
"reconnectMinimumSeconds": 1,
|
||||
"reconnectMaximumSeconds": 30,
|
||||
"delivery": "at-least-once-with-core-idempotency"
|
||||
},
|
||||
"sourceAcceptance": {
|
||||
"devicePlaneTestsPassed": 193,
|
||||
"tls13MutualAuthenticationTested": true,
|
||||
"keepaliveTested": true,
|
||||
"disconnectReconnectTested": true,
|
||||
"certificateRotationOverlapAndRetirementTested": true,
|
||||
"idempotentReplayTested": true,
|
||||
"unknownAndRevokedIdentityTested": true,
|
||||
"oversizedEnvelopeTested": true,
|
||||
"coreUnavailableRejectionTested": true,
|
||||
"crossSessionProgressAndPerSessionOrderingTested": true,
|
||||
"boundedAcceptanceWindowTested": true,
|
||||
"existingCoreImageBuild": "passed-no-cache",
|
||||
"existingGatewayImageBuild": "passed-no-cache"
|
||||
},
|
||||
"runtime": {
|
||||
"mutationInThisTransition": false,
|
||||
"edgePort8443Published": false,
|
||||
"trackerPort9921Published": false,
|
||||
"synologyPublicIngress": false,
|
||||
"trackerIngress": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched"
|
||||
},
|
||||
"nextGate": "closed-port-synthetic-runtime-transition-under-DCPLATFORM-21",
|
||||
"rollback": "source-revert-only-runtime-unchanged"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.device-edge-core-channel-upgrade.v1",
|
||||
"transitionId": "__PATCH_ID__",
|
||||
"action": "upgrade",
|
||||
"bootstrapPredecessor": {
|
||||
"patchId": "device-edge-core-channel-bootstrap-20260812-018",
|
||||
"artifactSha256": "5598b7388b491fe524ab46038ce476482a93a6cf07d8ca5e00206c69ded02931"
|
||||
},
|
||||
"service": "device-control-core",
|
||||
"composeActivation": "preserve-dedicated-additive-override",
|
||||
"identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export",
|
||||
"identityRecovery": "forbidden-valid-existing-identity-required",
|
||||
"tlsPurpose": "clientAuth",
|
||||
"direction": "core-initiated",
|
||||
"endpointPolicy": "public-ipv4-standard-https-tcp-443-only",
|
||||
"publicIngress": "none-on-synology",
|
||||
"edgeRegistrations": "preserved-requires-explicit-443-reconciliation",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"preservedServices": [
|
||||
"device-manager",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target"
|
||||
],
|
||||
"healthGate": "bounded-container-grace+core-edge-contract",
|
||||
"rollback": "restore-bootstrap-018-source-and-preapply-core-runtime"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.device-edge-core-channel-upgrade.v2",
|
||||
"transitionId": "__PATCH_ID__",
|
||||
"action": "upgrade",
|
||||
"upgradePredecessor": {
|
||||
"patchId": "device-edge-core-channel-upgrade-20260812-019",
|
||||
"artifactSha256": "8e9a220275959f378c1c4b00be5c7192e79afe2134eaab808a64e515870a8438"
|
||||
},
|
||||
"service": "device-control-core",
|
||||
"composeActivation": "preserve-dedicated-additive-override",
|
||||
"identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export",
|
||||
"identityRecovery": "forbidden-valid-existing-identity-required",
|
||||
"tlsPurpose": "clientAuth",
|
||||
"direction": "core-initiated",
|
||||
"endpointPolicy": "public-ipv4-standard-https-tcp-443-only",
|
||||
"publicIngress": "none-on-synology",
|
||||
"edgeRegistrations": "preserved",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"preservedServices": [
|
||||
"device-manager",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target"
|
||||
],
|
||||
"healthGate": "bounded-container-grace+core-edge-contract",
|
||||
"rollback": "restore-upgrade-019-source-and-preapply-core-runtime"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.device-edge-core-channel-upgrade.v4",
|
||||
"transitionId": "__PATCH_ID__",
|
||||
"action": "upgrade",
|
||||
"upgradePredecessor": {
|
||||
"patchId": "device-edge-core-channel-upgrade-v2-20260812-021",
|
||||
"artifactSha256": "e40a6fd24edfecac09e42cd82635a77850541bcf047788db3e9c55d2b9e58867"
|
||||
},
|
||||
"failedAttempt": {
|
||||
"patchId": "device-edge-core-channel-upgrade-v3-20260812-022",
|
||||
"artifactSha256": "9e2b409a4b2d19711db434e90d03ac8e3db77bd74949f83cace7949f33caf613",
|
||||
"backupId": "device-plane-device-edge-core-channel-upgrade-v3-20260812-022-20260812-123620"
|
||||
},
|
||||
"service": "device-control-core",
|
||||
"composeActivation": "replace-core-network-membership-with-private-plus-egress",
|
||||
"identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export",
|
||||
"identityRecovery": "forbidden-valid-existing-identity-required",
|
||||
"tlsPurpose": "clientAuth",
|
||||
"direction": "core-initiated",
|
||||
"endpointPolicy": "public-ipv4-standard-https-tcp-443-only",
|
||||
"coreNetworks": [
|
||||
"device-plane-private",
|
||||
"device-plane-egress"
|
||||
],
|
||||
"removedCoreNetwork": "device-plane-control",
|
||||
"composeCompatibility": "synology-compose-v2.20-no-gw-priority",
|
||||
"publicIngress": "none-on-synology",
|
||||
"edgeRegistrations": "preserved",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"preservedServices": [
|
||||
"device-manager",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target"
|
||||
],
|
||||
"healthGate": "bounded-container-grace+core-edge-contract+exact-private-egress-network-boundary",
|
||||
"rollback": "restore-upgrade-v2-021-source-and-preapply-core-runtime"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge.core-channel.v1",
|
||||
"status": "accepted-design",
|
||||
"authority": "DCPLATFORM-76/ADR-0001",
|
||||
"direction": "device-gateway-core-initiated",
|
||||
"transport": {
|
||||
"protocol": "http2-bidirectional-stream",
|
||||
"tls": "TLSv1.3-mutual-authentication",
|
||||
"edgeListen": "0.0.0.0:443",
|
||||
"endpointSource": "device-control-core.edge-registration",
|
||||
"browserAccess": "forbidden",
|
||||
"bearerOnlyAuthentication": "forbidden",
|
||||
"genericTcpForwarding": "forbidden"
|
||||
},
|
||||
"identity": {
|
||||
"corePrivateKeyLocation": "synology-canonical-secret-boundary",
|
||||
"edgePrivateKeyLocation": "edge-runner-managed-trust-boundary",
|
||||
"privateKeysInArtifacts": false,
|
||||
"certificateRotation": "generation-bound-audited",
|
||||
"unknownOrRevokedEdge": "reject"
|
||||
},
|
||||
"networkBoundary": {
|
||||
"synologyPublicIngress": false,
|
||||
"synologyPortForward": false,
|
||||
"vpsInitiatedSynologyConnection": false,
|
||||
"subnetRoutes": false,
|
||||
"exitNode": false,
|
||||
"tailscaleSsh": false,
|
||||
"dockerSocket": false,
|
||||
"allowedEdgeListeners": [
|
||||
"management-ssh",
|
||||
"raw-device-tcp/9921",
|
||||
"core-channel-mtls/443"
|
||||
]
|
||||
},
|
||||
"messageContract": {
|
||||
"versioned": true,
|
||||
"bounded": true,
|
||||
"requiredKeys": [
|
||||
"schemaVersion",
|
||||
"edgeRegistrationId",
|
||||
"channelGeneration",
|
||||
"trackerSessionId",
|
||||
"adapterProfileRef",
|
||||
"sequence",
|
||||
"eventAt",
|
||||
"receivedAt",
|
||||
"messageKind",
|
||||
"correlationId"
|
||||
],
|
||||
"unknownKind": "close-logical-session",
|
||||
"rawArbitraryDestination": "forbidden"
|
||||
},
|
||||
"acknowledgement": {
|
||||
"trackerPackageAck": "only-after-bounded-core-acceptance",
|
||||
"coreUnavailable": "do-not-acknowledge-tracker-package",
|
||||
"deduplicationKey": [
|
||||
"edgeRegistrationId",
|
||||
"channelGeneration",
|
||||
"trackerSessionId",
|
||||
"packageNumber",
|
||||
"contentDigest"
|
||||
],
|
||||
"deliverySemantics": "at-least-once"
|
||||
},
|
||||
"pilotLimits": {
|
||||
"maxTrackerSessions": 128,
|
||||
"maxSessionsPerObservedSource": 16,
|
||||
"maxNewConnectionsPerMinutePerObservedSource": 60,
|
||||
"maxBufferedBytesPerTrackerSession": 262144,
|
||||
"maxAggregateBufferedBytes": 33554432,
|
||||
"maxEnvelopePayloadBytes": 1048576,
|
||||
"keepaliveSeconds": 15,
|
||||
"deadPeerSeconds": 45,
|
||||
"reconnectMinimumSeconds": 1,
|
||||
"reconnectMaximumSeconds": 30,
|
||||
"durableEdgeSpool": false
|
||||
},
|
||||
"pilotSlo": {
|
||||
"trackerAckBeforeDurableCoreAcceptance": 0,
|
||||
"lossOfCoreAcceptedPackages": 0,
|
||||
"edgeReceiveToCoreAcceptanceP95Milliseconds": 2000,
|
||||
"edgeReceiveToCoreAcceptanceP99Milliseconds": 5000,
|
||||
"channelReestablishmentP95Seconds": 60,
|
||||
"channelReestablishmentHardCeilingSeconds": 120,
|
||||
"deadCoreDetectionHardCeilingSeconds": 45,
|
||||
"malformedOrUnauthenticatedAcceptedRecords": 0,
|
||||
"availabilityCommitment": "deferred-until-measured"
|
||||
},
|
||||
"commandBoundary": {
|
||||
"typedOnly": true,
|
||||
"rawPayload": "forbidden",
|
||||
"durableEdgeQueue": false,
|
||||
"sentEqualsSuccess": false,
|
||||
"protocolAckMeans": "acknowledged-not-verified",
|
||||
"unsafeAutomaticRetry": "forbidden"
|
||||
},
|
||||
"preserved": [
|
||||
"device-control-core-database",
|
||||
"device-gateway-core",
|
||||
"hub-authentik",
|
||||
"engine",
|
||||
"foundry-runtime",
|
||||
"gelios-production-path"
|
||||
],
|
||||
"rollout": [
|
||||
"source-and-ops-contract",
|
||||
"closed-port-synthetic-core-channel",
|
||||
"negative-network-and-identity-acceptance",
|
||||
"separate-public-device-ingress-transition",
|
||||
"one-device-pilot"
|
||||
],
|
||||
"rollback": "restore-closed-port-predecessor-without-vps-initiated-backhaul"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge.ingress-ipvlan.v1",
|
||||
"mode": "single-nic-ipvlan-b2-relay-only",
|
||||
"runtimeHost": "ndcmini12",
|
||||
"component": "device-edge",
|
||||
"selectedServices": [
|
||||
"device-edge-relay"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-edge-backhaul",
|
||||
"tailnet"
|
||||
],
|
||||
"composeProject": "nodedc-device-edge",
|
||||
"composeFiles": [
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml"
|
||||
],
|
||||
"parentInterface": "enp1s0f0",
|
||||
"lanSubnet": "192.168.68.0/22",
|
||||
"lanGateway": "192.168.68.1",
|
||||
"ingressIpv4": "192.168.71.253",
|
||||
"ingressIpv4Approval": "approved-outside-dhcp-pool",
|
||||
"ingressNetwork": "nodedc-device-edge-ingress",
|
||||
"deviceTcpListen": "192.168.71.253:9921",
|
||||
"hostPortPublication": "disabled",
|
||||
"healthPublication": "disabled",
|
||||
"privateUpstream": "device-edge-backhaul:19921",
|
||||
"protocolInspection": "gateway-owned",
|
||||
"identityTrust": "claimed-not-ownership-proof",
|
||||
"discoveryLifecycle": "quarantine",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"amneziaHostFullTunnel": "preserved",
|
||||
"routerNatFirewall": "separate-manual-gate",
|
||||
"rollback": "restore-predecessor-relay-remove-unused-ingress-network"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge-vps.backhaul.v1",
|
||||
"mode": "tailscale-userspace-key-only-ssh-local-forward",
|
||||
"runtimeHost": "koffyvngij",
|
||||
"component": "device-edge-vps",
|
||||
"tailscaleNodeName": "nodedc-b2-vps",
|
||||
"tailnetDnsSuffix": "tail8d32ac.ts.net",
|
||||
"targetHost": "100.109.216.21",
|
||||
"targetPort": 2222,
|
||||
"targetHostKeyFingerprint": "SHA256:QERJ5CIUXRj0nLChGT6HMtoX+WTaeaEY5ZgaWqT8d30",
|
||||
"targetUser": "device-backhaul",
|
||||
"runtimeUser": "nodedc-backhaul",
|
||||
"credentialBoundary": "private-key-readable-only-by-nodedc-backhaul",
|
||||
"permitOpen": "127.0.0.1:9921",
|
||||
"localForward": "127.0.0.1:19921",
|
||||
"proxy": "tailscale-userspace-socks5-127.0.0.1:1055",
|
||||
"keyIdentity": "nodedc-device-edge-vps-backhaul",
|
||||
"publicB2Ingress": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"rollback": "remove-backhaul-unit-and-user-restore-foundation-key-ownership"
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge-vps.command-transport.v1",
|
||||
"mode": "provider-neutral-typed-command-transport-over-accepted-core-channel",
|
||||
"status": "active-typed-command-transport",
|
||||
"authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001",
|
||||
"component": "device-edge-vps",
|
||||
"phase": "command-transport",
|
||||
"runtimeHost": "koffyvngij",
|
||||
"predecessorPatch": "device-edge-vps-tracker-ingress-20260812-012",
|
||||
"predecessorArtifactSha256": "290acef118839c6b0c31aac864c47da1832a289537366af9322d4624a1dd81ec",
|
||||
"runtimeUser": "nodedc-channel",
|
||||
"runtimeService": "nodedc-device-edge-channel.service",
|
||||
"runtimeComposition": "single-process-core-channel-plus-universal-device-gateway",
|
||||
"publicIngress": "tcp/443-mtls-core-channel+tcp/9921-bidirectional-tracker-session",
|
||||
"trackerIngress": "preserved:allowlisted-adapters-only",
|
||||
"initialAdapterProfile": "arusnavi.b2.internal.v1",
|
||||
"commandTransport": "typed-service-ping-v1",
|
||||
"commandCatalog": "allowlisted-adapter-typed-commands-only",
|
||||
"allowedCommands": [
|
||||
"service.ping"
|
||||
],
|
||||
"credentialBoundary": "transient-over-pinned-mtls-in-memory-until-single-tracker-write-never-stored-never-logged-never-returned",
|
||||
"responseBoundary": "exact-adapter-parser-serv-ok-only",
|
||||
"health": "127.0.0.1:18222",
|
||||
"adapterHealth": "127.0.0.1:18221",
|
||||
"rawDeviceTcp9921": "public-bidirectional-tracker-session-no-generic-forwarding",
|
||||
"gelios": "untouched-legacy-only",
|
||||
"tailscale": "absent",
|
||||
"dataBoundary": "no-vps-database-no-business-logic-no-synology-route",
|
||||
"resourceCeilings": {
|
||||
"memory": "192M",
|
||||
"swap": "0",
|
||||
"cpu": "75%",
|
||||
"tasks": 128,
|
||||
"openFiles": 1024,
|
||||
"sessions": 128,
|
||||
"sessionsPerAddress": 16,
|
||||
"connectionsPerMinutePerAddress": 60,
|
||||
"sessionBufferBytes": 65536,
|
||||
"aggregateBufferBytes": 33554432
|
||||
},
|
||||
"preserved": [
|
||||
"management-ssh-key",
|
||||
"accepted-node-runtime",
|
||||
"accepted-core-channel-trust-and-registration",
|
||||
"accepted-tracker-ingress",
|
||||
"retired-tailnet-boundary",
|
||||
"gelios-production-path"
|
||||
],
|
||||
"forbidden": [
|
||||
"vps-initiated-synology-connection",
|
||||
"generic-tcp-forwarding",
|
||||
"tailscale-runtime",
|
||||
"docker",
|
||||
"public-health",
|
||||
"vps-database",
|
||||
"vps-business-logic",
|
||||
"unregistered-adapter",
|
||||
"raw-command",
|
||||
"firmware-command",
|
||||
"reboot-command",
|
||||
"persistent-command-credential"
|
||||
],
|
||||
"acceptance": [
|
||||
"exact-tracker-ingress-012-predecessor",
|
||||
"single-non-root-edge-process",
|
||||
"core-channel-remains-accepted",
|
||||
"public-tracker-tcp-9921-listening",
|
||||
"adapter-profile-allowlisted",
|
||||
"typed-service-ping-only",
|
||||
"exact-serv-ok-response-parser",
|
||||
"bounded-session-and-buffer-limits",
|
||||
"tailscale-remains-absent",
|
||||
"no-vps-to-synology-route",
|
||||
"gelios-untouched"
|
||||
],
|
||||
"rollback": "restore-exact-tracker-ingress-012-source-and-existing-runtime"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge-vps.core-channel.v1",
|
||||
"mode": "provider-neutral-core-initiated-mtls-http2",
|
||||
"status": "closed-tracker-ingress",
|
||||
"authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001",
|
||||
"component": "device-edge-vps",
|
||||
"phase": "core-channel",
|
||||
"runtimeHost": "koffyvngij",
|
||||
"runtimeUser": "nodedc-channel",
|
||||
"runtimeService": "nodedc-device-edge-channel.service",
|
||||
"runtime": "accepted-node-v22.23.2-no-docker",
|
||||
"publicIngress": "tcp/443-mtls-only",
|
||||
"health": "127.0.0.1:18222",
|
||||
"trackerIngress": "disabled",
|
||||
"rawDeviceTcp9921": "closed",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"privateKeyBoundary": "runner-managed-host-local-only",
|
||||
"peerTrustPrerequisite": "exact-pinned-self-signed-core-certificate-and-fingerprint",
|
||||
"tls": "TLSv1.3+h2+mutual-authentication",
|
||||
"networkPrivilege": "CAP_NET_BIND_SERVICE-only-for-non-root-tcp-443",
|
||||
"resourceCeilings": {
|
||||
"memory": "128M",
|
||||
"swap": "0",
|
||||
"cpu": "50%",
|
||||
"tasks": 64,
|
||||
"openFiles": 1024
|
||||
},
|
||||
"preserved": [
|
||||
"management-ssh-key",
|
||||
"accepted-node-runtime",
|
||||
"foundation-source",
|
||||
"gelios-production-path"
|
||||
],
|
||||
"forbidden": [
|
||||
"vps-initiated-synology-connection",
|
||||
"generic-tcp-forwarding",
|
||||
"tailscale-ssh-backhaul",
|
||||
"docker",
|
||||
"public-health",
|
||||
"tracker-tcp/9921"
|
||||
],
|
||||
"acceptance": [
|
||||
"exact-non-root-runtime-identity",
|
||||
"tls13-h2-mutual-authentication",
|
||||
"edge-server-and-core-client-self-signed-identities-mutually-pinned",
|
||||
"core-initiated-channel-accepted",
|
||||
"unknown-core-certificate-rejected",
|
||||
"public-443-only-beside-management-ssh",
|
||||
"tracker-tcp-9921-closed",
|
||||
"loopback-health-contract",
|
||||
"resource-ceilings-present"
|
||||
],
|
||||
"rollback": "close-443-stop-channel-restore-exact-accepted-foundation-without-backhaul-or-relay"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge-vps.foundation.v1",
|
||||
"mode": "static-runtime-key-only-ssh-default-deny-no-public-b2",
|
||||
"runtimeHost": "koffyvngij",
|
||||
"publicIpv4": "155.212.211.15",
|
||||
"component": "device-edge-vps",
|
||||
"nodeVersion": "22.23.2",
|
||||
"nodeArchiveSha256": "d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307",
|
||||
"tailscaleVersion": "1.102.2",
|
||||
"tailscaleArchiveSha256": "ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8",
|
||||
"serviceUser": "nodedc-edge",
|
||||
"managementSsh": "root-key-only",
|
||||
"managementKeyFingerprint": "SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw",
|
||||
"serverHostKeyFingerprint": "SHA256:mhqNn2S6zstkYL7VFdvt3SYHv1nLjB4J7/s57RrKG6w",
|
||||
"firewall": "default-deny-public-22-only",
|
||||
"tailscale": "userspace-needs-external-enrollment",
|
||||
"backhaulKey": "runner-managed-new-ed25519",
|
||||
"publicB2Ingress": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"rollback": "restore-exact-ssh-firewall-service-and-absent-runtime-predecessor"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge-vps.relay.v1",
|
||||
"mode": "public-b2-opaque-bounded-relay",
|
||||
"runtimeHost": "koffyvngij",
|
||||
"publicIpv4": "155.212.211.15",
|
||||
"component": "device-edge-vps",
|
||||
"runtimeUser": "nodedc-relay",
|
||||
"credentialAccess": "none",
|
||||
"listen": "0.0.0.0:9921",
|
||||
"health": "127.0.0.1:18221",
|
||||
"privateUpstream": "127.0.0.1:19921",
|
||||
"sourceAdmission": "public-ipv4-only",
|
||||
"maxSessions": 128,
|
||||
"maxSessionsPerAddress": 16,
|
||||
"maxConnectionsPerMinutePerAddress": 60,
|
||||
"maxTrackedSourceAddresses": 4096,
|
||||
"maxBytesPerDirection": 67108864,
|
||||
"sessionTimeoutMs": 300000,
|
||||
"protocolInspection": "gateway-owned",
|
||||
"identityTrust": "claimed-not-ownership-proof",
|
||||
"discoveryLifecycle": "quarantine",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"dns": "unchanged",
|
||||
"b2Routes": "unchanged",
|
||||
"rollback": "close-9921-stop-relay-remove-user-and-restore-accepted-backhaul"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge-vps.runtime-reconciliation.v1",
|
||||
"component": "device-edge-vps",
|
||||
"runtimeHost": "koffyvngij",
|
||||
"publicIpv4": "155.212.211.15",
|
||||
"transition": "recover-exact-runtime-executable-modes-after-failed-core-channel-publish",
|
||||
"acceptedFoundationPatch": "device-edge-vps-foundation-20260806-003",
|
||||
"acceptedFoundationArtifactSha256": "1be852f144e9f0fea32af70bebd07a2607b6a1818825094bd4c1b4062064716a",
|
||||
"failedPatch": "device-edge-vps-core-channel-20260812-001",
|
||||
"failedArtifactSha256": "c199980e5754cf3e874a09f42e408fc88885cdbf7c872eac36c0ab768a7bab00",
|
||||
"runtimeMutation": "restore-root-owned-executable-mode-0755-for-exact-known-binaries",
|
||||
"publicCoreChannel": "disabled",
|
||||
"trackerIngress": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"rollback": "restore-exact-runtime-files-modes-and-reconciliation-marker"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge-vps.tailscale-retirement.v1",
|
||||
"mode": "retire-superseded-vps-tailnet-after-accepted-core-channel",
|
||||
"status": "core-channel-only",
|
||||
"authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001",
|
||||
"component": "device-edge-vps",
|
||||
"phase": "tailscale-retirement",
|
||||
"runtimeHost": "koffyvngij",
|
||||
"predecessorPatch": "device-edge-vps-core-channel-20260812-010",
|
||||
"predecessorArtifactSha256": "c8ef3c4bb45850cad32e881eba081bc4c891c2886e5500d02cb94616d82353f3",
|
||||
"runtimeAction": "stop-disable-remove-userspace-tailscale-runtime-state-and-superseded-trust",
|
||||
"publicIngress": "tcp/443-mtls-only",
|
||||
"trackerIngress": "disabled",
|
||||
"rawDeviceTcp9921": "closed",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"preserved": [
|
||||
"management-ssh-key",
|
||||
"accepted-node-runtime",
|
||||
"accepted-core-channel-source-runtime-and-trust",
|
||||
"core-channel-registration",
|
||||
"foundation-source-for-audit-and-rollback",
|
||||
"gelios-production-path"
|
||||
],
|
||||
"retired": [
|
||||
"nodedc-b2-tailscaled.service",
|
||||
"userspace-socks5-127.0.0.1:1055",
|
||||
"tailscale-local-state",
|
||||
"tailscale-runtime-binaries",
|
||||
"superseded-backhaul-private-key"
|
||||
],
|
||||
"forbidden": [
|
||||
"tailscale-runtime",
|
||||
"tailnet-address",
|
||||
"vps-initiated-synology-connection",
|
||||
"generic-tcp-forwarding",
|
||||
"tailscale-ssh-backhaul",
|
||||
"docker",
|
||||
"public-health",
|
||||
"tracker-tcp/9921"
|
||||
],
|
||||
"acceptance": [
|
||||
"exact-core-channel-010-predecessor",
|
||||
"core-channel-remains-accepted",
|
||||
"tailscale-service-absent-inactive-and-disabled",
|
||||
"tailscale-userspace-listeners-absent",
|
||||
"tailscale-local-state-and-runtime-binaries-absent",
|
||||
"superseded-backhaul-private-key-absent",
|
||||
"public-443-only-beside-management-ssh",
|
||||
"tracker-tcp-9921-closed",
|
||||
"command-transport-disabled"
|
||||
],
|
||||
"externalRevocation": "delete-exact-nodedc-b2-vps-machine-in-tailnet-after-deploy-ok",
|
||||
"rollback": "before-external-tailnet-revocation-restore-backed-up-local-tailscale-runtime-state-unit-and-core-channel-predecessor"
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge-vps.tracker-ingress.v1",
|
||||
"mode": "provider-neutral-allowlisted-adapter-ingress-over-accepted-core-channel",
|
||||
"status": "active-tracker-ingress",
|
||||
"authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001",
|
||||
"component": "device-edge-vps",
|
||||
"phase": "tracker-ingress",
|
||||
"runtimeHost": "koffyvngij",
|
||||
"predecessorPatch": "device-edge-vps-tailscale-retirement-20260812-011",
|
||||
"predecessorArtifactSha256": "e7b61ec9c83122fa5631467010eff871b98935746df6a1326b6ff6bb9713d877",
|
||||
"runtimeUser": "nodedc-channel",
|
||||
"runtimeService": "nodedc-device-edge-channel.service",
|
||||
"runtimeComposition": "single-process-core-channel-plus-universal-device-gateway",
|
||||
"publicIngress": "tcp/443-mtls-core-channel+tcp/9921-tracker-telemetry",
|
||||
"trackerIngress": "enabled:allowlisted-adapters-only",
|
||||
"initialAdapterProfile": "arusnavi.b2.internal.v1",
|
||||
"acknowledgementBoundary": "tracker-ack-only-after-core-durable-acceptance",
|
||||
"health": "127.0.0.1:18222",
|
||||
"adapterHealth": "127.0.0.1:18221",
|
||||
"rawDeviceTcp9921": "public-telemetry-ingest",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"tailscale": "absent",
|
||||
"dataBoundary": "no-vps-database-no-business-logic-no-synology-route",
|
||||
"resourceCeilings": {
|
||||
"memory": "192M",
|
||||
"swap": "0",
|
||||
"cpu": "75%",
|
||||
"tasks": 128,
|
||||
"openFiles": 1024,
|
||||
"sessions": 128,
|
||||
"sessionsPerAddress": 16,
|
||||
"connectionsPerMinutePerAddress": 60,
|
||||
"sessionBufferBytes": 65536,
|
||||
"aggregateBufferBytes": 33554432
|
||||
},
|
||||
"preserved": [
|
||||
"management-ssh-key",
|
||||
"accepted-node-runtime",
|
||||
"accepted-core-channel-trust-and-registration",
|
||||
"retired-tailnet-boundary",
|
||||
"gelios-production-path"
|
||||
],
|
||||
"forbidden": [
|
||||
"vps-initiated-synology-connection",
|
||||
"generic-tcp-forwarding",
|
||||
"tailscale-runtime",
|
||||
"docker",
|
||||
"public-health",
|
||||
"vps-database",
|
||||
"vps-business-logic",
|
||||
"unregistered-adapter",
|
||||
"device-command"
|
||||
],
|
||||
"acceptance": [
|
||||
"exact-tailscale-retirement-011-predecessor",
|
||||
"single-non-root-edge-process",
|
||||
"core-channel-remains-accepted",
|
||||
"public-tracker-tcp-9921-listening",
|
||||
"adapter-profile-allowlisted",
|
||||
"bounded-session-and-buffer-limits",
|
||||
"tracker-ack-after-core-acceptance",
|
||||
"tailscale-remains-absent",
|
||||
"no-vps-to-synology-route",
|
||||
"command-transport-disabled",
|
||||
"gelios-untouched"
|
||||
],
|
||||
"rollback": "close-9921-restore-exact-tailscale-retirement-011-source-unit-firewall-and-accepted-core-channel-runtime"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.device-manager-control-plane-reconciliation.v1",
|
||||
"mode": "failed-control-plane-baseline-adoption",
|
||||
"failedPatchId": "device-manager-control-plane-20260810-001",
|
||||
"failedArtifactSha256": "50e275c1085286bcb3bb2b273aefc8bbba70f446ca2c7bd464dc745710a291a6",
|
||||
"backupId": "device-plane-device-manager-control-plane-20260810-001-20260811-000321",
|
||||
"sourceAction": "publish-reconciliation-marker-only",
|
||||
"runtimeAction": "read-only-acceptance",
|
||||
"preservedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target"
|
||||
],
|
||||
"absentService": "device-manager",
|
||||
"databaseVolume": "nodedc-device-plane-postgres-data",
|
||||
"publicIngress": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"rollback": "marker-only-runtime-unchanged"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.device-manager-control-plane.v1",
|
||||
"action": "activate",
|
||||
"service": "device-manager",
|
||||
"publicIngress": "reverse-proxy-only",
|
||||
"deviceCoreManagementApi": "file-token-authenticated",
|
||||
"launcherTrust": "file-token-scoped-to-device-core-handoff",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.device-manager-control-plane-v2-reconciliation.v1",
|
||||
"mode": "failed-v2-control-plane-baseline-adoption",
|
||||
"failedPatchId": "device-manager-control-plane-20260811-003",
|
||||
"failedArtifactSha256": "ba29618ffbfed55448768794f28b18dda439ddb39a1d2a4f1dece19de7f29990",
|
||||
"backupId": "device-plane-device-manager-control-plane-20260811-003-20260811-012505",
|
||||
"failureClass": "deterministic-runtime-module-resolution",
|
||||
"missingModule": "/packages/external-provider-contract/src/credential-reference.mjs",
|
||||
"correctiveAction": "runtime-local-contract-adapter+staged-module-import-gate",
|
||||
"sourceAction": "publish-reconciliation-marker-only",
|
||||
"runtimeAction": "read-only-acceptance",
|
||||
"preservedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target"
|
||||
],
|
||||
"absentService": "device-manager",
|
||||
"databaseVolume": "nodedc-device-plane-postgres-data",
|
||||
"publicIngress": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"rollback": "marker-only-runtime-unchanged"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.device-manager-control-plane.v2",
|
||||
"action": "activate",
|
||||
"predecessor": {
|
||||
"patchId": "device-manager-control-plane-reconciliation-20260811-002",
|
||||
"artifactSha256": "dd86dd58e4f649db0981db5089e003caf3961356179f2abb514662351487e1e6",
|
||||
"mode": "failed-control-plane-baseline-adoption"
|
||||
},
|
||||
"service": "device-manager",
|
||||
"publicIngress": "reverse-proxy-only",
|
||||
"deviceCoreManagementApi": "file-token-authenticated",
|
||||
"launcherTrust": "file-token-scoped-to-device-core-handoff",
|
||||
"healthGate": "bounded-container-grace+core-contract",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"rollback": "restore-reconciled-baseline"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.device-manager-release.v1",
|
||||
"releaseId": "__PATCH_ID__",
|
||||
"action": "upgrade",
|
||||
"predecessor": {
|
||||
"kind": "release",
|
||||
"patchId": "device-manager-release-20260811-010",
|
||||
"artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e"
|
||||
},
|
||||
"service": "device-manager",
|
||||
"publicIngress": "reverse-proxy-only",
|
||||
"deviceCoreManagementApi": "file-token-authenticated",
|
||||
"launcherTrust": "file-token-scoped-to-device-core-handoff",
|
||||
"healthGate": "bounded-container-grace+core-contract",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"rollback": "restore-preapply-snapshot"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.device-manager-release.v2",
|
||||
"releaseId": "__PATCH_ID__",
|
||||
"action": "upgrade",
|
||||
"predecessor": {
|
||||
"kind": "release",
|
||||
"patchId": "device-manager-release-20260811-010",
|
||||
"artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e"
|
||||
},
|
||||
"service": "device-manager",
|
||||
"publicIngress": "reverse-proxy-only",
|
||||
"deviceCoreManagementApi": "file-token-authenticated",
|
||||
"launcherTrust": "file-token-scoped-to-device-core-handoff",
|
||||
"edgeChannel": "core-initiated-pinned-mtls-enabled-zero-or-more-registered-edges",
|
||||
"edgeChannelIdentity": "runner-managed-host-local-private-key-public-certificate-export",
|
||||
"edgeChannelEgress": "dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-8443-registration-policy",
|
||||
"healthGate": "bounded-container-grace+core-contract",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"rollback": "restore-preapply-snapshot"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.device-manager-release.v3",
|
||||
"releaseId": "__PATCH_ID__",
|
||||
"action": "upgrade",
|
||||
"predecessor": {
|
||||
"kind": "release",
|
||||
"patchId": "device-manager-release-20260811-010",
|
||||
"artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e"
|
||||
},
|
||||
"controlCorePredecessor": {
|
||||
"patchId": "device-control-core-release-v2-20260812-025",
|
||||
"artifactSha256": "c61b1f0de1bae23de0caa7289036865ea419ff5705611416f736ca929d1592db"
|
||||
},
|
||||
"edgeChannelPredecessor": {
|
||||
"patchId": "device-edge-core-channel-upgrade-v4-20260812-023",
|
||||
"artifactSha256": "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|
||||
},
|
||||
"service": "device-manager",
|
||||
"publicIngress": "reverse-proxy-only",
|
||||
"deviceCoreManagementApi": "file-token-authenticated",
|
||||
"launcherTrust": "file-token-scoped-to-device-core-handoff",
|
||||
"edgeChannel": "preserve-active-v4-core-initiated-pinned-mtls",
|
||||
"edgeChannelIdentity": "reuse-runner-managed-host-local-private-key-public-certificate-export",
|
||||
"edgeChannelEgress": "preserve-dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-443-only",
|
||||
"healthGate": "bounded-container-grace+core-contract",
|
||||
"commandTransport": "typed-service-ping-v1",
|
||||
"commandCatalog": "allowlisted-adapter-typed-commands-only",
|
||||
"credentialBoundary": "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned",
|
||||
"gelios": "untouched-legacy-only",
|
||||
"rollback": "restore-preapply-snapshot"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.b2-discovery-ingress.v1",
|
||||
"mode": "verified-b2-loopback-discovery-only",
|
||||
"predecessorPatchId": "device-plane-foundation-network-publication-20260725-003",
|
||||
"predecessorArtifactSha256": "6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf",
|
||||
"sourceAction": "publish-verified-b2-loopback-discovery-source",
|
||||
"runtimeAction": "build-and-recreate-stateless-services",
|
||||
"selectedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-postgres"
|
||||
],
|
||||
"privateNetwork": "nodedc-device-plane-private",
|
||||
"controlNetwork": "nodedc-device-plane-control",
|
||||
"publishedPorts": [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
"127.0.0.1:9921:9921/tcp"
|
||||
],
|
||||
"protocolProfile": "arusnavi.b2.internal.v1",
|
||||
"framingSpecification": "arusnavi.internal.protocol-sheet.gid-12.v1",
|
||||
"identityTrust": "claimed-not-ownership-proof",
|
||||
"discoveryLifecycle": "quarantine",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"databaseVolume": "nodedc-device-plane-postgres-data",
|
||||
"rollback": "restore-source-and-predecessor-stateless-runtime"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.b2-discovery-loopback-recovery.v1",
|
||||
"mode": "failed-b2-loopback-build-reconciliation",
|
||||
"failedPatchId": "device-plane-b2-discovery-loopback-20260801-003",
|
||||
"failedArtifactSha256": "7273c5bf67fe6bc1f1da66ad726009240d39ee3aee58201b96c23d6f707a3d84",
|
||||
"failedBackupId": "device-plane-device-plane-b2-discovery-loopback-20260801-003-20260802-154311",
|
||||
"sourceAction": "publish-reconciliation-marker-only",
|
||||
"runtimeAction": "read-only-acceptance",
|
||||
"preservedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres"
|
||||
],
|
||||
"expectedLoopbackPorts": [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121"
|
||||
],
|
||||
"closedPort": "127.0.0.1:9921/tcp",
|
||||
"databaseVolume": "nodedc-device-plane-postgres-data",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"rollback": "marker-only-runtime-unchanged"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.backhaul-target-tailnet-serve.v1",
|
||||
"mode": "failed-backhaul-target-to-loopback-tailnet-serve",
|
||||
"failedPatchId": "device-plane-backhaul-target-20260803-001",
|
||||
"failedArtifactSha256": "ed0bda4110a756c32be68990e2e0f647409d5a77eec7e26c18502bafbdc1bb76",
|
||||
"failedBackupId": "device-plane-device-plane-backhaul-target-20260803-001-20260804-035519",
|
||||
"predecessorPatchId": "device-plane-b2-discovery-loopback-20260803-006",
|
||||
"predecessorArtifactSha256": "25f9e9e55e283e9b7bb5e128ff14a244f848b1c063acca9724a23206131c9adf",
|
||||
"sourceAction": "publish-loopback-backhaul-target-source",
|
||||
"runtimeAction": "build-create-target-and-register-private-tailnet-serve",
|
||||
"composeOverlay": "docker-compose.device-plane.backhaul-target.yml",
|
||||
"selectedServices": [
|
||||
"device-backhaul-target"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres"
|
||||
],
|
||||
"loopbackListenAddress": "127.0.0.1",
|
||||
"listenPort": 2222,
|
||||
"tailnetAddress": "100.109.216.21",
|
||||
"tailnetExposure": "tailscale-serve-private",
|
||||
"tailscaleServeTarget": "tcp://127.0.0.1:2222",
|
||||
"permittedTarget": "127.0.0.1:9921",
|
||||
"networkMode": "host",
|
||||
"dockerPortPublication": "disabled",
|
||||
"routerNatFirewall": "unchanged",
|
||||
"edgePublicIngress": "disabled",
|
||||
"funnel": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"databaseVolume": "nodedc-device-plane-postgres-data",
|
||||
"runtimeTrust": "runner-managed",
|
||||
"rollback": "remove-tailnet-serve-target-and-restore-source"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.backhaul-vps-enrollment.v1",
|
||||
"mode": "rotate-backhaul-client-mini-to-vps",
|
||||
"predecessorPatchId": "device-plane-backhaul-target-tailnet-serve-20260804-002",
|
||||
"predecessorArtifactSha256": "219408705dd4d80a962ed00eeb53a69df0b9ab6458443734d5c9cd1d1f795eba",
|
||||
"sourceAction": "publish-vps-enrollment-marker-only",
|
||||
"runtimeAction": "rotate-authorized-key-and-recreate-backhaul-target",
|
||||
"selectedServices": [
|
||||
"device-backhaul-target"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres"
|
||||
],
|
||||
"previousEnrollment": "device-edge-backhaul.pub",
|
||||
"nextEnrollment": "device-edge-vps-backhaul.pub",
|
||||
"nextKeyFingerprint": "SHA256:HHTiDYiCRxSiKjBLCip6JMSzGfLGrDz5g8SIkosJcVw",
|
||||
"permittedTarget": "127.0.0.1:9921",
|
||||
"tailnetAddress": "100.109.216.21",
|
||||
"dockerPortPublication": "disabled",
|
||||
"routerNatFirewall": "unchanged",
|
||||
"edgePublicIngress": "disabled",
|
||||
"funnel": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"rollback": "restore-previous-authorized-key-and-recreate-target"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.foundation-network-publication.v1",
|
||||
"mode": "failed-foundation-network-publication-correction",
|
||||
"failedRecoveryPatchId": "device-plane-foundation-recovery-20260725-002",
|
||||
"failedRecoveryArtifactSha256": "9183cc385142584bfd12510bb0a3e6b833b2fd26607436f2486a564c628ea1bf",
|
||||
"failedRecoveryBackupId": "device-plane-device-plane-foundation-recovery-20260725-002-20260725-232447",
|
||||
"sourceAction": "publish-network-corrected-foundation-source",
|
||||
"runtimeAction": "recreate-stateless-services-no-build",
|
||||
"selectedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-postgres"
|
||||
],
|
||||
"privateNetwork": "nodedc-device-plane-private",
|
||||
"controlNetwork": "nodedc-device-plane-control",
|
||||
"publishedLoopbackPorts": [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121"
|
||||
],
|
||||
"databaseVolume": "nodedc-device-plane-postgres-data",
|
||||
"rollback": "restore-partial-source-and-internal-only-stateless-runtime"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.foundation-recovery.v1",
|
||||
"mode": "failed-foundation-live-runtime-adoption",
|
||||
"failedPatchId": "device-plane-foundation-20260725-001",
|
||||
"failedArtifactSha256": "23d428de547854ad8b1a026671e2f850386ab0be98bde80f016f1e9db631ee24",
|
||||
"backupId": "device-plane-device-plane-foundation-20260725-001-20260725-223441",
|
||||
"sourceAction": "publish-exact-failed-artifact-source",
|
||||
"runtimeAction": "read-only-acceptance",
|
||||
"preservedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres"
|
||||
],
|
||||
"databaseVolume": "nodedc-device-plane-postgres-data",
|
||||
"rollback": "source-only-runtime-unchanged"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.postgres-bootstrap.v1",
|
||||
"service": "device-postgres",
|
||||
"volume": "nodedc-device-plane-postgres-data",
|
||||
"mode": "create-if-absent",
|
||||
"ordinaryApplicationSelection": "forbidden",
|
||||
"rollbackVolumePolicy": "preserve"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge.superseded-transport.v1",
|
||||
"status": "frozen",
|
||||
"frozenAt": "2026-08-10",
|
||||
"authority": "DCPLATFORM-76/ADR-0001",
|
||||
"reason": "The public VPS must not initiate a private connection to Synology.",
|
||||
"successor": "nodedc.device-edge.core-channel.v1",
|
||||
"forbiddenForNewPlanOrApply": [
|
||||
"nodedc.device-edge-vps.backhaul.v1",
|
||||
"nodedc.device-edge-vps.relay.v1:privateUpstream=127.0.0.1:19921",
|
||||
"nodedc.device-plane.backhaul-vps-enrollment.v1",
|
||||
"tailscale-userspace-key-only-ssh-local-forward",
|
||||
"rotate-backhaul-client-mini-to-vps"
|
||||
],
|
||||
"historicalSource": [
|
||||
"deployment/device-edge-vps-backhaul-v1.json",
|
||||
"deployment/device-edge-vps-relay-v1.json",
|
||||
"deployment/device-plane-backhaul-vps-enrollment-v1.json",
|
||||
"deployment/tailscale-device-edge-policy.hujson",
|
||||
"vps/config/backhaul_ssh_config",
|
||||
"vps/systemd/nodedc-b2-backhaul.service",
|
||||
"vps/systemd/nodedc-b2-relay.service",
|
||||
"infra/deploy-runner/build-device-edge-vps-artifact.mjs:backhaul|relay",
|
||||
"infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs",
|
||||
"infra/deploy-runner/nodedc-b2-vps-deploy:backhaul|relay"
|
||||
],
|
||||
"testOnlyReconstruction": {
|
||||
"environment": "NODEDC_ALLOW_SUPERSEDED_TRANSPORT",
|
||||
"value": "test-only",
|
||||
"deployCandidate": false
|
||||
},
|
||||
"runtimeMutationInPhase0": false
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// NODE.DC tailnet policy: user devices retain self-access while the public
|
||||
// Robot2B Device Edge VPS receives one purpose-bound egress grant.
|
||||
{
|
||||
"tagOwners": {
|
||||
"tag:device-edge-vps": ["autogroup:admin"],
|
||||
},
|
||||
|
||||
"hosts": {
|
||||
"device-plane-backhaul": "100.109.216.21",
|
||||
"nodedc-admin-macbook": "100.114.248.4",
|
||||
"nodedc-device-edge": "100.64.19.31",
|
||||
},
|
||||
|
||||
"grants": [
|
||||
// Preserve unrestricted connectivity only between devices owned by the
|
||||
// same authenticated tailnet member. Tagged service nodes are excluded.
|
||||
{
|
||||
"src": ["autogroup:member"],
|
||||
"dst": ["autogroup:self"],
|
||||
"ip": ["*"],
|
||||
},
|
||||
|
||||
// The public VPS can reach exactly the private SSH forwarding target.
|
||||
{
|
||||
"src": ["tag:device-edge-vps"],
|
||||
"dst": ["device-plane-backhaul"],
|
||||
"ip": ["tcp:2222"],
|
||||
},
|
||||
],
|
||||
|
||||
// Preserve the existing Tailscale SSH policy for user-owned devices.
|
||||
"ssh": [
|
||||
{
|
||||
"action": "check",
|
||||
"src": ["autogroup:member"],
|
||||
"dst": ["autogroup:self"],
|
||||
"users": ["autogroup:nonroot", "root"],
|
||||
},
|
||||
],
|
||||
|
||||
// These assertions are evaluated by Tailscale before every policy save.
|
||||
"tests": [
|
||||
{
|
||||
"src": "tag:device-edge-vps",
|
||||
"proto": "tcp",
|
||||
"accept": ["device-plane-backhaul:2222"],
|
||||
"deny": [
|
||||
"device-plane-backhaul:22",
|
||||
"device-plane-backhaul:5001",
|
||||
"nodedc-admin-macbook:22",
|
||||
"nodedc-device-edge:22",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
device-control-core:
|
||||
environment:
|
||||
DEVICE_EDGE_CHANNEL_ENABLED: "true"
|
||||
DEVICE_EDGE_CHANNEL_CORE_KEY_FILE: /run/nodedc-secrets/device-edge-channel/core-private-key.pem
|
||||
DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE: /run/nodedc-secrets/device-edge-channel/core-certificate.pem
|
||||
DEVICE_EDGE_CHANNEL_TRUST_ROOT: /run/nodedc-secrets/device-edge-channel/peers
|
||||
DEVICE_EDGE_CHANNEL_MAX_EDGES: "32"
|
||||
DEVICE_EDGE_CHANNEL_RECONCILE_INTERVAL_MS: "15000"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-private-key.pem
|
||||
target: /run/nodedc-secrets/device-edge-channel/core-private-key.pem
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-certificate.pem
|
||||
target: /run/nodedc-secrets/device-edge-channel/core-certificate.pem
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/peers
|
||||
target: /run/nodedc-secrets/device-edge-channel/peers
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
networks:
|
||||
- device-plane-egress
|
||||
|
||||
networks:
|
||||
device-plane-egress:
|
||||
name: nodedc-device-plane-egress
|
||||
driver: bridge
|
||||
internal: false
|
||||
@@ -0,0 +1,82 @@
|
||||
services:
|
||||
device-edge-backhaul:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/device-edge-backhaul/Dockerfile
|
||||
image: nodedc/device-edge-backhaul:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=8m,mode=1777
|
||||
volumes:
|
||||
- ../secrets/backhaul/id_ed25519:/run/keys/edge-to-synology:ro
|
||||
- ../trust/synology-backhaul-known_hosts:/run/trust/known_hosts:ro
|
||||
command:
|
||||
- -N
|
||||
- -T
|
||||
- -p
|
||||
- "2222"
|
||||
- -i
|
||||
- /run/keys/edge-to-synology
|
||||
- -L
|
||||
- 0.0.0.0:19921:127.0.0.1:9921
|
||||
- -o
|
||||
- BatchMode=yes
|
||||
- -o
|
||||
- PasswordAuthentication=no
|
||||
- -o
|
||||
- KbdInteractiveAuthentication=no
|
||||
- -o
|
||||
- PubkeyAuthentication=yes
|
||||
- -o
|
||||
- IdentitiesOnly=yes
|
||||
- -o
|
||||
- StrictHostKeyChecking=yes
|
||||
- -o
|
||||
- UserKnownHostsFile=/run/trust/known_hosts
|
||||
- -o
|
||||
- UpdateHostKeys=no
|
||||
- -o
|
||||
- ExitOnForwardFailure=yes
|
||||
- -o
|
||||
- ServerAliveInterval=15
|
||||
- -o
|
||||
- ServerAliveCountMax=3
|
||||
- -o
|
||||
- TCPKeepAlive=yes
|
||||
- -o
|
||||
- LogLevel=VERBOSE
|
||||
- -o
|
||||
- ProxyCommand=nc -X 5 -x nodedc-device-edge-tailnet-1:1055 %h %p
|
||||
- device-backhaul@100.109.216.21
|
||||
networks:
|
||||
- device-edge-private
|
||||
- device-edge-tailnet
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
pids_limit: 32
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- nc
|
||||
- -z
|
||||
- -w
|
||||
- "3"
|
||||
- 127.0.0.1
|
||||
- "19921"
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-edge-private:
|
||||
name: nodedc-device-edge-private
|
||||
external: true
|
||||
device-edge-tailnet:
|
||||
name: nodedc-device-edge-tailnet
|
||||
external: true
|
||||
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
device-edge-relay:
|
||||
environment:
|
||||
DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"
|
||||
DEVICE_EDGE_RELAY_TCP_HOST: 0.0.0.0
|
||||
DEVICE_EDGE_RELAY_TCP_PORT: "9921"
|
||||
DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul
|
||||
DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"
|
||||
DEVICE_EDGE_RELAY_MAX_SESSIONS: "100"
|
||||
DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10"
|
||||
DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
|
||||
DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only
|
||||
DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"
|
||||
DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"
|
||||
DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS: "10000"
|
||||
networks:
|
||||
device-edge-private:
|
||||
gw_priority: 0
|
||||
device-edge-ingress:
|
||||
ipv4_address: 192.168.71.253
|
||||
gw_priority: 100
|
||||
|
||||
networks:
|
||||
device-edge-private:
|
||||
name: nodedc-device-edge-private
|
||||
device-edge-ingress:
|
||||
name: nodedc-device-edge-ingress
|
||||
driver: ipvlan
|
||||
driver_opts:
|
||||
parent: enp1s0f0
|
||||
ipvlan_mode: l2
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 192.168.68.0/22
|
||||
gateway: 192.168.68.1
|
||||
@@ -0,0 +1,44 @@
|
||||
services:
|
||||
device-edge-relay:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/device-edge-relay/Dockerfile
|
||||
image: nodedc/device-edge-relay:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
DEVICE_EDGE_RELAY_HEALTH_HOST: 127.0.0.1
|
||||
DEVICE_EDGE_RELAY_HEALTH_PORT: "18221"
|
||||
DEVICE_EDGE_RELAY_INGRESS_ENABLED: "false"
|
||||
DEVICE_EDGE_RELAY_TCP_PORT: "9921"
|
||||
DEVICE_EDGE_RELAY_MAX_SESSIONS: "100"
|
||||
DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10"
|
||||
DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
|
||||
DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"
|
||||
DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"
|
||||
DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS: "10000"
|
||||
networks:
|
||||
device-edge-private: {}
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18221/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-edge-private:
|
||||
name: nodedc-device-edge-private
|
||||
internal: true
|
||||
@@ -0,0 +1,76 @@
|
||||
services:
|
||||
device-control-core:
|
||||
environment:
|
||||
DEVICE_MANAGEMENT_API_ENABLED: "true"
|
||||
DEVICE_MANAGEMENT_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/management-core-token
|
||||
target: /run/nodedc-secrets/management-core-token
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
device-manager:
|
||||
image: nodedc/device-manager:local
|
||||
pull_policy: never
|
||||
build:
|
||||
context: ./services/device-manager
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: "18122"
|
||||
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "true"
|
||||
NODEDC_DEVICE_MANAGER_COOKIE_SECURE: "true"
|
||||
NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW: "false"
|
||||
NODEDC_DEVICE_MANAGER_SERVICE_SLUG: device-core
|
||||
NODEDC_LAUNCHER_BASE_URL: https://hub.nodedc.ru
|
||||
NODEDC_LAUNCHER_INTERNAL_URL: http://launcher:5173
|
||||
NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token
|
||||
NODEDC_DEVICE_CORE_INTERNAL_URL: http://device-control-core:18120
|
||||
NODEDC_DEVICE_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-platform/secrets/device-core-internal-token
|
||||
target: /run/nodedc-secrets/device-core-internal-token
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/management-core-token
|
||||
target: /run/nodedc-secrets/management-core-token
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
expose:
|
||||
- "18122"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- platform-edge
|
||||
depends_on:
|
||||
device-control-core:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18122/healthz').then(r=>r.json()).then(v=>{if(!v.ok||!v.authRequired||!v.deviceCoreConfigured)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
platform-edge:
|
||||
external: true
|
||||
name: nodedc-platform_edge
|
||||
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
device-backhaul-target:
|
||||
image: nodedc/device-backhaul-target:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /run:size=8m,mode=0755
|
||||
- /tmp:size=8m,mode=1777
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/backhaul-target/ssh_host_ed25519_key
|
||||
target: /run/nodedc-secrets/ssh_host_ed25519_key
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/backhaul-target/authorized_keys
|
||||
target: /run/nodedc-secrets/authorized_keys
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- DAC_OVERRIDE
|
||||
- SETGID
|
||||
- SETUID
|
||||
- SYS_CHROOT
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "-w", "3", "127.0.0.1", "2222"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
@@ -0,0 +1,158 @@
|
||||
services:
|
||||
device-postgres:
|
||||
image: postgres:16-alpine
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: device_plane
|
||||
POSTGRES_USER: device_plane
|
||||
POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
volumes:
|
||||
- type: volume
|
||||
source: device-plane-postgres-data
|
||||
target: /var/lib/postgresql/data
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
networks:
|
||||
- device-plane-private
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-control-core:
|
||||
image: nodedc/device-control-core:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "18120"
|
||||
DEVICE_DATABASE_HOST: device-postgres
|
||||
DEVICE_DATABASE_PORT: "5432"
|
||||
DEVICE_DATABASE_NAME: device_plane
|
||||
DEVICE_DATABASE_USER: device_plane
|
||||
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
DEVICE_DATABASE_POOL_SIZE: "10"
|
||||
DEVICE_DISCOVERY_INGEST_ENABLED: "true"
|
||||
DEVICE_EDGE_CHANNEL_ENABLED: "false"
|
||||
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
|
||||
DEVICE_IDENTIFIER_PEPPER_FILE: /run/nodedc-secrets/identifier-pepper
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token
|
||||
target: /run/nodedc-secrets/gateway-core-token
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/identifier-pepper
|
||||
target: /run/nodedc-secrets/identifier-pepper
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18120:18120"
|
||||
networks:
|
||||
- device-plane-private
|
||||
depends_on:
|
||||
device-postgres:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-gateway:
|
||||
image: nodedc/device-gateway:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
|
||||
DEVICE_GATEWAY_HEALTH_PORT: "18121"
|
||||
DEVICE_GATEWAY_LISTEN_ENABLED: "true"
|
||||
DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"
|
||||
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
|
||||
DEVICE_GATEWAY_TCP_PORT: "9921"
|
||||
DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120
|
||||
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
|
||||
DEVICE_GATEWAY_CORE_TIMEOUT_MS: "5000"
|
||||
DEVICE_GATEWAY_MAX_BUFFERED_BYTES: "65536"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS: "100"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS: "10"
|
||||
DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
|
||||
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token
|
||||
target: /run/nodedc-secrets/gateway-core-token
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18121:18121"
|
||||
- "127.0.0.1:9921:9921"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
depends_on:
|
||||
device-control-core:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-plane-private:
|
||||
name: nodedc-device-plane-private
|
||||
internal: true
|
||||
device-plane-control:
|
||||
name: nodedc-device-plane-control
|
||||
driver: bridge
|
||||
internal: false
|
||||
driver_opts:
|
||||
com.docker.network.bridge.enable_ip_masquerade: "false"
|
||||
|
||||
volumes:
|
||||
device-plane-postgres-data:
|
||||
name: nodedc-device-plane-postgres-data
|
||||
@@ -0,0 +1,367 @@
|
||||
# ADR 0001: Core-initiated Device Gateway Edge channel
|
||||
|
||||
Status: accepted for implementation on 2026-08-10.
|
||||
|
||||
Scope: NODE.DC Device Core / Device Gateway / public Device Edge VPS.
|
||||
|
||||
Authority: NDC PLATFORM `DCPLATFORM-76`, with deploy implementation governed by
|
||||
`DCPLATFORM-21`.
|
||||
|
||||
## Decision
|
||||
|
||||
The NODE.DC Core side initiates and owns the only private control/data channel
|
||||
to a public Device Gateway Edge. The Edge never initiates a network connection
|
||||
to Synology, DSM, Docker, Hub, Engine, Foundry, PostgreSQL, a Mac workstation or
|
||||
another LAN service.
|
||||
|
||||
The target transport is TLS 1.3 mutual authentication over one long-lived
|
||||
HTTP/2 bidirectional session:
|
||||
|
||||
```text
|
||||
ARUSNAVI B2
|
||||
-> raw TCP/9921
|
||||
-> Device Gateway Edge on the VPS
|
||||
-> accepted Core-owned mTLS session on standard HTTPS TCP/443
|
||||
-> Device Gateway Core on Synology
|
||||
-> Device Control Core
|
||||
```
|
||||
|
||||
The Core dials an endpoint selected from an approved Edge registration. The
|
||||
endpoint is not hardcoded into an adapter, device record, Foundry application,
|
||||
Engine workflow or deployment artifact.
|
||||
|
||||
`device.nodedc.ru` remains the HTTPS user surface for Device Core. It is not the
|
||||
raw B2 endpoint and is not moved to the VPS for this transport.
|
||||
|
||||
## Why this direction is mandatory
|
||||
|
||||
The VPS is a public, replaceable and partially untrusted edge host. A VPS owner
|
||||
or a compromised root account must not gain a route into the private NODE.DC
|
||||
network. An outbound Core connection works through NAT without a Synology port
|
||||
forward and gives the Core a single explicit peer and protocol to validate.
|
||||
|
||||
The previous design used a tagged userspace Tailscale process on the VPS,
|
||||
SOCKS5, SSH local forwarding and a Synology backhaul target. Although that
|
||||
design had narrow ACLs, the trust direction was still VPS-initiated and it kept
|
||||
an avoidable private-network membership on the public host. It is superseded.
|
||||
|
||||
## Component ownership
|
||||
|
||||
Device Gateway Edge owns only:
|
||||
|
||||
- public tracker TCP sessions;
|
||||
- allowlisted adapter framing and protocol timing;
|
||||
- the minimum HEADER/PACKAGE acknowledgement state;
|
||||
- bounded in-memory buffers and flow-control counters;
|
||||
- the server side of the mutually authenticated Core channel;
|
||||
- typed command delivery to an already connected tracker session.
|
||||
|
||||
Device Gateway Core and Device Control Core own:
|
||||
|
||||
- Edge registration and certificate identity;
|
||||
- owner scopes, Device Projects and project access;
|
||||
- enrollment, quarantine, claim and transfer policy;
|
||||
- restricted identifiers and credential references;
|
||||
- complete telemetry decoding/normalization and data classification;
|
||||
- command policy, confirmation, ledger and audit;
|
||||
- Engine/Data Product and Foundry bindings.
|
||||
|
||||
The Edge does not contain a business database, durable telemetry store, Hub or
|
||||
Authentik credentials, Engine/Foundry tokens, PostgreSQL credentials or the
|
||||
Core client private key.
|
||||
|
||||
## Adapter split
|
||||
|
||||
One versioned adapter package exposes explicit role-scoped interfaces:
|
||||
|
||||
- `edge-session`: framing, bounded validation, HEADER/PACKAGE ACK and typed
|
||||
command wire encoding required by the active socket;
|
||||
- `core-decoder`: complete tag decoding, safe observation normalization and
|
||||
model/firmware capability mapping;
|
||||
- `command-contract`: typed commands and acknowledgement semantics shared by
|
||||
policy and delivery code.
|
||||
|
||||
The Edge artifact contains only the allowlisted `edge-session` and required
|
||||
wire command implementation. It does not contain owner, workflow or Foundry
|
||||
logic. The Core artifact may contain the full adapter package. A new device
|
||||
model is added through the adapter registry; it does not create another Device
|
||||
Manager application or a model-specific Synology service.
|
||||
|
||||
## Authentication and key ownership
|
||||
|
||||
- The Core has a unique client certificate and private key stored only in the
|
||||
canonical Synology secret/trust boundary.
|
||||
- The Edge has a unique server certificate and private key stored only in the
|
||||
Edge runtime trust boundary.
|
||||
- Both certificates chain to the dedicated Device Edge private CA or an
|
||||
equivalent separately approved workload-identity issuer.
|
||||
- The Core verifies the Edge registration id, certificate identity, CA,
|
||||
validity and configured endpoint.
|
||||
- The Edge accepts only an approved Core workload identity and never accepts a
|
||||
browser, bearer-token-only or anonymous channel.
|
||||
- Certificates have bounded lifetime, explicit generation and audited rotation.
|
||||
- No private key, enrollment token or certificate bundle is carried in Git,
|
||||
Ops, MCP payloads or ordinary deploy artifacts.
|
||||
|
||||
TLS terminates on the Edge process. Root compromise of the VPS can therefore
|
||||
read tracker traffic and impersonate that Edge identity until it is revoked.
|
||||
It cannot obtain the Core private key or open a new connection into Synology.
|
||||
|
||||
## Session protocol
|
||||
|
||||
The application protocol is versioned and fail-closed. Every envelope has:
|
||||
|
||||
- schema version;
|
||||
- Edge registration id;
|
||||
- channel generation;
|
||||
- tracker session id;
|
||||
- adapter/profile reference and version;
|
||||
- monotonically increasing direction-local sequence;
|
||||
- event timestamp and receive timestamp;
|
||||
- bounded payload length;
|
||||
- message kind and correlation id.
|
||||
|
||||
Allowed Edge-to-Core messages are limited to channel hello/health, tracker
|
||||
session opened/closed, discovery evidence, bounded verified frame, delivery
|
||||
acknowledgement and bounded counters. Allowed Core-to-Edge messages are limited
|
||||
to channel acceptance, flow-control window, session disposition and typed
|
||||
command delivery.
|
||||
|
||||
Unknown schema versions, message kinds, adapters, Edge ids or oversized frames
|
||||
close the logical session and create a safe audit event. They never fall back
|
||||
to arbitrary TCP forwarding.
|
||||
|
||||
## Tracker acknowledgement rule
|
||||
|
||||
The VPS has no durable business store. It must not acknowledge a valid tracker
|
||||
PACKAGE merely because bytes reached the VPS.
|
||||
|
||||
The Edge sends the verified frame to Core and waits for a bounded Core
|
||||
acceptance acknowledgement. Only then may the Edge send the protocol PACKAGE
|
||||
acknowledgement to the tracker. If the Core channel is unavailable or the
|
||||
acceptance deadline expires, the Edge does not acknowledge the PACKAGE; the
|
||||
tracker remains responsible for its documented retry behavior.
|
||||
|
||||
HEADER acknowledgement follows the same ownership boundary: discovery must be
|
||||
accepted into Core quarantine or matched to an admitted device session before
|
||||
the Edge completes the handshake. An unavailable Core means no admitted
|
||||
tracker session.
|
||||
|
||||
This gives at-least-once delivery without a VPS database. Core deduplicates by
|
||||
Edge generation, tracker session, package number and content digest.
|
||||
|
||||
## Realtime and flow control
|
||||
|
||||
The Core does not poll the VPS for batches. Telemetry returns immediately over
|
||||
the established channel. The initial implementation contract is:
|
||||
|
||||
- one Core channel per Edge generation;
|
||||
- maximum 128 concurrent tracker sessions on the pilot VPS;
|
||||
- maximum 16 sessions per observed source address;
|
||||
- maximum 60 new tracker connections per minute per observed source;
|
||||
- maximum 256 KiB buffered per tracker session;
|
||||
- maximum 32 MiB aggregate tracker/channel buffering;
|
||||
- maximum 1 MiB for one protocol frame before adapter-specific lower limits;
|
||||
- 15-second keepalive and 45-second dead-peer deadline;
|
||||
- reconnect with jittered exponential delay from 1 to 30 seconds;
|
||||
- no unbounded disk spool;
|
||||
- explicit accepted, duplicate, late, dropped, rejected and throttled counters.
|
||||
|
||||
These are pilot ceilings, not a claim that one 961 MiB VPS supports production
|
||||
scale. Load gates at 1, 100 and 1000 synthetic sessions determine the production
|
||||
Edge topology.
|
||||
|
||||
## Pilot service objectives
|
||||
|
||||
These are acceptance objectives for the controlled pilot, not a published
|
||||
production SLA:
|
||||
|
||||
- zero tracker PACKAGE acknowledgements before durable Core acceptance in all
|
||||
normal, timeout, disconnect and Core-restart tests;
|
||||
- zero loss of Core-accepted packages in the acceptance run; duplicate delivery
|
||||
is permitted on reconnect but must collapse to one normalized observation;
|
||||
- Edge receive to Core acceptance latency at 128 concurrent synthetic sessions:
|
||||
p95 at or below 2 seconds and p99 at or below 5 seconds;
|
||||
- after both peers and the network are healthy, channel re-establishment: p95 at
|
||||
or below 60 seconds and hard acceptance ceiling of 120 seconds;
|
||||
- dead Core detection at the Edge no later than 45 seconds after the last valid
|
||||
channel activity;
|
||||
- memory stays inside the configured 32 MiB aggregate application buffer plus
|
||||
the separately measured fixed runtime baseline; exceeding a bound throttles
|
||||
or closes the offender and never expands the limit;
|
||||
- malformed, unknown, unauthenticated and revoked-identity inputs produce zero
|
||||
accepted telemetry records and zero command deliveries;
|
||||
- a typed command for a currently connected test tracker reaches Edge wire
|
||||
delivery or a conclusive rejection in p95 at or below 2 seconds; verified
|
||||
device execution is a separate adapter-defined objective.
|
||||
|
||||
Availability percentage and the 1000-session production capacity are explicitly
|
||||
uncommitted until measured on the target host and recorded by a later transition.
|
||||
|
||||
## Commands
|
||||
|
||||
Commands use the same Core-owned channel and the existing tracker TCP session.
|
||||
The VPS never exposes a command HTTP API and never accepts an arbitrary raw
|
||||
payload.
|
||||
|
||||
Core sends a typed command containing exact device/session, adapter/profile,
|
||||
command catalog version, parameters, idempotency key, expiry and correlation
|
||||
id. Edge either rejects it before wire delivery or returns delivery evidence.
|
||||
Protocol acknowledgement is `acknowledged`, not `verified`. Verification needs
|
||||
a subsequent device observation or explicit readback defined by the adapter.
|
||||
|
||||
No command is queued durably on the VPS. Channel loss before a conclusive
|
||||
outcome yields `unknown` or `failed` according to the command contract. Unsafe
|
||||
automatic retry is forbidden.
|
||||
|
||||
## Network boundary
|
||||
|
||||
The target Edge exposes only:
|
||||
|
||||
- management SSH according to the separately accepted management policy;
|
||||
- raw tracker ingress TCP/9921;
|
||||
- Core channel TCP/443 with mandatory mTLS; this is not a browser or bearer-token endpoint.
|
||||
|
||||
Synology exposes no public device or backhaul port. The Edge receives no subnet
|
||||
route, exit-node capability, Tailscale SSH, Docker socket or generic proxy. The
|
||||
Core channel cannot request an arbitrary destination or port.
|
||||
|
||||
Firewall policy is default deny. Raw ingress and Core channel have independent
|
||||
connection/rate/resource limits. DDoS and malformed traffic terminate at the
|
||||
provider/VPS and cannot be forwarded as a generic byte stream to Synology.
|
||||
|
||||
## Threat model
|
||||
|
||||
Protected assets are the Synology/LAN network, Device Core data and credentials,
|
||||
Hub/Authentik identities, Engine and Foundry capabilities, command authority and
|
||||
the integrity of admitted telemetry.
|
||||
|
||||
The design assumes any of the following can happen independently: VPS root is
|
||||
compromised; the VPS owner makes an unsafe change; an Internet client floods or
|
||||
sends malformed B2 traffic; an Edge certificate is copied; a tracker identifier
|
||||
is spoofed; the Core channel is interrupted; a valid package is replayed; or a
|
||||
command outcome becomes unknown during disconnect.
|
||||
|
||||
The boundary mitigates lateral entry into the private platform, arbitrary TCP
|
||||
proxying, anonymous/bearer-only channel access, unbounded memory growth, replay
|
||||
as a second normalized observation, command injection through a raw API, secret
|
||||
distribution to the VPS and acknowledgement of telemetry that only reached
|
||||
volatile Edge memory.
|
||||
|
||||
Residual risks are explicit: compromised VPS root can read, drop, delay or forge
|
||||
traffic attributed to that Edge until revocation, attack trackers connected to
|
||||
it and exhaust the VPS or its uplink. Provider-scale DDoS protection, tracker
|
||||
firmware trust and physical tracker compromise are outside this component. None
|
||||
of those residual risks grants an inbound route or credential to Synology.
|
||||
|
||||
## Tailscale decision
|
||||
|
||||
Direct mTLS is the accepted target. Tailscale is not required for the product
|
||||
channel.
|
||||
|
||||
The current VPS userspace Tailscale foundation is a live predecessor and is not
|
||||
removed in Phase 0. A later canonical transition removes it after the mTLS
|
||||
channel is accepted. If direct mTLS proves impossible for an externally
|
||||
evidenced reason, a new ADR may admit Tailscale only with all of the following:
|
||||
|
||||
- Core still initiates the application session;
|
||||
- tagged service identity;
|
||||
- no user ownership, subnet routes, DNS, exit node or Tailscale SSH;
|
||||
- an ACL to one exact Edge application endpoint only;
|
||||
- negative tests for DSM 22/5001, Docker, MacBook and LAN;
|
||||
- no SSH LocalForward or generic SOCKS backhaul.
|
||||
|
||||
The old VPS-to-Synology SSH local-forward is not an allowed fallback.
|
||||
|
||||
## Superseded source
|
||||
|
||||
The following source is historical/recovery evidence and must not be used for a
|
||||
new plan or apply:
|
||||
|
||||
- `deployment/device-edge-vps-backhaul-v1.json`;
|
||||
- `deployment/device-edge-vps-relay-v1.json` when it forwards to port 19921;
|
||||
- `deployment/device-plane-backhaul-vps-enrollment-v1.json`;
|
||||
- `deployment/tailscale-device-edge-policy.hujson` for the old SSH target;
|
||||
- `vps/config/backhaul_ssh_config`;
|
||||
- `vps/systemd/nodedc-b2-backhaul.service`;
|
||||
- `vps/systemd/nodedc-b2-relay.service` with the old upstream;
|
||||
- `infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs`;
|
||||
- `infra/deploy-runner/nodedc-b2-vps-deploy` backhaul/relay phases.
|
||||
|
||||
Builders fail closed by default. A test-only environment switch may reconstruct
|
||||
historical artifacts for deterministic regression tests, but artifacts built in
|
||||
that mode are not deploy candidates. Both reviewed runner sources reject the
|
||||
superseded VPS phases and Synology enrollment even if such an archive exists.
|
||||
|
||||
## Deployment boundaries
|
||||
|
||||
The successor is a new additive Edge transport generation, not a weakened edit
|
||||
of the old phase. Before an application artifact exists, DCPLATFORM-21 must
|
||||
define:
|
||||
|
||||
- component and transition identity;
|
||||
- exact payload allowlist/denylist;
|
||||
- fixed Edge roots, systemd units and trust roots;
|
||||
- runtime-secret ownership and rotation;
|
||||
- predecessor checks for the current Tailscale foundation;
|
||||
- services stopped/started and preserved state;
|
||||
- health and negative network acceptance inside apply;
|
||||
- automatic rollback to the current closed-port predecessor.
|
||||
|
||||
The first successor apply must leave TCP/9921 closed. It accepts only the mTLS
|
||||
Core channel in a no-device/synthetic mode. Public tracker ingress is a separate
|
||||
later transition after Core-channel acceptance.
|
||||
|
||||
## Acceptance gates
|
||||
|
||||
Phase 0 is accepted when:
|
||||
|
||||
- this ADR and a machine-readable contract are present;
|
||||
- the old builders fail closed by default;
|
||||
- the reviewed VPS runner source rejects old backhaul/relay phases;
|
||||
- regression tests prove the freeze and the new direction;
|
||||
- Ops records exact SLO, threat and rollback boundaries;
|
||||
- no runtime, DNS, route, port or tracker setting changed.
|
||||
|
||||
The future Core-channel slice is accepted only when:
|
||||
|
||||
- Synology has no new public listener or router mapping;
|
||||
- a Core client without the exact identity cannot connect;
|
||||
- an Edge with an unknown/revoked identity is rejected by Core;
|
||||
- the Edge cannot reach DSM, Docker, Hub, Engine, Foundry, PostgreSQL, MacBook
|
||||
or LAN targets;
|
||||
- unknown/oversized/replayed envelopes fail closed;
|
||||
- Core loss causes no tracker PACKAGE acknowledgement after the deadline;
|
||||
- reconnect and deduplication preserve at-least-once behavior;
|
||||
- secrets and unrestricted identifiers are absent from artifacts, logs, Ops,
|
||||
metrics and MCP;
|
||||
- rollback stops the successor channel, restores the closed-port predecessor
|
||||
and preserves Device Plane DB, Gelios, Foundry and Engine.
|
||||
|
||||
## Rollback
|
||||
|
||||
Phase 0 changes only source and Ops; rollback is a source revert with no runtime
|
||||
effect.
|
||||
|
||||
The future transport apply owns an automatic rollback partition containing the
|
||||
new Edge/Core channel units, configs, certificate references, firewall entries
|
||||
and source publication. Rollback must:
|
||||
|
||||
- stop and disable only the candidate channel units;
|
||||
- remove only candidate firewall rules/listeners;
|
||||
- restore the exact accepted predecessor files and unit states;
|
||||
- keep public B2/9921 closed unless it was already an accepted predecessor;
|
||||
- preserve Device Core/PostgreSQL, Gateway Core, Hub, Engine, Foundry and
|
||||
Gelios;
|
||||
- preserve audit evidence and record the failed generation;
|
||||
- never restore or invent the superseded VPS-initiated SSH backhaul.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Edge contains protocol-session code and can be replaced independently. A
|
||||
VPS compromise can disrupt or falsify its tracker observations and can attack
|
||||
connected trackers, but it does not become a path into the private platform.
|
||||
|
||||
The design deliberately accepts temporary telemetry unavailability when Core
|
||||
is unreachable instead of acknowledging data into an untrusted, non-durable
|
||||
VPS buffer. This is the correct failure mode for the stated trust boundary.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Device Edge B2 public pilot runbook
|
||||
|
||||
> Frozen historical runbook — 2026-08-10
|
||||
>
|
||||
> Do not configure these NAT rules, Mini relay, DNS changes or tracker route.
|
||||
> This path is superseded by
|
||||
> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`. The public pilot will receive
|
||||
> a new runbook only after the Core-initiated mTLS channel and VPS Edge pass
|
||||
> their separate security/deploy acceptance.
|
||||
|
||||
Status: router/NAT is **not configured** by this document. It is a one-pilot,
|
||||
human-operated exposure gate for the already accepted Mini relay. It never
|
||||
changes Synology, Gelios, VPN, Device Plane command transport or device
|
||||
ownership.
|
||||
|
||||
## Exact traffic path
|
||||
|
||||
```text
|
||||
ARUSNAVI B2 pilot
|
||||
-> public IPv4 : TCP/9921
|
||||
-> provider router : TCP/9921 -> 192.168.1.151:9921
|
||||
-> Deco X55 : TCP/9921 -> 192.168.71.253:9921
|
||||
-> Mini IPvlan relay
|
||||
-> restricted private backhaul
|
||||
-> Synology Gateway 127.0.0.1:9921
|
||||
```
|
||||
|
||||
The observed Deco WAN is `192.168.1.151` with gateway `192.168.0.1`; it is an
|
||||
RFC1918 address. Therefore this is a double-NAT topology. A Deco rule alone
|
||||
cannot make the tracker reachable from the internet.
|
||||
|
||||
## Immutable safety boundary
|
||||
|
||||
- Forward **TCP only**, external and internal port `9921`.
|
||||
- Deco target is exactly `192.168.71.253`, never the Mini host
|
||||
`192.168.68.54` and never Synology.
|
||||
- Do not enable DMZ, UPnP, port ranges, UDP, 443 forwarding or any catch-all
|
||||
rule.
|
||||
- Do not remove, replace or edit the Gelios monitoring route.
|
||||
- The relay accepts connections only from a public IPv4 source, keeps
|
||||
quarantine-only discovery and has no command transport. A LAN client will
|
||||
intentionally be rejected; that is not a test failure.
|
||||
- The rule must be deleted again if the single pilot is abandoned or the
|
||||
expected quarantine evidence is not obtained.
|
||||
|
||||
## Gate 1 — DNS and public address
|
||||
|
||||
1. Determine the actual public IPv4 on the provider-facing edge. Do not use
|
||||
`192.168.0.1`, `192.168.1.151`, `192.168.68.1` or `192.168.71.253` as a
|
||||
DNS answer: all are private addresses.
|
||||
2. Point `device.nodedc.ru` to that public IPv4 only if the tracker UI accepts
|
||||
a hostname. Otherwise configure the public IPv4 directly.
|
||||
3. If the provider router has no public WAN IPv4, or an internet check still
|
||||
cannot reach it after both rules below, stop. This is provider CGNAT/bridge
|
||||
territory: request a public IPv4 or a bridge/forwarding option from the
|
||||
provider. Do not introduce a VPS as an unreviewed workaround.
|
||||
|
||||
## Gate 2 — provider router
|
||||
|
||||
On the upstream/provider router, create one port-forward rule:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | `NDC B2 pilot to Deco` |
|
||||
| Protocol | `TCP` |
|
||||
| External port | `9921` |
|
||||
| Target address | `192.168.1.151` |
|
||||
| Target port | `9921` |
|
||||
|
||||
Save only that rule. It targets the Deco WAN address, not a service host.
|
||||
|
||||
## Gate 3 — Deco X55
|
||||
|
||||
In the Deco mobile app: **More → Advanced → NAT Forwarding → Port Forwarding →
|
||||
Add**. Create exactly:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | `NDC B2 pilot` |
|
||||
| Protocol | `TCP` |
|
||||
| External port | `9921` |
|
||||
| Internal IP | `192.168.71.253` |
|
||||
| Internal port | `9921` |
|
||||
|
||||
If the app forces a client selection, select the IPvlan relay only if its
|
||||
address is shown as `192.168.71.253`. Do not select the Mini host or any NAS.
|
||||
If the UI will not accept the fixed IPvlan address, stop and record that fact;
|
||||
do not substitute a DHCP address.
|
||||
|
||||
## Gate 4 — ARUSNAVI B2 route
|
||||
|
||||
Use one known pilot B2 and one unused monitoring-server slot. Configure an
|
||||
additional route with the documented **INTERNAL** protocol and the public
|
||||
hostname/IP from Gate 1, TCP port `9921`. Preserve the existing Gelios route in
|
||||
its current slot. The ARUSNAVI account password stays in the operator surface;
|
||||
it is not entered into Foundry, Device Plane or the relay.
|
||||
|
||||
The device IMEI observed in `HEADER2` is a claimed identifier only. It becomes
|
||||
a quarantine discovery, not an owned device and never a command target.
|
||||
|
||||
## Acceptance and failure handling
|
||||
|
||||
The first valid HEADER/PACKAGE through the pilot route must produce a masked,
|
||||
quarantine-only discovery in Device Control Core and the existing Gelios map
|
||||
path must continue independently. Do not issue a device command.
|
||||
|
||||
On any unexpected behavior, remove the two NAT rules in reverse order:
|
||||
|
||||
1. remove the Deco `NDC B2 pilot` rule;
|
||||
2. remove the provider-router `NDC B2 pilot to Deco` rule.
|
||||
|
||||
This ends external reachability while leaving the Mini, VPN, backhaul,
|
||||
Synology and Gelios unchanged. Do not use a LAN port probe as acceptance: the
|
||||
relay correctly rejects private source addresses.
|
||||
@@ -0,0 +1,898 @@
|
||||
# NDC Device Manager / Direct ARUSNAVI B2 / VPS
|
||||
|
||||
> Historical audit notice — 2026-08-10
|
||||
>
|
||||
> Live evidence in this document remains useful, but the product and transport
|
||||
> decisions are superseded by `DCPLATFORM-76` and
|
||||
> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`. Device Core is a standalone
|
||||
> Hub application, not a Foundry Page; the Edge contains a bounded adapter
|
||||
> session role; Synology/Core initiates the private channel to the VPS; the old
|
||||
> VPS-to-Synology relay/backhaul must not be deployed.
|
||||
|
||||
Актуализированный архитектурный аудит и план перехода на Direct-primary с Gelios read fallback.
|
||||
|
||||
Дата фиксации: 6 августа 2026 года.
|
||||
|
||||
## 1. Итоговое решение
|
||||
|
||||
Целевая read-архитектура зафиксирована так:
|
||||
|
||||
- ARUSNAVI B2 отправляет данные параллельно в два monitoring server slot;
|
||||
- прямой route B2 → NODE.DC становится основным источником позиции и текущей телеметрии;
|
||||
- Gelios остаётся постоянно работающим legacy/read-only кандидатом для fallback;
|
||||
- при недоступности прямого потока переключение выполняется отдельно для каждого устройства, а не глобально по одному health endpoint;
|
||||
- наружу публикуется один канонический факт на один трайк; два источника не пишут одновременно в одну current/history projection;
|
||||
- identity трайка, существующие `sourceId`, joins Foundry и Timescale-инфраструктура сохраняются;
|
||||
- команды через Gelios не используются;
|
||||
- команды через Direct B2 не входят в текущий этап и остаются выключенными;
|
||||
- Mac Mini окончательно исключён из production ingress; его артефакты остаются историческим прототипом;
|
||||
- VPS выполняет только публичный L4 ingress и зашифрованный backhaul. На нём нет БД, Engine, EDP, Foundry, Device Control Core, decoder, ACK-логики или command transport.
|
||||
|
||||
Важная терминологическая поправка: B2 приходит на VPS не HTTP-пакетами, а сырым TCP-потоком `INTERNAL`. HTTP/HTTPS может использоваться только внутри закрытого контура после Gateway/decoder. Шифрование начинается на плече VPS → private NODE.DC contour; первый hop B2 → VPS остаётся обычным raw TCP, если сам B2 не поддерживает иной транспорт.
|
||||
|
||||
## 2. Что проверено live
|
||||
|
||||
Этот документ опирается не только на Ops, но и на live MCP и текущий source:
|
||||
|
||||
- Ops: проекты `NDC PLATFORM` и `ROBOT2B`, полные карточки и комментарии;
|
||||
- Engine: granted L2 targets, графы, runtime, executions, credential binding status, output profiles и telemetry catalog;
|
||||
- Foundry: application instance, Map page, bindings, profiles и server-owned consumer progress;
|
||||
- Ontology: live catalog, entities, relations и guardrails;
|
||||
- source: Device Plane, ARUSNAVI adapter, Gateway, Control Core, EDP writer/reader scope и deployment artifacts;
|
||||
- DNS: текущий A-record;
|
||||
- SSH: доступные локальные aliases и наличие однозначной VPS identity.
|
||||
|
||||
Ops в этом аудите является журналом решений и статусов. Реализация считается подтверждённой только там, где она совпадает с live MCP, runtime или source.
|
||||
|
||||
## 3. Что в исходном аудите устарело
|
||||
|
||||
### 3.1 MCP уже работает
|
||||
|
||||
Исходный текст говорил, что Ops, Engine, Ontology и Foundry MCP недоступны. На момент этой актуализации все четыре live boundary доступны и прочитаны.
|
||||
|
||||
### 3.2 Канонический продукт — v5, не v3
|
||||
|
||||
Production Map получает:
|
||||
|
||||
```text
|
||||
fleet.positions.current.v5@5.0.0
|
||||
ontology.map.moving_object.v3
|
||||
delivery = snapshot+patch
|
||||
history = latest observation / 60 s bucket
|
||||
retention = 90 days
|
||||
```
|
||||
|
||||
Определение продукта находится в `platform/services/external-data-plane/definitions/fleet.positions.current.v5.json`.
|
||||
|
||||
### 3.3 Device Manager Page ещё не существует
|
||||
|
||||
Live Page Library Foundry содержит только `map@0.1.0`. Канонического шаблона `Device Manager`, server-owned `device-plane-control` provider и соответствующего UI сейчас нет.
|
||||
|
||||
Device Manager не блокирует Direct ingestion: текущий этап можно завершить через Device Plane, Engine/EDP и существующую Map. UI управления устройствами остаётся отдельной более поздней фазой.
|
||||
|
||||
### 3.4 VPS проверен live по SSH
|
||||
|
||||
После получения точной SSH identity выполнен read-only аудит `root@155.212.211.15`. Изменений на host не выполнялось.
|
||||
|
||||
Проверенная identity:
|
||||
|
||||
- hostname `koffyvngij`;
|
||||
- KVM/QEMU;
|
||||
- Ubuntu 24.04.4 LTS, kernel `6.8.0-137-generic`;
|
||||
- SSH host key уже был pinned локально; ED25519 fingerprint `SHA256:mhqNn2S6zstkYL7VFdvt3SYHv1nLjB4J7/s57RrKG6w`.
|
||||
- используемый client key имеет mode `0600` и fingerprint `SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw`;
|
||||
- в `/root/.ssh/authorized_keys` находятся два unrestricted key lines: этот Mac key и отдельный provider `beget-access-key`; их дальнейшая судьба должна быть explicit management-access policy, а не ручное удаление во время аудита.
|
||||
|
||||
Проверенная ёмкость:
|
||||
|
||||
- 1 vCPU;
|
||||
- 961 MiB RAM, около 621 MiB available во время аудита;
|
||||
- swap отсутствует;
|
||||
- root filesystem 8.7 GiB, 2.3 GiB used, 6.4 GiB available;
|
||||
- inode usage 11%;
|
||||
- system clock synchronized, NTP active, timezone UTC;
|
||||
- failed systemd units отсутствуют;
|
||||
- journal занимает 9.9 MiB.
|
||||
|
||||
Проверенная сеть/runtime:
|
||||
|
||||
- `eth0` имеет public `155.212.211.15/32`, default route через `100.100.1.1` on-link;
|
||||
- наружу слушает только SSH на `0.0.0.0:22` и `[::]:22`; `9921/TCP` не слушает;
|
||||
- Docker, Podman, containerd, Tailscale, WireGuard и relay отсутствуют;
|
||||
- Fail2Ban активен для `sshd`; unattended upgrades и time sync активны.
|
||||
|
||||
Текущий security baseline не принят для production:
|
||||
|
||||
- UFW inactive;
|
||||
- nftables/iptables INPUT policy `ACCEPT`; единственное правило — Fail2Ban reject set для SSH;
|
||||
- `PermitRootLogin yes`;
|
||||
- `PasswordAuthentication yes`, root password установлен;
|
||||
- `X11Forwarding yes`;
|
||||
- `AllowTcpForwarding yes`, `PermitOpen any`, `DisableForwarding no`.
|
||||
|
||||
Следовательно, VPS подходит по мощности для одного bounded relay и encrypted backhaul, но public `9921` нельзя включать до canonical firewall/SSH/runtime bootstrap и rollback acceptance.
|
||||
|
||||
### 3.5 Домен сейчас указывает не на VPS
|
||||
|
||||
`device.nodedc.ru` на 6 августа 2026 года резолвится в `95.165.91.235`. По `DCPLATFORM-34` это внешний адрес Synology/DSM, а не подтверждённый адрес нового VPS.
|
||||
|
||||
`device.dc.ru`, упомянутый устно, A-record не вернул. Каноническое имя в архитектуре — `device.nodedc.ru`.
|
||||
|
||||
До VPS acceptance и DNS cutover нельзя настраивать B2 на текущий `device.nodedc.ru:9921`: raw ingress на Synology запрещён.
|
||||
|
||||
### 3.6 Полный IMEI уже существует в restricted Foundry contour
|
||||
|
||||
Live Foundry получает `device_imei` в двух restricted bindings:
|
||||
|
||||
- `fleet.units.contacts.current.v1`;
|
||||
- `fleet.units.identity.current.v1`.
|
||||
|
||||
Карточка объекта показывает полный IMEI как `restricted` text field из aspect `unit_identity`. Это не public/unrestricted поле, но это и не маскированное значение.
|
||||
|
||||
Новая принятая политика:
|
||||
|
||||
- IMEI не является паролем или secret;
|
||||
- он может проходить внутри NODE.DC contour и использоваться для сопоставления устройства;
|
||||
- он остаётся classified `restricted` identifier;
|
||||
- его нельзя без необходимости писать в Ops, обычные логи, публичные payload, metrics labels или unrestricted MCP output;
|
||||
- VPS видит IMEI только как часть непрозрачного TCP stream и не должен его извлекать или логировать;
|
||||
- основной lookup в Device Plane может оставаться HMAC digest; полный IMEI разрешён в restricted projection там, где он действительно нужен.
|
||||
|
||||
IMEI по-прежнему не является доказательством ownership. Он является идентификатором для lookup/crosswalk, а claim/tenant assignment остаются административным действием.
|
||||
|
||||
## 4. Фактическая production read-цепочка
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
B2["ARUSNAVI B2"] --> GELIOS["Gelios monitoring route"]
|
||||
GELIOS --> L2["Engine alxvw4fn\nGELIOS — REALTIME POSITIONS"]
|
||||
L2 --> EDP["EDP / Timescale\nfleet.positions.current.v5"]
|
||||
EDP --> FOUNDRY["Foundry\nRobot2B Trike Positions"]
|
||||
|
||||
B2 -. "Direct route пока не подключён" .-> VPS["VPS Device Edge\nfoundation accepted"]
|
||||
VPS -. "backhaul/relay gated" .-> GW["Synology Device Gateway"]
|
||||
GW -. "PACKAGE сейчас ACK + discard" .-> STOP["Нет decoder / EDP publish"]
|
||||
```
|
||||
|
||||
### 4.1 Live Engine
|
||||
|
||||
Production owner позиции и телеметрии:
|
||||
|
||||
- L1 workflow: `WCb62yGL8v`;
|
||||
- target: `alxvw4fn`;
|
||||
- name: `GELIOS — REALTIME POSITIONS`;
|
||||
- revision: `68f4c852b8bc25b99c40b22fb4ca724fa6a0a34adbff74d1224b12ca7087bad8`;
|
||||
- runtime workflow: `RupsyGGlawBzmUFm`;
|
||||
- runtime active;
|
||||
- schedule: каждые 10 секунд;
|
||||
- рабочий request: `GET /api/v1/units` с `incltrip=true`, `inclcntrs=true`, `inclsnsrs=true`, `incllsv=true`;
|
||||
- writer bindings и Gelios rotating credential зарегистрированы со status `ok`.
|
||||
|
||||
Во время проверки executions завершались успешно примерно за 5–10 секунд. Execution `1297364` был `success` и дал структурный профиль 107 subjects.
|
||||
|
||||
Normalized fact содержит:
|
||||
|
||||
- `sourceId = gelios-unit-<provider unit id>`;
|
||||
- `semanticType = map.moving_object`;
|
||||
- geometry Point;
|
||||
- `display_name`;
|
||||
- `position_source = gelios`;
|
||||
- `signal_state`;
|
||||
- `movement_state`;
|
||||
- speed/course/elevation;
|
||||
- satellite count, HDOP/accuracy при наличии;
|
||||
- mileage, engine hours;
|
||||
- `sensor_readings`.
|
||||
|
||||
Live telemetry catalog execution `1297364` подтвердил 17 безопасных reading IDs без rejected/unsupported values:
|
||||
|
||||
```text
|
||||
sensor.param.call_btn
|
||||
sensor.param.gps_mod
|
||||
sensor.param.gsm
|
||||
sensor.param.gsm_level
|
||||
sensor.param.gsm_st
|
||||
sensor.param.gyro
|
||||
sensor.param.ign_virt
|
||||
sensor.param.in_0
|
||||
sensor.param.in_1
|
||||
sensor.param.in0
|
||||
sensor.param.in1
|
||||
sensor.param.nav_st
|
||||
sensor.param.pwr_ext
|
||||
sensor.param.pwr_int
|
||||
sensor.param.sim1_st
|
||||
sensor.param.sim2_st
|
||||
sensor.param.v_in
|
||||
```
|
||||
|
||||
`in_0` и `in_1` имеют provider-configured label conflict; это уже отражено в telemetry catalog и не должно скрываться при Direct comparison.
|
||||
|
||||
### 4.2 Инертный duplicate target
|
||||
|
||||
Target `g7q86421` (`gelios.positions.current.realtime.v7`) не является вторым production writer.
|
||||
|
||||
В его live graph соединены только:
|
||||
|
||||
```text
|
||||
manual trigger
|
||||
→ monitoring config
|
||||
→ units request
|
||||
→ extraction
|
||||
→ ontology map
|
||||
```
|
||||
|
||||
Scheduled trigger и `NDC Data Product Publish • fleet.positions.current.v5` физически отсоединены. Schedule всё ещё создаёт короткие успешные пустые executions примерно раз в две минуты, но публикации не выполняются.
|
||||
|
||||
Это подтверждает текущий правильный инвариант: у `fleet.positions.current.v5` один активный producer path.
|
||||
|
||||
### 4.3 Engine platform debt
|
||||
|
||||
При будущей переработке L2 нельзя игнорировать открытые карточки:
|
||||
|
||||
- `DCPLATFORM-72`: UI `node.parameters` и executable `data.n8n.parameters` могут расходиться;
|
||||
- `DCPLATFORM-73`: managed writer не всегда корректно перепривязывается к новой graph revision без revoke/recreate.
|
||||
|
||||
Любое изменение arbiter graph требует post-write graph equality, deep validation, свежего execution и exact writer acceptance. Успешный patch preview сам по себе недостаточен.
|
||||
|
||||
## 5. Live Foundry
|
||||
|
||||
Application:
|
||||
|
||||
- ID: `1c7dcdbb-6e50-4272-b1bc-aa5ece77ae99`;
|
||||
- name: `Robot2B Trike Positions`;
|
||||
- slug: `robot2b-trike-positions`;
|
||||
- status: `draft`;
|
||||
- version: `0.1.0`;
|
||||
- одна page `map` на `/`;
|
||||
- Map template `map@0.1.0`;
|
||||
- updated: `2026-08-05T17:29:28.077Z`.
|
||||
|
||||
Live server-owned consumers:
|
||||
|
||||
| Binding | Product | Subjects | Cursor | Last error |
|
||||
|---|---|---:|---:|---|
|
||||
| `trike-current-positions` | `fleet.positions.current.v5` | 107 | 124720 | null |
|
||||
| `trike-unit-profile` | `fleet.units.profile.current.v1` | 107 | 74 | null |
|
||||
| `trike-unit-contacts` | `fleet.units.contacts.current.v1` | 107 | 55 | null |
|
||||
| `trike-unit-identity` | `fleet.units.identity.current.v1` | 107 | 3651 | null |
|
||||
| `depttrans-pmd-slow-zones` | `map.zones.current.v2` | 903 | 10 | null |
|
||||
|
||||
Все consumers включены и используют `target-scoped-server-only` reader grant. Positions consumer получил свежий patch во время аудита.
|
||||
|
||||
Существующее представление уже provider-neutral по renderer и composition:
|
||||
|
||||
- один primary moving-object binding;
|
||||
- profile/identity joins выполняются по стабильному `sourceId`;
|
||||
- в Data tab уже показываются `sourceId`, `semanticType`, `position_source`, `dataProductId`, `receivedAt`;
|
||||
- отдельный Direct pin или отдельная Direct Map не нужны.
|
||||
|
||||
Чего нет:
|
||||
|
||||
- source-selection mode `primary|fallback|shadow|stale`;
|
||||
- причина выбора источника;
|
||||
- последнее Direct и последнее Gelios observation одновременно;
|
||||
- source health/freshness;
|
||||
- source badge/facet/counter;
|
||||
- Arnavi/Direct contract;
|
||||
- Device Manager Page.
|
||||
|
||||
Consumer policy v5 имеет `freshness=none`, `staleAfterMs=null` и `staleTransitions=0`. Следовательно, Foundry сам не определит падение VPS: selection и freshness должны приходить из server-owned upstream contract.
|
||||
|
||||
Отдельная визуальная деталь: сохранённый state `trike-current-positions` имеет `visible=true`, но filters `movement_state=[]` и `signal_state=[]`. По контракту Foundry пустой массив matches nothing. Это может объяснять пустую Map при наличии 107 subjects. Состояние не изменялось в ходе аудита; перед visual acceptance надо отдельно подтвердить, намеренно ли сохранён explicit empty view.
|
||||
|
||||
`trike-unit-contacts` выглядит избыточным: subject detail profile его не использует, а нужные IMEI/contacts уже присутствуют в `unit_identity`. Удалять binding до отдельного UI review не следует.
|
||||
|
||||
## 6. Live Ontology
|
||||
|
||||
Live catalog:
|
||||
|
||||
- hash: `cdf3f5310359cb36`;
|
||||
- 204 entities;
|
||||
- 183 relations;
|
||||
- 143 aliases;
|
||||
- 46 guardrails;
|
||||
- 41 blocked conflations.
|
||||
|
||||
Provider-neutral каркас уже существует:
|
||||
|
||||
```text
|
||||
integration.provider
|
||||
integration.connection
|
||||
integration.collection_profile
|
||||
integration.collection_run
|
||||
integration.raw_envelope
|
||||
integration.canonical_subject
|
||||
integration.read_model
|
||||
integration.realtime_channel
|
||||
map.moving_object
|
||||
map.state_facet
|
||||
```
|
||||
|
||||
Поэтому не нужен новый renderer, новая Map, новый сайт или отдельная пользовательская сущность `Arnavi trike`.
|
||||
|
||||
Но семантический разрыв реальный:
|
||||
|
||||
- tracker entity есть только как `gelios.tracker_device`;
|
||||
- IMEI/device identifier не определён provider-neutral;
|
||||
- Arnavi/ARUSNAVI provider package отсутствует;
|
||||
- source selection/failover semantics отсутствуют;
|
||||
- `gelios.telemetry_snapshot`, `gelios.signal_state` и `gelios.movement_state` имеют Gelios-specific authority.
|
||||
|
||||
Нельзя публиковать Direct B2 под видом Gelios mapping. Особенно нельзя молча объявить Direct `signal_state` результатом Gelios monitoring-config или добавить `fallback/stale` в закрытые `active|inactive` / `moving|stopped` enum.
|
||||
|
||||
Минимальное canonical изменение — не новая бизнес-сущность, а:
|
||||
|
||||
1. source-evidenced ARUSNAVI B2 provider/mapping package;
|
||||
2. provider-neutral policy для выбранного источника и freshness;
|
||||
3. нейтральное определение `signal_state`/`movement_state` для successor product;
|
||||
4. restricted device identifier/crosswalk semantics по открытой `DCPLATFORM-70`.
|
||||
|
||||
## 7. Текущий Direct B2 код
|
||||
|
||||
### 7.1 Что реализовано
|
||||
|
||||
ARUSNAVI adapter реализует:
|
||||
|
||||
- HEADER2 `FF 23`;
|
||||
- 8-byte little-endian IMEI;
|
||||
- 15-digit validation;
|
||||
- PACKAGE boundaries;
|
||||
- packet length и checksum;
|
||||
- HEADER acknowledgement с Unix time;
|
||||
- package-number acknowledgement;
|
||||
- bounded frame/buffer limits;
|
||||
- commands disabled.
|
||||
|
||||
Основные source anchors:
|
||||
|
||||
- `platform/device-plane/packages/arusnavi-b2-adapter/src/index.mjs:56` — HEADER2;
|
||||
- `.../index.mjs:93` — HEADER ACK;
|
||||
- `.../index.mjs:110` — PACKAGE framing/checksum;
|
||||
- `.../index.mjs:187` — PACKAGE ACK;
|
||||
- `platform/device-plane/services/device-gateway/src/runtime.mjs:144` — session state machine.
|
||||
|
||||
Device Plane test suite: 41 passed, 0 failed.
|
||||
|
||||
### 7.2 Критический ACK/data-loss gap
|
||||
|
||||
`tryParseB2Package()` возвращает только:
|
||||
|
||||
```text
|
||||
bytesConsumed
|
||||
packageNumber
|
||||
packetCount
|
||||
```
|
||||
|
||||
Он не возвращает packet data или decoded tags.
|
||||
|
||||
Gateway после успешного parse:
|
||||
|
||||
```text
|
||||
buffer = buffer after package
|
||||
ACK counter++
|
||||
send PACKAGE ACK
|
||||
```
|
||||
|
||||
Package sink, durable queue, EDP publish и decoder отсутствуют. Иными словами, текущий Gateway сообщает B2 «пакет принят», после чего payload теряется.
|
||||
|
||||
Этот режим допустим только как discovery/framing pilot. Он непригоден для Direct-primary telemetry.
|
||||
|
||||
### 7.3 Реализован только quarantine observe
|
||||
|
||||
Core имеет только:
|
||||
|
||||
```text
|
||||
POST /internal/v1/device-discoveries:observe
|
||||
```
|
||||
|
||||
Он HMAC-хэширует IMEI, создаёт/обновляет quarantine discovery и отдаёт masked projection. Claim endpoint, inventory import и device CRUD отсутствуют.
|
||||
|
||||
Дополнительный blocker: Gateway Core client принимает только response `lifecycleState=quarantine`. Если discovery будет переведён в `claimed`, текущий client отклонит response, а HEADER ACK не будет отправлен.
|
||||
|
||||
### 7.4 Нет связи device → canonical trike
|
||||
|
||||
`device_bindings` сейчас содержит только:
|
||||
|
||||
```text
|
||||
contour_id
|
||||
target_kind
|
||||
target_ref
|
||||
capabilities
|
||||
```
|
||||
|
||||
В нём нет `device_id`. В `device_instances` нет `canonical_subject_ref`.
|
||||
|
||||
Следовательно, из одного contour binding нельзя доказать, какой IMEI соответствует какому `gelios-unit-*`. Без explicit crosswalk Direct publisher либо создаст дубликаты, либо присвоит наблюдение неправильному трайку.
|
||||
|
||||
Минимальное исправление без новой доменной сущности:
|
||||
|
||||
- добавить `canonical_subject_ref` к существующему `device_instance`/claim contract;
|
||||
- сопоставлять IMEI digest с restricted Gelios identity inventory;
|
||||
- сохранять существующий `gelios-unit-*` как canonical `sourceId` на переходном этапе;
|
||||
- неизвестный/duplicate IMEI оставлять unmatched quarantine;
|
||||
- автоматический matcher может только предложить mapping; принятие принадлежности остаётся explicit admin action.
|
||||
|
||||
## 8. Почему Mini artifacts нельзя применить к VPS
|
||||
|
||||
Mini deployment жёстко фиксирует:
|
||||
|
||||
- runtime host `ndcmini12`;
|
||||
- NIC `enp1s0f0`;
|
||||
- IPvlan `192.168.71.253`;
|
||||
- LAN `192.168.68.0/22`;
|
||||
- gateway `192.168.68.1`;
|
||||
- Mini host `192.168.68.54/22`;
|
||||
- Amnezia routes;
|
||||
- userspace Tailnet SOCKS container;
|
||||
- Deco/double-NAT topology.
|
||||
|
||||
Эти assumptions находятся в:
|
||||
|
||||
- `docker-compose.device-edge.ingress.yml`;
|
||||
- `deployment/device-edge-ingress-ipvlan-v1.json`;
|
||||
- `deployment/device-edge-admission-gate-v1.json`;
|
||||
- `deployment/device-edge-backhaul-v1.json`;
|
||||
- `infra/deploy-runner/build-device-edge-ingress-artifact.mjs`;
|
||||
- `infra/deploy-runner/nodedc-edge-deploy`;
|
||||
- `docs/DEVICE_EDGE_B2_PUBLIC_PILOT_RUNBOOK.md`.
|
||||
|
||||
Переиспользовать можно relay implementation и fail-closed limits. Переиспользовать Mini Compose overlay, descriptor, builder или runner нельзя.
|
||||
|
||||
VPS требует отдельный versioned placement/transition того же логического component `device-edge`, с собственными:
|
||||
|
||||
- exact host identity и predecessor;
|
||||
- public interface/bind;
|
||||
- firewall contract;
|
||||
- Tailscale/WireGuard route;
|
||||
- SSH backhaul trust;
|
||||
- resource limits;
|
||||
- acceptance;
|
||||
- backup/journal/rollback.
|
||||
|
||||
Это additive extension `DCPLATFORM-21`, а не ручной `docker compose up` и не ослабление канона.
|
||||
|
||||
## 9. Целевая архитектура
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
B2["ARUSNAVI B2\nдва server slot"]
|
||||
|
||||
B2 -->|"INTERNAL raw TCP"| GELIOS["Gelios legacy read route"]
|
||||
B2 -->|"INTERNAL raw TCP :9921"| VPS["VPS Device Edge\nopaque relay only"]
|
||||
|
||||
VPS -->|"encrypted private backhaul"| GW["Synology Device Gateway\nframing + decoder + ACK owner"]
|
||||
GW --> CORE["Device Control Core\nclaim + canonical subject crosswalk"]
|
||||
GW -->|"after decode and durable acceptance"| DIRECT["Direct candidate Data Product\nworking contract"]
|
||||
|
||||
GELIOS --> GL2["Existing Gelios L2 alxvw4fn"]
|
||||
GL2 --> V5["fleet.positions.current.v5\nGelios candidate + legacy history"]
|
||||
|
||||
DIRECT --> ARB["Server-owned per-device arbiter"]
|
||||
V5 --> ARB
|
||||
ARB --> V6["fleet.positions.current.v6\none canonical writer"]
|
||||
V6 --> MAP["Existing Foundry Map binding\nsame page/profile/sourceId"]
|
||||
|
||||
CORE -. "future, separate red domain" .-> CMD["Commands disabled"]
|
||||
```
|
||||
|
||||
### 9.1 Что остаётся существующим
|
||||
|
||||
- Device Plane PostgreSQL/Core/Gateway на Synology;
|
||||
- EDP/Timescale;
|
||||
- Robot2B L1;
|
||||
- Gelios collector `alxvw4fn`;
|
||||
- `fleet.positions.current.v5` как legacy candidate и immutable pre-cutover history;
|
||||
- Foundry application, page, presentation profile и detail profile;
|
||||
- `sourceId = gelios-unit-*` как переходная canonical identity;
|
||||
- profile/identity products и joins;
|
||||
- Gelios route на B2.
|
||||
|
||||
### 9.2 Какие технические контракты неизбежно добавляются
|
||||
|
||||
«Без новых сущностей» достижимо на уровне продукта/UI: не нужны новый сайт, новый backend, новая DB, новая Map или второй трайк.
|
||||
|
||||
Но полностью без новых технических контрактов нельзя:
|
||||
|
||||
- нужен Direct candidate product или другой durable server-owned candidate store;
|
||||
- нужен ARUSNAVI provider/mapping package;
|
||||
- нужен один canonical arbiter writer scope;
|
||||
- нужен successor `fleet.positions.current.v6`, потому что v5 не разрешает поля selection/fallback provenance и имеет старую семантическую authority;
|
||||
- нужен exact device → canonical subject field/claim contract.
|
||||
|
||||
Это versioning и integration configuration, а не новые пользовательские domain objects.
|
||||
|
||||
### 9.3 Где должен жить arbiter
|
||||
|
||||
Не на VPS и не в Foundry.
|
||||
|
||||
Engine подходит для чтения candidate products, нормализации и orchestration. EDP должен оставаться durable state и единственным publish authority. Практический вариант:
|
||||
|
||||
1. Gelios продолжает писать v5.
|
||||
2. Gateway/Direct adapter пишет отдельный candidate product после durable acceptance.
|
||||
3. Arbiter L2 читает оба products отдельными managed reader grants.
|
||||
4. Arbiter читает предыдущий canonical state для hysteresis или хранит selection state в каноническом fact.
|
||||
5. Только arbiter имеет writer grant на v6.
|
||||
|
||||
Существующий `g7q86421` можно рассмотреть как runtime slot для arbiter только после neutral rematerialization. Сейчас он Gelios-scoped; публиковать Direct под его Gelios connection нельзя. Если Engine не позволяет безопасно сменить connection authority, нужен новый служебный L2 target внутри существующего Robot2B L1. Это не новая бизнес-сущность.
|
||||
|
||||
### 9.4 Правило выбора источника
|
||||
|
||||
Выбор выполняется по каждому `canonical_subject_ref`.
|
||||
|
||||
Direct eligible, только если одновременно выполнены:
|
||||
|
||||
- device claimed и crosswalk однозначен;
|
||||
- HEADER/PACKAGE framing и checksum валидны;
|
||||
- telemetry packet decoded по versioned ARUSNAVI contract;
|
||||
- observed time валиден и не уходит недопустимо в будущее;
|
||||
- observation монотонно либо явно допустимо out-of-order;
|
||||
- EDP candidate receipt durable;
|
||||
- direct observation свежее per-device threshold;
|
||||
- Gateway/backhaul не сообщает terminal fault.
|
||||
|
||||
Fallback:
|
||||
|
||||
```text
|
||||
direct stale or invalid
|
||||
→ grace period
|
||||
→ select latest valid Gelios observation
|
||||
→ selection_mode=fallback
|
||||
```
|
||||
|
||||
Возврат:
|
||||
|
||||
```text
|
||||
direct returns
|
||||
→ shadow only
|
||||
→ N consecutive valid observations / accepted time window
|
||||
→ identity and timestamp checks pass
|
||||
→ selection_mode=primary
|
||||
```
|
||||
|
||||
Точные grace/N/timeout не следует угадывать. Их надо вывести из pilot packet cadence и shadow statistics.
|
||||
|
||||
Первый arbiter должен выбирать цельный position+telemetry observation атомарно. Не следует молча смешивать координаты Direct с sensor readings Gelios в одном fact. Cold profile и restricted identity могут временно продолжать приходить из Gelios отдельными joined aspects.
|
||||
|
||||
### 9.5 Provenance successor product
|
||||
|
||||
Рабочий набор полей v6:
|
||||
|
||||
```text
|
||||
position_source
|
||||
telemetry_source
|
||||
source_selection_mode
|
||||
source_selection_reason
|
||||
selected_observed_at
|
||||
direct_last_observed_at
|
||||
gelios_last_observed_at
|
||||
selection_changed_at
|
||||
```
|
||||
|
||||
Точные field IDs и enum должны сначала пройти Ontology/Data Product authority. `fallback` нельзя перегружать в `signal_state`.
|
||||
|
||||
Для визуальной совместимости можно сохранить enum:
|
||||
|
||||
```text
|
||||
signal_state = active | inactive
|
||||
movement_state = moving | stopped
|
||||
```
|
||||
|
||||
Но v6 должен определить их как neutral Robot2B policy. Direct `signal_state` выводится из свежести direct observation, а не из Gelios monitoring-config. Movement threshold, если сохраняется `speed > 2`, должен быть явно принят как platform policy, а не назван «нативным B2 статусом».
|
||||
|
||||
### 9.6 История
|
||||
|
||||
Timescale/Postgres остаётся тем же сервисом и volume, но история EDP scoped по:
|
||||
|
||||
```text
|
||||
tenant + connection + provider + dataProductId + sourceId
|
||||
```
|
||||
|
||||
Поэтому смена writer connection/product не создаёт автоматическую бесшовную history query через старые v5 rows.
|
||||
|
||||
Правильный cutover:
|
||||
|
||||
- v5 history остаётся immutable legacy history до даты переключения;
|
||||
- v6 пишет новую canonical history с тем же `sourceId`;
|
||||
- `NDC Robot2B History and Reports` получает version-aware read: v5 до cutover, v6 после cutover;
|
||||
- никакой второй Timescale DB и никакая destructive migration не создаются.
|
||||
|
||||
## 10. Durable ACK contract
|
||||
|
||||
Production Direct path должен иметь такой порядок:
|
||||
|
||||
```text
|
||||
HEADER2
|
||||
→ resolve/create discovery
|
||||
→ resolve claimed device and canonical subject when available
|
||||
→ HEADER ACK
|
||||
|
||||
PACKAGE
|
||||
→ validate length/checksum
|
||||
→ decode packet tags
|
||||
→ normalize candidate observation
|
||||
→ idempotent durable EDP acceptance
|
||||
→ PACKAGE ACK
|
||||
```
|
||||
|
||||
Если durable acceptance недоступен, PACKAGE ACK не отправляется, и B2 получает возможность повторить пакет.
|
||||
|
||||
Idempotency нельзя строить только по package number: диапазон ограничен и номер переиспользуется. Candidate key должен включать device identity, session/package context и digest подтверждённых bytes/packet timestamp. Raw payload не обязан сохраняться; можно сохранять digest и normalized facts.
|
||||
|
||||
Для quarantine pilot допустим отдельный режим framing proof: validated PACKAGE ACK после безопасного quarantine evidence без заявления, что телеметрия сохранена. Этот режим должен быть явно отличим от production telemetry acceptance.
|
||||
|
||||
Gateway, а не VPS, остаётся ACK owner.
|
||||
|
||||
## 11. VPS contract
|
||||
|
||||
### 11.1 Runtime role
|
||||
|
||||
VPS:
|
||||
|
||||
- слушает public `TCP/9921`;
|
||||
- применяет bounded session/rate/buffer policy;
|
||||
- непрозрачно передаёт stream на private backhaul;
|
||||
- держит минимальный loopback/internal health;
|
||||
- не читает IMEI и telemetry;
|
||||
- не ACK’ает B2;
|
||||
- не хранит raw packet или DB;
|
||||
- не имеет Core/EDP/Engine/Foundry credentials;
|
||||
- не выполняет commands.
|
||||
|
||||
### 11.2 Предлагаемый минимальный placement
|
||||
|
||||
На слабом VPS достаточно:
|
||||
|
||||
- host Tailscale или WireGuard;
|
||||
- `device-edge-relay` container;
|
||||
- `device-edge-backhaul` container с key-only SSH local forward в Synology target;
|
||||
- Docker Compose plugin;
|
||||
- system firewall default-deny;
|
||||
- time sync;
|
||||
- bounded Docker logs;
|
||||
- root-owned deploy runner и state outside artifact.
|
||||
|
||||
Live baseline подтверждает, что host нельзя использовать для production image builds или тяжёлого runtime. Начальные resource limits для reviewed candidate:
|
||||
|
||||
- relay memory limit около 192 MiB;
|
||||
- backhaul около 64 MiB;
|
||||
- PID limits;
|
||||
- logs `10 MiB × 3`;
|
||||
- image build не выполнять на production host, если 1 GiB RAM не выдерживает; использовать reviewed prebuilt/digest-pinned image или swap policy, утверждённую отдельно.
|
||||
|
||||
До application artifact нужен отдельный bootstrap transition, который fail-closed переводит host из текущего baseline:
|
||||
|
||||
- firewall INPUT `ACCEPT` → default-deny с сохранением проверенного SSH access;
|
||||
- root/password/X11/unrestricted forwarding → отдельный key-only management boundary;
|
||||
- установить только утверждённый container/runtime и encrypted backhaul prerequisites;
|
||||
- создать versioned service identities, fixed roots, bounded logs/resources и rollback;
|
||||
- не открывать `9921` в bootstrap transition.
|
||||
|
||||
Текущие Mini defaults `10 s session timeout` и `256 KiB per direction` являются pilot constraints. Их нельзя автоматически переносить в production B2: packet cadence и session lifetime должны быть измерены на одном реальном B2.
|
||||
|
||||
### 11.3 Public surface
|
||||
|
||||
Наружу:
|
||||
|
||||
- `9921/TCP` для B2;
|
||||
- management SSH только key-only и максимально ограниченно, предпочтительно через Tailnet/allowlist.
|
||||
|
||||
Не публикуются:
|
||||
|
||||
- health endpoint;
|
||||
- Docker API;
|
||||
- UDP range;
|
||||
- Core/Gateway/EDP ports;
|
||||
- database;
|
||||
- DSM;
|
||||
- reverse proxy `443 → 9921`.
|
||||
|
||||
### 11.4 DNS
|
||||
|
||||
DNS меняется только после:
|
||||
|
||||
1. exact VPS identity;
|
||||
2. canonical deploy-ok;
|
||||
3. public listener/firewall acceptance;
|
||||
4. encrypted backhaul acceptance;
|
||||
5. external synthetic TCP proof;
|
||||
6. rollback proof.
|
||||
|
||||
Только затем `device.nodedc.ru` переводится с `95.165.91.235` на VPS. Изменение B2 server slot выполняется после DNS convergence и только для одного pilot; Gelios slot не трогается.
|
||||
|
||||
## 12. Фазовый план
|
||||
|
||||
### Phase 0 — authority и точный VPS target
|
||||
|
||||
- точный target `root@155.212.211.15` получен;
|
||||
- pinned ED25519 fingerprint проверен;
|
||||
- live read-only inventory завершён;
|
||||
- VPS placement зафиксирован в `DCPLATFORM-21` и `DCPLATFORM-74`;
|
||||
- не менять DNS, B2 и Synology.
|
||||
|
||||
### Phase 1 — canonical VPS bootstrap
|
||||
|
||||
- отдельный VPS `device-edge` descriptor/runner;
|
||||
- install Docker/Compose, runtime user, firewall, time sync, log policy, private network client;
|
||||
- deploy relay/backhaul exact artifact;
|
||||
- prove resource limits, ports, health, backup/journal и automatic rollback;
|
||||
- terminal state только `deploy-ok`.
|
||||
|
||||
### Phase 2 — transport acceptance без трекера
|
||||
|
||||
- synthetic TCP from external host → VPS `9921` → encrypted backhaul → loopback Gateway;
|
||||
- prove VPS does not parse/ACK;
|
||||
- prove Synology remains non-public;
|
||||
- prove closing/rollback removes only VPS `9921` and preserves Gelios;
|
||||
- no DNS/B2 change yet.
|
||||
|
||||
### Phase 3 — один B2, quarantine shadow
|
||||
|
||||
- вручную добавить свободный B2 INTERNAL slot на accepted VPS address/hostname;
|
||||
- Gelios slot сохранить;
|
||||
- доказать HEADER2 → masked quarantine;
|
||||
- доказать PACKAGE framing/checksum/ACK;
|
||||
- commands remain disabled;
|
||||
- не объявлять telemetry production, пока PACKAGE всё ещё discard.
|
||||
|
||||
### Phase 4 — decoder, claim и durable candidate
|
||||
|
||||
- принять точную официальную tag/framing specification для firmware pilot B2;
|
||||
- parser возвращает packet data/typed records;
|
||||
- реализовать explicit claim/crosswalk в существующем Device Instance contract;
|
||||
- исправить Gateway/Core lifecycle для claimed devices;
|
||||
- добавить Direct candidate product и managed writer;
|
||||
- ACK только после durable candidate receipt;
|
||||
- unknown tags fail closed или попадают в bounded classification evidence, но не в unrestricted Data Product.
|
||||
|
||||
### Phase 5 — shadow comparison
|
||||
|
||||
Для одного pilot сравнивать Direct и Gelios без canonical switch:
|
||||
|
||||
- identity match;
|
||||
- observation timestamps и clock skew;
|
||||
- coordinate distance;
|
||||
- speed/course/elevation;
|
||||
- satellites/HDOP/accuracy;
|
||||
- mileage/engine hours;
|
||||
- все 17 текущих telemetry reading IDs;
|
||||
- missing/extra tags;
|
||||
- duplicate/retry/out-of-order behavior;
|
||||
- reconnect cadence и session lifetime.
|
||||
|
||||
До этой фазы утверждение «Gelios отдаёт ровно те же B2 пакеты» не считается доказанным. Сейчас доказано лишь, что Gelios предоставляет данные, относящиеся к B2 units, и текущий normalized surface совпадает с ожидаемым B2 профилем. Exact packet equivalence требует Direct shadow.
|
||||
|
||||
### Phase 6 — arbiter и v6
|
||||
|
||||
- neutral Ontology/source-selection contract;
|
||||
- per-device eligibility, grace и hysteresis;
|
||||
- один arbiter writer;
|
||||
- successor v6;
|
||||
- history cutover policy;
|
||||
- no field-level silent mixing;
|
||||
- simulate Direct failure and recovery;
|
||||
- Gelios continues collecting at all times.
|
||||
|
||||
### Phase 7 — Foundry cutover
|
||||
|
||||
- существующий `trike-current-positions` binding переводится на v6;
|
||||
- page, binding ID, presentation/detail profiles и sourceId сохраняются;
|
||||
- Data tab получает selection provenance;
|
||||
- optional source badge/facet добавляется только по versioned field contract;
|
||||
- решить explicit empty filters;
|
||||
- проверить все joins profile/identity и 107 subjects;
|
||||
- rollback возвращает binding на v5.
|
||||
|
||||
### Phase 8 — эксплуатационная приёмка
|
||||
|
||||
- sustained Direct-primary window;
|
||||
- controlled VPS/backhaul/Gateway failure → per-device Gelios fallback;
|
||||
- controlled recovery → shadow → Direct promotion;
|
||||
- no duplicate current facts/history;
|
||||
- no timestamp regression;
|
||||
- bounded logs/resources;
|
||||
- alerts/metrics без full IMEI;
|
||||
- documented rollback.
|
||||
|
||||
### Phase 9 — команды, отдельная работа
|
||||
|
||||
Только после принятого Direct read path:
|
||||
|
||||
- official command contract;
|
||||
- draft/plan/confirm/queue/dispatch/ACK/reconciliation;
|
||||
- immutable audit и idempotency;
|
||||
- no automatic Gelios command fallback;
|
||||
- no blind retry after unknown outcome.
|
||||
|
||||
## 13. Ops reconciliation
|
||||
|
||||
Фактическое значение карточек после аудита:
|
||||
|
||||
- `DCPLATFORM-74` — master architecture; Mini placement устарел, VPS должен стать новым accepted Device Edge placement;
|
||||
- `ROBOT2B-5` — старый pause superseded: Direct track возобновлён, SSH access подтверждён, но работа пока blocked на canonical VPS bootstrap/backhaul и Direct data path gaps;
|
||||
- `ROBOT2B-6` — current production Gelios baseline; теперь это сохраняемый legacy candidate/fallback, а не конечная архитектура;
|
||||
- `DCPLATFORM-21` — authority для отдельного VPS runner/artifact/rollback;
|
||||
- `DCPLATFORM-70` — provider-neutral device/identifier/crosswalk debt;
|
||||
- `DCPLATFORM-72` и `DCPLATFORM-73` — обязательные Engine change acceptance risks.
|
||||
|
||||
Актуализация записана в Ops 6 августа 2026 года:
|
||||
|
||||
- `DCPLATFORM-74`: comment `fcdb49c3-8cfc-4c94-8e67-137de14ec9f2`;
|
||||
- `ROBOT2B-5`: comment `eb6284b5-4d0e-4064-8eda-659485b94115`, карточка переведена из Backlog в In Progress;
|
||||
- `ROBOT2B-6`: comment `5fecce6e-fec0-4119-814e-0e2bb7a6a41e`;
|
||||
- `DCPLATFORM-21`: comment `c9bbe692-d301-4127-8734-9c1e91bb5083`.
|
||||
|
||||
После live SSH-аудита VPS добавлены уточнения:
|
||||
|
||||
- `DCPLATFORM-74`: comment `fa26ac68-17c2-4d56-8b6b-f483b1275739`;
|
||||
- `DCPLATFORM-21`: comment `8db0d7e2-1bb4-4390-93e3-aebb68429a1b`;
|
||||
- `ROBOT2B-5`: comment `750aeab7-b2eb-4ea1-bb4a-e6e098c5a02c`.
|
||||
|
||||
Ops не должен утверждать, что:
|
||||
|
||||
- Direct telemetry уже принимается;
|
||||
- VPS relay/bootstrap уже развёрнут и принят;
|
||||
- `device.nodedc.ru` уже указывает на VPS;
|
||||
- Device Manager Page существует;
|
||||
- IMEI полностью отсутствует в Foundry;
|
||||
- текущий Gateway выполняет durable PACKAGE acceptance;
|
||||
- Gelios и Direct уже переключаются автоматически.
|
||||
|
||||
## 14. Текущие блокеры
|
||||
|
||||
1. VPS firewall/SSH baseline не hardened и не принят canonical runner-ом.
|
||||
2. Не выбран и не enrolled exact encrypted VPS → private NODE.DC backhaul identity/target.
|
||||
3. Current DNS указывает на Synology.
|
||||
4. Mini deploy artifacts не переносимы на VPS.
|
||||
5. Gateway ACK’ает и отбрасывает PACKAGE.
|
||||
6. Нет telemetry tag decoder.
|
||||
7. Нет claim endpoint и claimed-device handshake.
|
||||
8. Нет device → canonical trike crosswalk.
|
||||
9. Нет Direct candidate product/writer.
|
||||
10. Нет neutral source-selection ontology/product contract.
|
||||
11. Нет arbiter и successor product/history cutover.
|
||||
12. Foundry source provenance минимальна, а saved filters могут скрывать все subjects.
|
||||
13. Команды намеренно выключены.
|
||||
|
||||
## 15. Следующее разрешённое действие
|
||||
|
||||
Следующий шаг — зафиксировать exact encrypted backhaul choice/identity и подготовить additive VPS deployment domain: root-owned runner, deterministic bootstrap artifact, exact predecessor, firewall/SSH acceptance и rollback. Первый bootstrap не открывает `9921`. Никакой B2, DNS или production Map mutation до отдельного transport acceptance не требуется.
|
||||
|
||||
## 16. Implementation update — 2026-08-06
|
||||
|
||||
Этот раздел заменяет устаревшие operational assertions в разделах 3.4, 11.2,
|
||||
12 Phase 0–1, 13–15. Архитектурные выводы остальных разделов сохраняются.
|
||||
|
||||
Реализовано:
|
||||
|
||||
- созданы отдельные Ops cards `DCPLATFORM-75` и `ROBOT2B-7`;
|
||||
- создан отдельный root-owned VPS deploy domain
|
||||
`/usr/local/sbin/nodedc-b2-vps-deploy`;
|
||||
- Docker на 961 MiB VPS сознательно не устанавливался;
|
||||
- foundation `device-edge-vps-foundation-20260806-003` принят с `deploy-ok`;
|
||||
- SSH переведён в key-only, nftables — в default-deny, публично только TCP/22;
|
||||
- Node.js 22.23.2 и Tailscale 1.102.2 установлены из pinned static archives;
|
||||
- Tailscale runtime user `nodedc-edge` и отдельный ED25519 backhaul key созданы
|
||||
runner-ом; isolation-aware backhaul/relay transitions используют отдельные
|
||||
`nodedc-backhaul` и `nodedc-relay`;
|
||||
- default wildcard tailnet grant удалён; VPS переведён из user ownership в
|
||||
`tag:device-edge-vps` с единственным egress `100.109.216.21:2222/tcp`;
|
||||
- отрицательные проверки с VPS подтвердили запрет Synology SSH/DSM, MacBook и
|
||||
второго edge-узла;
|
||||
- TCP/9921 остаётся закрыт;
|
||||
- backhaul и relay artifacts собраны и стадированы, но не применены до
|
||||
predecessor acceptance;
|
||||
- public VPS key стадирован в Synology enrollment;
|
||||
- marker-only Synology key-rotation artifact и runner candidate стадированы.
|
||||
|
||||
Текущие blockers/gates:
|
||||
|
||||
1. Root на Synology должен promote/verify exact runner, review plan и выполнить
|
||||
exact enrollment apply.
|
||||
2. После этого VPS runner может принять backhaul, затем relay.
|
||||
3. DNS и B2 routes остаются неизменными до отдельного transport pilot.
|
||||
4. Provider recovery key `beget-access-key` — RSA-1024; его отзыв требует
|
||||
отдельного owner decision.
|
||||
|
||||
Полный повторяемый manual и release evidence находятся в
|
||||
`device-plane/docs/ROBOT2B_B2_VPS_CONFIGURATION_BIBLE_2026-08-06.md`.
|
||||
@@ -0,0 +1,287 @@
|
||||
# Device Plane Implementation Baseline
|
||||
|
||||
> Superseded topology notice — 2026-08-10
|
||||
>
|
||||
> The historical Foundry-Page product boundary, Mini ingress, VPS-initiated
|
||||
> Tailscale/SSH backhaul and `device.nodedc.ru` raw-TCP assumptions below are
|
||||
> retained only as implementation history. They must not be used for a new
|
||||
> plan/apply. The accepted successor is
|
||||
> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`: Device Core is a standalone
|
||||
> Hub application, Synology/Core initiates a mutually authenticated full-duplex
|
||||
> channel to the VPS, and `device.nodedc.ru` remains the HTTPS UI surface.
|
||||
|
||||
Status: PostgreSQL, Control Core and Gateway foundation are running healthy on
|
||||
Synology. The accepted foundation has public ingress and discovery ingest
|
||||
disabled. The next additive transition enables only an authenticated,
|
||||
quarantine-only ARUSNAVI B2 discovery path on raw TCP 9921. Command transport
|
||||
remains disabled.
|
||||
|
||||
## Product boundary
|
||||
|
||||
The Device Manager user interface is a canonical Foundry Page Library
|
||||
template. Foundry owns page instances, layout, presentation and an opaque
|
||||
`device-plane-control` binding. It does not own device records, credentials,
|
||||
raw protocol or command delivery.
|
||||
|
||||
The independent NDC Device Plane owns physical-device state and direct
|
||||
connections:
|
||||
|
||||
```text
|
||||
Foundry Device Manager Page
|
||||
|
|
||||
| device-plane-control (typed server boundary)
|
||||
v
|
||||
Device Control Core <-> Device PostgreSQL
|
||||
|
|
||||
v
|
||||
Device Gateway <-> physical devices
|
||||
```
|
||||
|
||||
The isolated ingress placement replaces the direct physical-device arrow when
|
||||
the raw route must not terminate on the multi-service Synology:
|
||||
|
||||
```text
|
||||
ARUSNAVI B2 device
|
||||
|
|
||||
| raw TCP 9921 (future, separately approved)
|
||||
v
|
||||
Device Edge Relay on dedicated mini
|
||||
|
|
||||
| outbound restricted SSH local-forward; opaque byte stream only
|
||||
v
|
||||
Synology loopback 127.0.0.1:9921 -> Device Gateway -> Device Control Core
|
||||
```
|
||||
|
||||
The Edge Relay owns neither protocol acknowledgement nor device identity. It
|
||||
does not receive the Gateway/Core token, PostgreSQL credentials, Foundry
|
||||
bindings or any command capability. The Synology Gateway remains the sole B2
|
||||
codec and acknowledgement owner.
|
||||
|
||||
Engine L2 may consume safe decoded observations and build workflows/Data
|
||||
Products. It does not own TCP sessions, secrets or the command transport.
|
||||
|
||||
## Preserved production path
|
||||
|
||||
The existing Gelios -> Engine L2 -> External Data Plane -> Foundry Map path is
|
||||
outside this implementation slice. Its credentials, workflows, Data Products,
|
||||
bindings and map presentation must not be changed or restarted by a Device
|
||||
Plane artifact.
|
||||
|
||||
The first B2 pilot adds an NDC server route in parallel and keeps the existing
|
||||
Gelios route unchanged.
|
||||
|
||||
## Source and runtime placement
|
||||
|
||||
Source:
|
||||
|
||||
```text
|
||||
platform/device-plane/
|
||||
packages/device-protocol-contract/
|
||||
packages/arusnavi-b2-adapter/
|
||||
services/device-control-core/
|
||||
services/device-gateway/
|
||||
services/device-edge-relay/
|
||||
docker-compose.device-plane.yml
|
||||
docker-compose.device-edge.yml
|
||||
```
|
||||
|
||||
Planned Synology runtime:
|
||||
|
||||
```text
|
||||
/volume1/docker/nodedc-device-plane
|
||||
```
|
||||
|
||||
Planned Compose project and services:
|
||||
|
||||
```text
|
||||
nodedc-device-plane
|
||||
device-control-core
|
||||
device-gateway
|
||||
device-postgres
|
||||
```
|
||||
|
||||
`device-postgres` is a private persistent prerequisite. Application overlays
|
||||
must never force-recreate it or its volume.
|
||||
|
||||
The canonical runner selects only `device-control-core` and `device-gateway`
|
||||
with `--no-deps`. Its health acceptance is scoped to the selected services and
|
||||
requires the fail-closed fields to remain disabled. A failed first activation
|
||||
removes only candidate stateless services and never requests volume removal.
|
||||
Rollback now records an explicit pre-apply service inventory in the backup;
|
||||
the existence of the shared Compose file does not imply that Core or Gateway
|
||||
existed before apply.
|
||||
|
||||
The exact foundation recovery validates the failed archive, journal, backup,
|
||||
partial live source and observed healthy image/container generations. It then
|
||||
publishes the matching source and performs read-only runtime acceptance. It
|
||||
does not build, restart, recreate or remove any service.
|
||||
|
||||
## Network boundary
|
||||
|
||||
The accepted Synology foundation publishes no device port. Device Gateway's
|
||||
raw B2 listener is reachable only through `127.0.0.1:9921`; its health
|
||||
endpoints are loopback-only. The only planned external raw-TCP termination is
|
||||
the dedicated Mini Edge Relay described below.
|
||||
|
||||
`device.nodedc.ru` is a DNS name, not an HTTP/TCP mode. The same name may later
|
||||
serve an HTTPS Control API on 443 and the B2 raw TCP protocol on 9921.
|
||||
|
||||
DSM HTTP/HTTPS Reverse Proxy is not a raw TCP ingress and must not be configured
|
||||
as `443 -> 9921`.
|
||||
|
||||
The artifact never changes DSM firewall, DSM Router Configuration, DNS or a
|
||||
physical router.
|
||||
|
||||
### Dedicated mini Device Edge
|
||||
|
||||
The Debian mini is the isolated raw-TCP edge. Its accepted predecessor keeps the
|
||||
relay disabled and publishes health only on `127.0.0.1:18221`. The reviewed
|
||||
target removes even that host publication: health remains container-internal,
|
||||
the relay stays on the `internal: true` private bridge for backhaul, and a
|
||||
second IPvlan L2 attachment gives only the relay a LAN-routable address for
|
||||
`9921/TCP`. The relay has bounded global/per-address sessions and connection
|
||||
rate, a bounded source-rate table and a per-direction byte budget. It emits no
|
||||
bytes of its own and does not inspect device payloads.
|
||||
|
||||
The admission-gate transition is deliberately fail-closed at the relay: an
|
||||
ingress instance accepts only a syntactically public IPv4 source, limits its
|
||||
in-memory source table to 2,048 addresses and closes either direction after
|
||||
262,144 bytes. Private, loopback, link-local, carrier-grade NAT, multicast,
|
||||
reserved and documentation addresses are rejected before an upstream connection
|
||||
is made. This is a connection-admission and resource-boundary control, not a
|
||||
claim that Docker IPvlan traffic is filtered by a host firewall. A raw B2
|
||||
protocol has no TLS client identity and cellular devices do not offer a stable
|
||||
source-IP allowlist, so a router/NAT mapping remains prohibited until its
|
||||
separate exposure and abuse controls are reviewed.
|
||||
|
||||
IPvlan deliberately reuses the Mini's one physical parent `enp1s0f0`; a second
|
||||
Ethernet adapter is not required. The host keeps `192.168.68.54/22` and the
|
||||
Amnezia `0.0.0.0/1` plus `128.0.0.0/1` routes. The relay has its own fixed LAN
|
||||
IPv4 and default route through `192.168.68.1`, while its private connected route
|
||||
continues to reach `device-edge-backhaul:19921`. No Docker host `ports:` entry,
|
||||
host-network mode, privileged container or VPN teardown is allowed.
|
||||
|
||||
Enabling public ingress is a separate reviewed operation and requires all of
|
||||
the following evidence:
|
||||
|
||||
1. A distinct, no-shell Synology SSH account and key whose sole permitted open
|
||||
target is `127.0.0.1:9921`; host-key pinning and a persistent, monitored
|
||||
tunnel are required.
|
||||
2. A private backhaul sidecar/network; the raw listener may forward only to
|
||||
that tunnel. The Core token and all Core/Database secrets remain on
|
||||
Synology.
|
||||
3. Router evidence proving the fixed relay IPv4 is outside DHCP. The artifact
|
||||
cannot choose an address and never changes router, firewall or DHCP state.
|
||||
A manual router/NAT rule is a later independent approval, after the relay's
|
||||
admission gate and external-exposure runbook have been accepted.
|
||||
4. The host full-tunnel VPN remains active. Before production activation, the
|
||||
exact single-NIC IPvlan design must pass duplicate-address detection,
|
||||
gateway reachability, external return-path and private-backhaul checks.
|
||||
5. One pre-authorized B2 pilot route, quarantine-only Gateway/Core ingest and
|
||||
disabled command transport.
|
||||
|
||||
## Identity and onboarding
|
||||
|
||||
An IMEI is a claimed protocol identifier, not proof of tenant ownership.
|
||||
|
||||
- An unknown connection produces a quarantine-only discovery.
|
||||
- A discovery never receives commands.
|
||||
- Pilot claim requires an explicit platform-admin action.
|
||||
- Production assignment requires authoritative pre-enrollment or an audited
|
||||
inventory import.
|
||||
- First-claim-wins by IMEI is forbidden.
|
||||
|
||||
The ARUSNAVI Web account login/password is used only by the human operator to
|
||||
configure the additional device route. It is not a Device Plane credential.
|
||||
|
||||
## Protocol evidence
|
||||
|
||||
The official B2 material proves:
|
||||
|
||||
- four simultaneous monitoring server routes;
|
||||
- `INTERNAL`, `EXTERNAL`, `USER_AG` and EGTS variants;
|
||||
- INTERNAL server-side identification by modem IMEI;
|
||||
- server route fields for DNS/IP, TCP port, protocol and optional ID;
|
||||
- SMS/TCP command families and a six-digit device access password.
|
||||
|
||||
The official ARUSNAVI INTERNAL protocol sheet now provides the first read-path
|
||||
framing contract:
|
||||
|
||||
- HEADER2 for GPRS is `FF 23` followed by an eight-byte little-endian IMEI;
|
||||
- the server confirms HEADER2 with a bounded `SERVER_COM` carrying Unix time;
|
||||
- a PACKAGE begins with `5B`, carries a package number in `01..FB`, contains
|
||||
one or more length-framed PACKET records and ends with `5D`;
|
||||
- every PACKET checksum is verified before acknowledgement;
|
||||
- every valid PACKAGE is acknowledged by package number;
|
||||
- without acknowledgement the tracker repeats the transmission.
|
||||
|
||||
The pilot codec implements only that verified read/acknowledgement subset. It
|
||||
does not decode telemetry tags, export command builders or accept arbitrary
|
||||
server commands. An IMEI parsed from a valid HEADER2 remains a claimed
|
||||
identifier and never proves tenant ownership.
|
||||
|
||||
## Command boundary
|
||||
|
||||
Outbound command transport is disabled in this baseline. No command builder is
|
||||
exported.
|
||||
|
||||
Later lifecycle:
|
||||
|
||||
```text
|
||||
draft -> planned -> awaiting_confirmation -> queued -> dispatched
|
||||
-> acknowledged | failed | expired | unknown
|
||||
```
|
||||
|
||||
`send` is not success. An `unknown` result forbids automatic retry.
|
||||
|
||||
Erase, factory reset, firmware/custom firmware, physical outputs and arbitrary
|
||||
raw TCP remain forbidden until separate reviewed acceptance slices.
|
||||
|
||||
## Implemented local foundation
|
||||
|
||||
- Provider-neutral discovery, contour and opaque Foundry-binding contracts.
|
||||
- B2 model profile with four parallel routes and INTERNAL/IMEI evidence.
|
||||
- PostgreSQL migration for model profiles, contours, quarantine discoveries,
|
||||
claimed devices, Foundry bindings and append-only audit events.
|
||||
- Core health endpoint and an authenticated quarantine-ingest boundary that is
|
||||
disabled unless explicitly enabled with file-backed secrets.
|
||||
- Gateway discovery-only HEADER2/PACKAGE state machine with bounded buffers,
|
||||
handshake timeout, concurrent/per-source session limits and per-source
|
||||
connection rate limits.
|
||||
- Authenticated Gateway-to-Core discovery ingest. Core HMAC-hashes the full IMEI
|
||||
and persists only its digest, masked view and verified framing evidence.
|
||||
- Only HEADER2 and valid PACKAGE acknowledgements are emitted; no command
|
||||
builder or command transport is present.
|
||||
- Recursive rejection of secret-like fields, raw payloads and command-shaped
|
||||
input in presentation contracts.
|
||||
- Automated contract, adapter, migration, Core and Gateway tests.
|
||||
- Additive `component=device-plane` runner registry with exact roots, builds,
|
||||
services, allowlist/denylist, runner-owned secrets, health contracts and
|
||||
automatic source/runtime rollback.
|
||||
- Deterministic data-only artifact builder and positive/negative regression
|
||||
tests.
|
||||
- Compose foundation with a private internal network, preserved PostgreSQL
|
||||
volume, file-backed database password and loopback-only health publishing.
|
||||
- Exact one-time PostgreSQL bootstrap descriptor, deterministic builder and
|
||||
absence preflight: an existing database container or volume fails closed,
|
||||
and rollback never removes the volume.
|
||||
|
||||
## Next activation slice
|
||||
|
||||
1. The Deco DHCP range has been recorded as `192.168.68.50` through
|
||||
`192.168.71.250`; the fixed Relay IPv4 is `192.168.71.253`, outside that
|
||||
pool and independently DAD-tested. It is pinned in Compose, descriptor,
|
||||
builder and the separate Edge runner.
|
||||
2. Build the deterministic `component=device-edge` artifact, promote the
|
||||
root-owned Edge runner and review its `plan`. The Synology runner and inbox
|
||||
are not used for this host.
|
||||
3. Apply the admission-gate update only to `device-edge-relay`; prove exact
|
||||
IPvlan runtime, no host ports, `public-ipv4-only` admission, byte/source
|
||||
limits, internal health, private backhaul reachability, unchanged
|
||||
backhaul/tailnet identities and preserved Amnezia routes. Automatic rollback
|
||||
restores the reviewed IPvlan predecessor and leaves router state unchanged.
|
||||
4. Independently review and add the single router/NAT rule for TCP `9921` only,
|
||||
then verify that Synology still exposes no public device port.
|
||||
5. Add the NDC route to one approved B2 free server slot while preserving
|
||||
Gelios, then prove HEADER/discovery/PACKAGE acknowledgement. Claim and tenant
|
||||
assignment remain a later explicit platform-admin operation.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Device Core repository boundary
|
||||
|
||||
## Device Core owns
|
||||
|
||||
- Device Manager UI and BFF;
|
||||
- Device Control Core, gateways and edge runtimes;
|
||||
- provider/model adapters and protocol contracts;
|
||||
- Device Plane and Device Edge VPS Compose/configuration sources;
|
||||
- Device Core deployment descriptor templates;
|
||||
- deterministic builders and tests for Device Core artifacts.
|
||||
|
||||
## NODEDC Platform owns
|
||||
|
||||
- the root-owned `nodedc-deploy` executable and component registry;
|
||||
- Hub and Authentik identities, grants and signed runtime claims;
|
||||
- Launcher integration and service catalogue entry;
|
||||
- the `device.nodedc.ru` reverse-proxy route;
|
||||
- shared platform secrets handed to Device Core at runtime;
|
||||
- platform deployment builders and acceptance checks for those integration
|
||||
seams.
|
||||
|
||||
## NODEDC Design Guideline owns
|
||||
|
||||
- `@nodedc/tokens`, `@nodedc/ui-core` and `@nodedc/ui-react`;
|
||||
- canonical controls, icons, tokens and interaction patterns;
|
||||
- design-system validation and catalogue documentation.
|
||||
|
||||
Device Core consumes these packages from the sibling canonical repository. It
|
||||
must not fork or hard-code their source.
|
||||
|
||||
## Migration invariants
|
||||
|
||||
Repository extraction is a source-ownership change only. It must not change:
|
||||
|
||||
- deployed component names;
|
||||
- live filesystem roots;
|
||||
- Compose project or service names;
|
||||
- persistent volume names;
|
||||
- runtime secret paths;
|
||||
- mTLS keys/certificates or edge registrations;
|
||||
- public ingress policy;
|
||||
- release predecessor hashes already recorded by the deploy ledger.
|
||||
|
||||
No runtime state, media upload, credential, private key, device password or
|
||||
real restricted identifier belongs in this repository.
|
||||
@@ -0,0 +1,539 @@
|
||||
# Robot2B B2 VPS — конфигурационная Библия
|
||||
|
||||
> Замороженная историческая конфигурация — 10.08.2026
|
||||
>
|
||||
> Не применять описанные ниже `backhaul`, `relay`, Tailscale/SSH LocalForward,
|
||||
> Synology enrollment или открытие `9921`. Целевой транспорт заменён на
|
||||
> Core-initiated mTLS full-duplex channel по
|
||||
> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`. Документ сохраняется как
|
||||
> evidence уже выполненных экспериментов и текущего predecessor VPS.
|
||||
|
||||
Статус документа: живой manual реализации от 2026-08-06.
|
||||
|
||||
Контур: Robot2B / NODE.DC Device Plane.
|
||||
|
||||
VPS: `155.212.211.15`, hostname `koffyvngij`.
|
||||
|
||||
## 1. Назначение
|
||||
|
||||
VPS является минимальным публичным Device Edge для трекеров Arnavi B2. Он не
|
||||
владеет бизнес-логикой, протоколом, БД, карточками устройств, визуализацией или
|
||||
командами. Его единственная целевая функция — принять ограниченный поток raw TCP
|
||||
на `155.212.211.15:9921`, не интерпретируя пакет, и передать его по шифрованному
|
||||
private backhaul в уже существующий `device-gateway` NODE.DC.
|
||||
|
||||
Gelios остаётся отдельным legacy-источником. Эта конфигурация не меняет Gelios,
|
||||
DNS `device.nodedc.ru`, маршруты B2 или command transport.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
B2["Arnavi B2 trackers"]
|
||||
VPS["Device Edge VPS\n155.212.211.15:9921"]
|
||||
TS["Tailscale userspace\nSOCKS5 127.0.0.1:1055"]
|
||||
SSH["SSH local forward\n127.0.0.1:19921"]
|
||||
TARGET["Synology device-backhaul-target\n100.109.216.21:2222"]
|
||||
GW["device-gateway\n127.0.0.1:9921"]
|
||||
CORE["device-control-core + PostgreSQL"]
|
||||
FOUNDRY["Foundry / visualization"]
|
||||
GELIOS["Gelios legacy source"]
|
||||
|
||||
B2 -->|"raw TCP, planned route"| VPS
|
||||
VPS --> TS -->|"grant: tag:device-edge-vps → TCP/2222 only"| SSH --> TARGET --> GW --> CORE --> FOUNDRY
|
||||
GELIOS -->|"independent legacy ingest"| CORE
|
||||
```
|
||||
|
||||
## 2. Красные границы
|
||||
|
||||
- На VPS нет базы данных и durable telemetry storage.
|
||||
- VPS не разбирает Arnavi/B2 protocol и не принимает решения по IMEI.
|
||||
- IMEI остаётся claimed identifier, а не доказательством владения устройством.
|
||||
- Неизвестное устройство должно попадать в quarantine/discovery lifecycle.
|
||||
- Command transport отключён. Команды через VPS или Gelios не вводятся.
|
||||
- Gelios не выключается и не перетирается новым потоком.
|
||||
- На Synology не публикуется Docker-порт `2222`; доступ только через private
|
||||
Tailscale Serve.
|
||||
- Public VPS не является пользовательским tailnet-узлом. Его service identity
|
||||
`tag:device-edge-vps` может инициировать только TCP к `100.109.216.21:2222`.
|
||||
- VPS не принимает subnet routes, DNS tailnet, exit-node capability или
|
||||
Tailscale SSH. Доступ к DSM, MacBook и другим tailnet-узлам запрещён.
|
||||
- Приватные ключи, auth keys, токены и пароли не входят в Git, deploy artifacts,
|
||||
Ops или этот документ.
|
||||
- До отдельного pilot/cutover не меняются `device.nodedc.ru` и настройки B2.
|
||||
|
||||
## 3. Фактический predecessor VPS
|
||||
|
||||
Снято до первого apply:
|
||||
|
||||
- Ubuntu `24.04.4 LTS`, kernel `6.8.0-137-generic`, KVM/QEMU.
|
||||
- 1 vCPU, 961 MiB RAM, swap отсутствует.
|
||||
- Root filesystem около 8.7 GiB; свободно около 6.4 GiB.
|
||||
- `eth0`: `155.212.211.15/32`, default gateway `100.100.1.1`.
|
||||
- Публично слушал только TCP/22; TCP/9921 был закрыт.
|
||||
- Docker, Node.js, Tailscale и WireGuard отсутствовали.
|
||||
- `fail2ban`, NTP и unattended upgrades были активны.
|
||||
- UFW был inactive, nftables/iptables использовали INPUT ACCEPT.
|
||||
- SSH допускал root/password и forwarding; это устранено foundation-переходом.
|
||||
|
||||
## 4. Канонические переходы
|
||||
|
||||
Конфигурация разделена на независимые data-only артефакты. Порядок нельзя
|
||||
переставлять.
|
||||
|
||||
1. `foundation`:
|
||||
- pin статических Node.js и Tailscale runtimes;
|
||||
- service account `nodedc-edge`;
|
||||
- key-only SSH;
|
||||
- default-deny nftables, публично только TCP/22;
|
||||
- отдельный ED25519 backhaul key, сгенерированный на VPS;
|
||||
- userspace `tailscaled`, но без скрытого auth key;
|
||||
- TCP/9921 закрыт.
|
||||
2. Внешняя регистрация и сегментация `nodedc-b2-vps` в существующем tailnet:
|
||||
- заменить default wildcard policy на проверяемую deny-by-default policy;
|
||||
- назначить `tag:device-edge-vps`, тем самым удалить user ownership;
|
||||
- разрешить тегу только `100.109.216.21:2222/tcp`;
|
||||
- отрицательно проверить Synology `22/5001`, MacBook `22` и другие узлы;
|
||||
- только после этого вернуть Tailscale service в состояние Running.
|
||||
3. Synology `backhaul-vps-enrollment`:
|
||||
- принять только публичный VPS key через enrollment;
|
||||
- заменить прежний Mini key;
|
||||
- пересоздать только `device-backhaul-target`;
|
||||
- сохранить Device Plane, PostgreSQL, Tailscale Serve и Gelios;
|
||||
- при ошибке автоматически вернуть прежний key и target generation.
|
||||
4. VPS `backhaul`:
|
||||
- pinned Synology host key;
|
||||
- key-only SSH через Tailscale userspace SOCKS5;
|
||||
- `127.0.0.1:19921 -> 127.0.0.1:9921`;
|
||||
- публичный TCP/9921 всё ещё закрыт.
|
||||
5. VPS `relay`:
|
||||
- открыть публичный TCP/9921;
|
||||
- bounded opaque relay на `127.0.0.1:19921`;
|
||||
- loopback health на `127.0.0.1:18221`.
|
||||
6. Отдельный pilot: изменить адрес сервера у ограниченной группы B2. Это не
|
||||
часть конфигурации VPS.
|
||||
|
||||
## 5. Идентичности и ключи
|
||||
|
||||
### 5.1 Management SSH
|
||||
|
||||
Команда с MacBook:
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/nodedc_b2_vps \
|
||||
-o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
root@155.212.211.15
|
||||
```
|
||||
|
||||
В Ops и manual фиксируются только путь и fingerprints:
|
||||
|
||||
- локальный private key path: `~/.ssh/nodedc_b2_vps`;
|
||||
- MacBook management public key fingerprint:
|
||||
`SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw` (ED25519);
|
||||
- VPS SSH server host key fingerprint:
|
||||
`SHA256:mhqNn2S6zstkYL7VFdvt3SYHv1nLjB4J7/s57RrKG6w` (ED25519).
|
||||
|
||||
Foundation принудительно задаёт:
|
||||
|
||||
- `PermitRootLogin prohibit-password`;
|
||||
- `AuthenticationMethods publickey`;
|
||||
- `PasswordAuthentication no`;
|
||||
- `KbdInteractiveAuthentication no`;
|
||||
- forwarding, agent forwarding, X11 и tunnels запрещены;
|
||||
- `MaxAuthTries 3`, `LoginGraceTime 20`.
|
||||
|
||||
В `/root/.ssh/authorized_keys` остаются две management identity:
|
||||
|
||||
- MacBook ED25519 — fingerprint выше;
|
||||
- provider recovery key `beget-access-key`, RSA 1024,
|
||||
fingerprint `SHA256:9W1cgovqOlegteEWV0r5j4OjJgG0PGr97eVmpUBjg54`.
|
||||
|
||||
RSA-1024 provider key — остаточный риск. Он не удалён автоматически, потому
|
||||
что отзыв внешнего recovery-доступа является отдельным необратимым решением.
|
||||
После подтверждения владельца нужно либо удалить его отдельным каноническим
|
||||
переходом, либо документировать как принятый break-glass access.
|
||||
|
||||
### 5.2 VPS backhaul client identity
|
||||
|
||||
Private key генерирует root-owned runner непосредственно на VPS:
|
||||
|
||||
- private: `/var/lib/nodedc-b2-vps/trust/backhaul_ed25519`, owner
|
||||
`nodedc-edge` на foundation и `nodedc-backhaul` после backhaul apply,
|
||||
mode `0400`;
|
||||
- public: `/var/lib/nodedc-b2-vps/trust/backhaul_ed25519.pub`, mode `0444`;
|
||||
- fingerprint:
|
||||
`SHA256:HHTiDYiCRxSiKjBLCip6JMSzGfLGrDz5g8SIkosJcVw`;
|
||||
- public key:
|
||||
`ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGUlvJ8a83qov9DHO2S/BHbVAEH4Chyc4v/DKwOIWeES nodedc-device-edge-vps-backhaul`.
|
||||
|
||||
В Synology staging этот public key хранится по пути:
|
||||
|
||||
`/volume1/docker/nodedc-device-plane/enrollment/device-edge-vps-backhaul.pub`
|
||||
|
||||
Private key никогда не покидает VPS.
|
||||
|
||||
Foundation генерирует ключ до появления публичного relay, поэтому его
|
||||
временным владельцем является `nodedc-edge`. Backhaul transition атомарно
|
||||
создаёт `nodedc-backhaul`, передаёт ему каталог trust и ключ и валидирует mode.
|
||||
Relay запускается как третий пользователь `nodedc-relay`; он не может читать ни
|
||||
private key, ни Tailscale state. Даже локальный доступ relay к SOCKS5 не расширяет
|
||||
полномочия: tailnet grant разрешает только target `2222`, где аутентификация
|
||||
дополнительно требует private key пользователя `nodedc-backhaul`.
|
||||
|
||||
### 5.3 Synology backhaul target identity
|
||||
|
||||
- Tailnet IP: `100.109.216.21`.
|
||||
- Private SSH endpoint: `100.109.216.21:2222` через Tailscale Serve.
|
||||
- User: `device-backhaul`.
|
||||
- PermitOpen: только `127.0.0.1:9921`.
|
||||
- Host key fingerprint:
|
||||
`SHA256:QERJ5CIUXRj0nLChGT6HMtoX+WTaeaEY5ZgaWqT8d30`.
|
||||
- Public host key:
|
||||
`ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJsmoyS+0Tbhz9VXxrSxXwNMFpfbdTckCilObOnKdlEc nodedc-device-plane-backhaul-target`.
|
||||
- VPS pinned known_hosts:
|
||||
`/var/lib/nodedc-b2-vps/trust/backhaul_known_hosts`.
|
||||
|
||||
### 5.4 Tailscale service identity и grant
|
||||
|
||||
Живая policy сохранена 2026-08-06. Каноническая копия:
|
||||
`device-plane/deployment/tailscale-device-edge-policy.hujson`.
|
||||
|
||||
- `autogroup:member -> autogroup:self`, все протоколы: сохраняет привычный
|
||||
доступ пользователя только между собственными user-owned устройствами;
|
||||
- `tag:device-edge-vps -> device-plane-backhaul`, только `tcp:2222`;
|
||||
- `device-plane-backhaul = 100.109.216.21`;
|
||||
- tag owner: только `autogroup:admin`;
|
||||
- policy test требует accept `100.109.216.21:2222` и deny для Synology
|
||||
`22/5001`, MacBook `22`, `nodedc-device-edge:22`.
|
||||
|
||||
Назначение тега удалило `dcctouch@gmail.com` из поля `Managed by`; живой VPS
|
||||
управляется `tag:device-edge-vps`. После включения проверено с самого VPS:
|
||||
|
||||
- `100.109.216.21:2222` — reachable;
|
||||
- `100.109.216.21:22` — blocked;
|
||||
- `100.109.216.21:5001` — blocked;
|
||||
- `100.114.248.4:22` — blocked;
|
||||
- `100.64.19.31:22` — blocked.
|
||||
|
||||
Enrollment запускается с `--accept-dns=false --accept-routes=false --ssh=false`.
|
||||
Runner для backhaul/relay дополнительно требует `BackendState=Running`, online
|
||||
hostname `nodedc-b2-vps` и ровно один tag `tag:device-edge-vps`.
|
||||
|
||||
## 6. Runtime и файловая система VPS
|
||||
|
||||
### 6.1 Root-owned source/runtime
|
||||
|
||||
- runner: `/usr/local/sbin/nodedc-b2-vps-deploy`;
|
||||
- live root: `/opt/nodedc-b2-vps`;
|
||||
- deploy state: `/var/lib/nodedc-b2-vps-deploy`;
|
||||
- inbox: `/var/lib/nodedc-b2-vps-deploy/inbox`;
|
||||
- applied/failed artifacts: `applied/`, `failed/`;
|
||||
- backups: `backups/`;
|
||||
- journals: `state/applied.jsonl`, `state/failed.jsonl`;
|
||||
- lock: `state/deploy.lock`;
|
||||
- service state/trust: `/var/lib/nodedc-b2-vps`.
|
||||
|
||||
### 6.2 Pinned runtimes
|
||||
|
||||
- Node.js `22.23.2`;
|
||||
archive SHA-256
|
||||
`d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307`.
|
||||
- Tailscale `1.102.2`;
|
||||
archive SHA-256
|
||||
`ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8`.
|
||||
|
||||
Docker не устанавливается: для 961 MiB RAM он не нужен и добавляет лишний
|
||||
daemon/state surface.
|
||||
|
||||
### 6.3 systemd units
|
||||
|
||||
- `nodedc-b2-tailscaled.service`:
|
||||
- user `nodedc-edge`;
|
||||
- userspace networking, без TUN;
|
||||
- socket `/run/nodedc-b2-vps/tailscaled.sock`;
|
||||
- SOCKS5 `127.0.0.1:1055`;
|
||||
- `MemoryMax=160M`;
|
||||
- разрешены `AF_UNIX AF_INET AF_INET6 AF_NETLINK`.
|
||||
- `nodedc-b2-backhaul.service`:
|
||||
- user `nodedc-backhaul`, единственный читатель backhaul private key;
|
||||
- strict host key pinning;
|
||||
- local forward `127.0.0.1:19921`;
|
||||
- `MemoryMax=64M`.
|
||||
- `nodedc-b2-relay.service`:
|
||||
- user `nodedc-relay`, без credential access;
|
||||
- public `0.0.0.0:9921`;
|
||||
- health `127.0.0.1:18221`;
|
||||
- upstream `127.0.0.1:19921`;
|
||||
- `MemoryMax=192M`.
|
||||
|
||||
Все units используют `NoNewPrivileges`, `ProtectSystem=strict`,
|
||||
`ProtectHome=yes`, `PrivateTmp`, `PrivateDevices`, ограничение address families,
|
||||
tasks и file descriptors.
|
||||
|
||||
## 7. Firewall и порты
|
||||
|
||||
Foundation nftables policy:
|
||||
|
||||
- input: default drop;
|
||||
- loopback, established/related и ICMP разрешены;
|
||||
- новый TCP/22 ограничен `30/minute`, burst `60`;
|
||||
- TCP/9921 отсутствует;
|
||||
- forward: default drop;
|
||||
- output: accept.
|
||||
|
||||
Relay policy добавляет:
|
||||
|
||||
- drop новых соединений на TCP/9921 сверх `300/second`;
|
||||
- accept TCP/9921 после rate guard.
|
||||
|
||||
Портовая матрица:
|
||||
|
||||
| Endpoint | Видимость | Владелец | Стадия |
|
||||
|---|---|---|---|
|
||||
| `155.212.211.15:22` | public | OpenSSH | foundation |
|
||||
| `127.0.0.1:1055` | loopback | tailscaled SOCKS5 | foundation |
|
||||
| `127.0.0.1:19921` | loopback | SSH local forward | backhaul |
|
||||
| `127.0.0.1:18221` | loopback | relay health | relay |
|
||||
| `155.212.211.15:9921` | public | bounded relay | relay |
|
||||
| `100.109.216.21:2222` | tailnet grant только для VPS tag | Synology target | existing |
|
||||
| `127.0.0.1:9921` на Synology | loopback | device-gateway | existing |
|
||||
|
||||
## 8. Relay limits
|
||||
|
||||
- max concurrent sessions: `128`;
|
||||
- max sessions per source IP: `16`;
|
||||
- max new connections/minute/source IP: `60`;
|
||||
- max tracked source IPs: `4096`;
|
||||
- max bytes per direction/session: `64 MiB`;
|
||||
- session timeout: `300000 ms`;
|
||||
- source policy: `public-ipv4-only`;
|
||||
- protocol inspection: disabled на VPS;
|
||||
- command transport: disabled.
|
||||
|
||||
## 9. Установленный foundation release
|
||||
|
||||
Принят 2026-08-06:
|
||||
|
||||
- patch: `device-edge-vps-foundation-20260806-003`;
|
||||
- artifact SHA-256:
|
||||
`1be852f144e9f0fea32af70bebd07a2607b6a1818825094bd4c1b4062064716a`;
|
||||
- foundation-time runner SHA-256:
|
||||
`3f42d23431937e70c16ce1fd346fb84a706e506ae99d89eaf11780ff1ad56c03`;
|
||||
- current promoted runner SHA-256:
|
||||
`5ccdc1b53ce0688e7c120976e82937842bc8491a2e05eb5f280165accfd40b6c`;
|
||||
- backup:
|
||||
`device-edge-vps-foundation-20260806-003-20260806-151415`;
|
||||
- terminal result: `deploy-ok`;
|
||||
- fresh MacBook key-only SSH acceptance: success.
|
||||
|
||||
Два предыдущих ID терминальны и никогда не должны применяться повторно:
|
||||
|
||||
- `...-001`: `tailscaled` заблокирован отсутствием `AF_NETLINK`; automatic
|
||||
rollback `ok`;
|
||||
- `...-002`: cloud-init `50-cloud-init.conf` опередил `90-*` и сохранил
|
||||
`PasswordAuthentication yes`; automatic rollback `ok`;
|
||||
- `...-003`: исправлены `AF_NETLINK` и ранний `00-nodedc-b2-vps.conf`;
|
||||
acceptance успешна.
|
||||
|
||||
Следующие exact isolation-aware artifacts стадированы в VPS inbox и не
|
||||
применяются до своих predecessor barriers:
|
||||
|
||||
- backhaul `device-edge-vps-backhaul-20260806-002`, SHA-256
|
||||
`830750da8f9590ca4db458ec9e90f4d48ad8d1403160d3878a968b54e9eb6913`;
|
||||
- relay `device-edge-vps-relay-20260806-002`, SHA-256
|
||||
`305a6de769f24b2c6cee801426ec43b98a44d10e08cad75a96fd65d20b16b697`.
|
||||
|
||||
Версии `...-001` не применялись и recoverably перемещены из inbox в
|
||||
`/var/lib/nodedc-b2-vps-deploy/withdrawn/*.superseded-by-002`, потому что в них
|
||||
все три процесса использовали один Unix account `nodedc-edge`.
|
||||
|
||||
## 10. Synology VPS enrollment release
|
||||
|
||||
Стадировано, но до авторизации Tailscale и root plan/apply не считается
|
||||
применённым:
|
||||
|
||||
- runner candidate:
|
||||
`/volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.device-plane-backhaul-vps-enrollment-20260806-011`;
|
||||
- runner SHA-256:
|
||||
`453228c41b411d9c925091c77dc94e501f2eb3534fc241db0d2e58f0a28e12e2`;
|
||||
- artifact:
|
||||
`/volume1/docker/nodedc-deploy/inbox/nodedc-device-plane-device-plane-backhaul-vps-enrollment-20260806-001.tgz`;
|
||||
- artifact SHA-256:
|
||||
`576dabdafde5e3b2de09c7265127928c4b463e7b40dd554fd71babc16cc70e08`;
|
||||
- VPS enrollment public-key file SHA-256:
|
||||
`c2718c117fd09965386524d32fa9816d9d2d9cf00b59010dcf8bcf78f29bea8c`.
|
||||
|
||||
Root-переход на Synology:
|
||||
|
||||
```bash
|
||||
sudo sha256sum \
|
||||
/volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.device-plane-backhaul-vps-enrollment-20260806-011
|
||||
|
||||
sudo install -o root -g root -m 0755 \
|
||||
/volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.device-plane-backhaul-vps-enrollment-20260806-011 \
|
||||
/usr/local/sbin/nodedc-deploy
|
||||
|
||||
sudo /usr/local/sbin/nodedc-deploy verify-install
|
||||
|
||||
sudo /usr/local/sbin/nodedc-deploy plan \
|
||||
/volume1/docker/nodedc-deploy/inbox/nodedc-device-plane-device-plane-backhaul-vps-enrollment-20260806-001.tgz
|
||||
|
||||
# Apply только после review exact plan.
|
||||
sudo /usr/local/sbin/nodedc-deploy apply \
|
||||
/volume1/docker/nodedc-deploy/inbox/nodedc-device-plane-device-plane-backhaul-vps-enrollment-20260806-001.tgz
|
||||
```
|
||||
|
||||
Ожидаемый plan обязан показать:
|
||||
|
||||
- predecessor patch `device-plane-backhaul-target-tailnet-serve-20260804-002`;
|
||||
- predecessor artifact SHA
|
||||
`219408705dd4d80a962ed00eeb53a69df0b9ab6458443734d5c9cd1d1f795eba`;
|
||||
- build `none`;
|
||||
- recreate только `device-backhaul-target`;
|
||||
- next fingerprint `SHA256:HHTi…osJcVw`;
|
||||
- public ingress disabled;
|
||||
- Docker port publication disabled;
|
||||
- Tailscale Serve, router/NAT/firewall, PostgreSQL и Gelios unchanged;
|
||||
- automatic rollback на previous key + target recreate.
|
||||
|
||||
## 11. Внешняя регистрация Tailscale
|
||||
|
||||
На VPS auth key не хранится. Для первой регистрации root запускает:
|
||||
|
||||
```bash
|
||||
/opt/nodedc-b2-vps/runtime/tailscale/tailscale \
|
||||
--socket=/run/nodedc-b2-vps/tailscaled.sock \
|
||||
up \
|
||||
--hostname=nodedc-b2-vps \
|
||||
--accept-dns=false \
|
||||
--accept-routes=false \
|
||||
--ssh=false
|
||||
```
|
||||
|
||||
Одноразовый login URL не копируется в Ops. До включения service владелец tailnet
|
||||
обязан сохранить policy и назначить tag из раздела 5.4. Backhaul plan требует
|
||||
`BackendState=Running`, online hostname `nodedc-b2-vps` и exact service tag.
|
||||
|
||||
## 12. Сборка VPS artifacts
|
||||
|
||||
Из корня repository `platform`:
|
||||
|
||||
```bash
|
||||
NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR=/tmp \
|
||||
node infra/deploy-runner/build-device-edge-vps-artifact.mjs \
|
||||
foundation <unique-patch-id>
|
||||
|
||||
node infra/deploy-runner/build-device-edge-vps-artifact.mjs \
|
||||
backhaul <unique-patch-id>
|
||||
|
||||
node infra/deploy-runner/build-device-edge-vps-artifact.mjs \
|
||||
relay <unique-patch-id>
|
||||
```
|
||||
|
||||
Builder создаёт deterministic archive с `manifest.env`, `files.txt` и
|
||||
`payload/`. В artifact запрещены `.env`, keys, trust, runtime, logs, uploads,
|
||||
node_modules и symlinks. Foundation дополнительно проверяет pinned runtime
|
||||
digests.
|
||||
|
||||
Каждый release:
|
||||
|
||||
1. собрать дважды и сравнить SHA-256;
|
||||
2. проверить file list и отсутствие секретов;
|
||||
3. скопировать exact artifact в VPS inbox;
|
||||
4. выполнить свежий `plan`;
|
||||
5. review границ;
|
||||
6. выполнить один exact `apply`;
|
||||
7. считать `deploy-ok` терминальным результатом и не запускать apply повторно.
|
||||
|
||||
## 13. Rollback model
|
||||
|
||||
VPS runner перед mutation создаёт backup exact partition и сохраняет текущий
|
||||
nft ruleset и service enablement. При ошибке:
|
||||
|
||||
- candidate services останавливаются;
|
||||
- source/config возвращаются;
|
||||
- nftables и SSH config восстанавливаются;
|
||||
- service enablement возвращается;
|
||||
- при failed foundation удаляются созданные live/runtime roots и service user;
|
||||
- при failed backhaul ключ возвращается `nodedc-edge`, а созданный
|
||||
`nodedc-backhaul` удаляется;
|
||||
- при failed relay созданный `nodedc-relay` удаляется;
|
||||
- artifact переносится в `failed/`;
|
||||
- failed ID и digest становятся терминальными.
|
||||
|
||||
Synology VPS enrollment отдельно сохраняет previous `authorized_keys`. При любой
|
||||
ошибке он:
|
||||
|
||||
- удаляет candidate marker;
|
||||
- атомарно возвращает прежний restricted key;
|
||||
- пересоздаёт только `device-backhaul-target`;
|
||||
- проверяет target, три preserved Device Plane services, Tailscale Serve и
|
||||
loopback `9921`.
|
||||
|
||||
## 14. Развёртывание аналогичного VPS
|
||||
|
||||
Перед клонированием нельзя просто переиспользовать текущий artifact. Нужно
|
||||
создать новый descriptor/release с новыми параметрами:
|
||||
|
||||
1. Получить чистый Ubuntu 24.04 LTS VPS и зафиксировать hostname, public `/32`,
|
||||
gateway, CPU/RAM/disk.
|
||||
2. Добавить отдельный ED25519 management key и проверить fresh connection.
|
||||
3. Зафиксировать server host key fingerprint с доверенного канала.
|
||||
4. Проверить active fail2ban/NTP/unattended upgrades и отсутствие listeners,
|
||||
кроме TCP/22.
|
||||
5. Изменить pin в runner/descriptor:
|
||||
`RUNTIME_HOST`, `PUBLIC_IPV4`, management/server fingerprints и Tailscale
|
||||
node name.
|
||||
6. Не переносить private backhaul key: новый VPS должен сгенерировать новую
|
||||
пару самостоятельно.
|
||||
7. Собрать deterministic foundation с новым terminal patch ID.
|
||||
8. Promoted runner проверить отдельным `verify-install`.
|
||||
9. Сделать plan → review → apply.
|
||||
10. Зарегистрировать новый tailnet node без сохранения auth key.
|
||||
11. Скопировать только public backhaul key в новый enrollment path.
|
||||
12. Выполнить отдельный Synology key rotation/grant transition.
|
||||
13. Только после принятого private backhaul применять relay.
|
||||
14. Только после relay acceptance перенаправлять ограниченный pilot B2.
|
||||
|
||||
## 15. Acceptance checklist
|
||||
|
||||
- [x] Foundation artifact deterministic и secret-free.
|
||||
- [x] Foundation accepted с automatic rollback coverage.
|
||||
- [x] Fresh key-only SSH с MacBook работает.
|
||||
- [x] Public TCP/9921 не открыт на foundation.
|
||||
- [x] Отдельный VPS backhaul public key создан и pinned.
|
||||
- [x] Synology enrollment key, runner candidate и artifact стадированы.
|
||||
- [ ] Tailnet node `nodedc-b2-vps` авторизован и `Running`.
|
||||
- [x] Tailnet wildcard grant удалён; VPS tagged и negative-route tests пройдены.
|
||||
- [x] Isolation-aware VPS runner promoted и `verify-install-ok`.
|
||||
- [ ] Synology runner candidate promoted и `verify-install-ok`.
|
||||
- [ ] Synology enrollment `plan` reviewed и `deploy-ok`.
|
||||
- [ ] VPS backhaul artifact `deploy-ok`.
|
||||
- [ ] VPS relay artifact `deploy-ok`.
|
||||
- [ ] Public TCP/9921 принят внешним probe.
|
||||
- [ ] Pilot B2 route согласован отдельно.
|
||||
- [ ] Provider RSA-1024 recovery key удалён или принят как documented risk.
|
||||
|
||||
## 16. Исходники и проверки
|
||||
|
||||
Канонические файлы:
|
||||
|
||||
- `infra/deploy-runner/nodedc-b2-vps-deploy`;
|
||||
- `infra/deploy-runner/build-device-edge-vps-artifact.mjs`;
|
||||
- `infra/deploy-runner/test_device_edge_vps_artifact.py`;
|
||||
- `device-plane/vps/config/`;
|
||||
- `device-plane/vps/systemd/`;
|
||||
- `device-plane/deployment/device-edge-vps-*-v1.json`;
|
||||
- `infra/deploy-runner/nodedc-deploy`;
|
||||
- `infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs`;
|
||||
- `infra/deploy-runner/test_device_plane_backhaul_vps_enrollment_artifact.py`.
|
||||
|
||||
Проверки на момент документа:
|
||||
|
||||
- VPS artifact tests: 9/9 OK;
|
||||
- Device Plane targeted runner/artifact tests: 48/48 OK;
|
||||
- полный platform `npm test`: 41/41 OK во внешнем сетевом sandbox;
|
||||
- Python compilation: OK;
|
||||
- systemd unit syntax: OK; отсутствие binaries до foundation было ожидаемым;
|
||||
- `nft -c` foundation/relay: OK;
|
||||
- effective candidate OpenSSH policy: key-only;
|
||||
- `git diff --check`: OK для целевых файлов.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Device Plane baseline test matrix
|
||||
|
||||
| Boundary | Required proof |
|
||||
| --- | --- |
|
||||
| Restricted identity | IMEI accepts exactly 15 decimal digits internally |
|
||||
| Browser projection | Safe discovery view contains only a masked identifier |
|
||||
| Identifier hashing | HMAC digest is deterministic and does not reveal input |
|
||||
| Secret boundary | Secret-like or raw-payload keys are rejected recursively |
|
||||
| Command boundary | Discovery contract rejects command-shaped input |
|
||||
| Framing bound | B2 evidence inspection rejects empty and oversized buffers |
|
||||
| Framing honesty | Unverified B2 bytes return `official_framing_required` |
|
||||
| No identifier guessing | Embedded digit sequences are never returned as IMEI |
|
||||
| Model profile | Four server routes and INTERNAL identification are recorded |
|
||||
| Gelios preservation | Gelios is a parallel route, not a dependency or failover |
|
||||
| Core database secret | Production Compose uses a file-backed password, not a plaintext environment value |
|
||||
| Core health | Database is ready while discovery ingest and command transport remain disabled |
|
||||
| Gateway health | Public ingress, TCP listener and command transport remain disabled |
|
||||
| Compose exposure | Only loopback health ports `18120/18121` are published; raw `9921` is not |
|
||||
| Application service scope | `files.txt` selects only affected Core/Gateway services with `--no-deps` |
|
||||
| Database preservation | Ordinary application artifacts never select `device-postgres` |
|
||||
| Database bootstrap | Exact descriptor selects PostgreSQL only when both container and volume are absent |
|
||||
| Bootstrap rollback | Candidate container may be removed; named volume is never removed |
|
||||
| Rollback predecessor | Backup records actual pre-apply services; Compose presence cannot invent Core/Gateway |
|
||||
| Failed-001 evidence | Recovery requires exact failed archive, journal and backup digests |
|
||||
| Partial source | Recovery accepts only DB-bootstrap source plus the observed healthy foundation runtime |
|
||||
| Recovery mutation | Source is published without build, restart, recreate or service removal |
|
||||
| Recovery rollback | Failed acceptance restores source only and leaves runtime unchanged |
|
||||
| Artifact policy | `.env`, secrets, runtime state, tests, logs and `node_modules` are excluded |
|
||||
| Artifact reproducibility | Repeated builds for the same patch id are byte-identical |
|
||||
| Runner compatibility | Existing canonical Platform registry tests remain green |
|
||||
| Core/Edge trust direction | Core initiates the only private channel; VPS-initiated Synology connections are forbidden |
|
||||
| Core channel authentication | TLS 1.3 mutual authentication; unknown/revoked Edge identity fails closed |
|
||||
| Core channel bounds | Versioned envelopes, per-session and aggregate memory limits, keepalive and reconnect bounds |
|
||||
| Pilot SLO | Zero pre-Core ACK/loss, p95/p99 acceptance latency, reconnect/dead-peer ceilings and no premature availability claim |
|
||||
| Tracker acknowledgement | PACKAGE ACK is emitted only after bounded Core acceptance |
|
||||
| Superseded transport freeze | Old VPS backhaul/relay/enrollment builders fail closed outside explicit test-only reconstruction |
|
||||
| Superseded runner freeze | VPS runner rejects old phases and Synology runner rejects a prebuilt old enrollment artifact |
|
||||
| Runtime preservation in Phase 0 | VPS, Synology, DNS, B2 routes, Gelios, Engine and Foundry remain unchanged |
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const devicePlaneRoot = platformRoot;
|
||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||
const [
|
||||
patchId = "device-control-core-release-v2-20260812-025",
|
||||
predecessorPatchId,
|
||||
predecessorSha256,
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
if (
|
||||
extra.length
|
||||
|| !/^device-control-core-release-[A-Za-z0-9._-]{1,67}$/.test(patchId)
|
||||
|| ((predecessorPatchId === undefined) !== (predecessorSha256 === undefined))
|
||||
|| (
|
||||
predecessorPatchId !== undefined
|
||||
&& (
|
||||
!/^device-control-core-release-[A-Za-z0-9._-]{1,67}$/.test(predecessorPatchId)
|
||||
|| predecessorPatchId === patchId
|
||||
|| !/^[0-9a-f]{64}$/.test(predecessorSha256)
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"usage: build-device-control-core-release-artifact.mjs "
|
||||
+ "[device-control-core-release-id] [predecessor-release-id predecessor-sha256]",
|
||||
);
|
||||
}
|
||||
|
||||
const isV2 = patchId.startsWith("device-control-core-release-v2-");
|
||||
const expectedV2Predecessor = Object.freeze({
|
||||
patchId: predecessorPatchId ?? "device-control-core-release-20260812-024",
|
||||
artifactSha256: predecessorSha256 ?? "a289e909283109642e6bba3d9822a31f63423cfe0bbcd52705979681bd2bc793",
|
||||
});
|
||||
const descriptorPath = isV2
|
||||
? "deployment/device-control-core-release-v2.json"
|
||||
: "deployment/device-control-core-release-v1.json";
|
||||
const entries = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/device-edge-channel-contract",
|
||||
"services/device-control-core",
|
||||
descriptorPath,
|
||||
];
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-control-core-release-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
if (entry === descriptorPath) {
|
||||
const descriptor = JSON.parse(await readFile(resolve(devicePlaneRoot, entry), "utf8"));
|
||||
if (descriptor.releaseId !== "__PATCH_ID__") {
|
||||
throw new Error("device_control_core_release_template_id_mismatch");
|
||||
}
|
||||
descriptor.releaseId = patchId;
|
||||
if (predecessorPatchId !== undefined) {
|
||||
descriptor.predecessor = {
|
||||
kind: "release",
|
||||
patchId: predecessorPatchId,
|
||||
artifactSha256: predecessorSha256,
|
||||
};
|
||||
}
|
||||
const destination = join(payload, entry);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, `${JSON.stringify(descriptor, null, 2)}\n`, "utf8");
|
||||
continue;
|
||||
}
|
||||
await copySafe(resolve(devicePlaneRoot, entry), join(payload, entry), devicePlaneRoot);
|
||||
}
|
||||
|
||||
await validateDockerCopySources(
|
||||
join(payload, "services/device-control-core/Dockerfile"),
|
||||
payload,
|
||||
);
|
||||
for (const modulePath of [
|
||||
"services/device-control-core/src/device-gateway-core-runtime.mjs",
|
||||
"packages/device-protocol-contract/src/index.mjs",
|
||||
"packages/device-edge-channel-contract/src/index.mjs",
|
||||
]) {
|
||||
const imported = spawnSync(
|
||||
process.execPath,
|
||||
["--input-type=module", "--eval", `import(${JSON.stringify(pathToFileURL(join(payload, modulePath)).href)})`],
|
||||
{ cwd: payload, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
if (imported.status !== 0) {
|
||||
throw new Error(`device_control_core_release_staged_module_import_failed:${modulePath}:${imported.stderr || imported.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8"));
|
||||
if (
|
||||
descriptor.schemaVersion !== `nodedc.device-plane.device-control-core-release.${isV2 ? "v2" : "v1"}`
|
||||
|| descriptor.releaseId !== patchId
|
||||
|| descriptor.action !== "upgrade"
|
||||
|| descriptor.service !== "device-control-core"
|
||||
|| descriptor.composeActivation !== "preserve-active-v4-topology"
|
||||
|| descriptor.identityRecovery !== "forbidden-valid-existing-identity-required"
|
||||
|| descriptor.tlsPurpose !== "clientAuth"
|
||||
|| descriptor.direction !== "core-initiated"
|
||||
|| descriptor.endpointPolicy !== "public-ipv4-standard-https-tcp-443-only"
|
||||
|| JSON.stringify(descriptor.coreNetworks) !== JSON.stringify(["device-plane-private", "device-plane-egress"])
|
||||
|| descriptor.publicIngress !== "none-on-synology"
|
||||
|| descriptor.edgeRegistrations !== "preserved"
|
||||
|| descriptor.commandTransport !== (isV2 ? "typed-service-ping-v1" : "disabled")
|
||||
|| descriptor.gelios !== (isV2 ? "untouched-legacy-only" : "untouched")
|
||||
|| descriptor.rollback !== "restore-preapply-source-and-core-runtime"
|
||||
|| (
|
||||
isV2
|
||||
&& (
|
||||
descriptor.commandCatalog !== "allowlisted-adapter-typed-commands-only"
|
||||
|| descriptor.credentialBoundary !== "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned"
|
||||
|| descriptor.predecessor?.patchId !== expectedV2Predecessor.patchId
|
||||
|| descriptor.predecessor?.artifactSha256 !== expectedV2Predecessor.artifactSha256
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new Error("device_control_core_release_contract_mismatch");
|
||||
}
|
||||
|
||||
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, "utf8");
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
});
|
||||
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
const sha256 = createHash("sha256").update(await readFile(target)).digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
component: "device-plane",
|
||||
artifact: target,
|
||||
sha256,
|
||||
entries,
|
||||
services: ["device-control-core"],
|
||||
preserved: ["device-manager", "device-gateway", "device-postgres", "device-backhaul-target", "edge registrations", "mTLS identity", "Gelios"],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function copySafe(source, destination, sourceBoundary) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(sourceBoundary, source)}`);
|
||||
if (sourceStat.isFile()) {
|
||||
if (source.endsWith(".test.mjs") || source.endsWith(".map")) return;
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${source}`);
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if ([".DS_Store", ".git", "node_modules", "test"].includes(entry.name) || entry.name.startsWith(".env")) continue;
|
||||
await copySafe(join(source, entry.name), join(destination, entry.name), sourceBoundary);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDockerCopySources(dockerfilePath, buildContext) {
|
||||
const dockerfile = await readFile(dockerfilePath, "utf8");
|
||||
for (const [index, rawLine] of dockerfile.split("\n").entries()) {
|
||||
const line = rawLine.trim();
|
||||
if (!/^COPY\s+/i.test(line)) continue;
|
||||
if (line.endsWith("\\") || /^COPY\s+\[/i.test(line)) {
|
||||
throw new Error(`unsupported_docker_copy_syntax:${dockerfilePath}:${index + 1}`);
|
||||
}
|
||||
const tokens = line.split(/\s+/).slice(1);
|
||||
while (tokens[0]?.startsWith("--")) tokens.shift();
|
||||
if (tokens.length < 2) throw new Error(`invalid_docker_copy:${dockerfilePath}:${index + 1}`);
|
||||
for (const source of tokens.slice(0, -1)) {
|
||||
if (/[*?[\]{}]/.test(source)) throw new Error(`docker_copy_glob_rejected:${dockerfilePath}:${index + 1}:${source}`);
|
||||
const resolvedSource = resolve(buildContext, source);
|
||||
const relativeSource = relative(buildContext, resolvedSource);
|
||||
if (!relativeSource || relativeSource.startsWith("..") || resolve(buildContext, relativeSource) !== resolvedSource) {
|
||||
throw new Error(`docker_copy_source_outside_context:${dockerfilePath}:${index + 1}:${source}`);
|
||||
}
|
||||
try {
|
||||
await lstat(resolvedSource);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") throw new Error(`docker_copy_source_missing:${dockerfilePath}:${index + 1}:${source}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const devicePlaneRoot = platformRoot;
|
||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||
const [patchId = "device-edge-core-channel-bootstrap-20260811-017", ...extra] = process.argv.slice(2);
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error("usage: build-device-edge-core-channel-bootstrap-artifact.mjs [patch-id]");
|
||||
}
|
||||
|
||||
const upgradeV4 = patchId.startsWith("device-edge-core-channel-upgrade-v4-");
|
||||
const upgradeV2 = !upgradeV4 && patchId.startsWith("device-edge-core-channel-upgrade-v2-");
|
||||
const upgradeV1 = !upgradeV4 && !upgradeV2 && patchId.startsWith("device-edge-core-channel-upgrade-");
|
||||
const upgrade = upgradeV1 || upgradeV2 || upgradeV4;
|
||||
const descriptorPath = upgradeV4
|
||||
? "deployment/device-edge-core-channel-upgrade-v4.json"
|
||||
: upgradeV2
|
||||
? "deployment/device-edge-core-channel-upgrade-v2.json"
|
||||
: upgradeV1
|
||||
? "deployment/device-edge-core-channel-upgrade-v1.json"
|
||||
: "deployment/device-edge-core-channel-bootstrap-v1.json";
|
||||
const composePath = upgradeV4
|
||||
? "docker-compose.device-plane.yml"
|
||||
: "docker-compose.device-edge-core-channel.yml";
|
||||
const entries = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
composePath,
|
||||
"packages/device-protocol-contract",
|
||||
"packages/device-edge-channel-contract",
|
||||
"services/device-control-core",
|
||||
descriptorPath,
|
||||
];
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-edge-core-channel-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
if (entry === descriptorPath) {
|
||||
const descriptor = JSON.parse(await readFile(resolve(devicePlaneRoot, entry), "utf8"));
|
||||
if (descriptor.transitionId !== "__PATCH_ID__") {
|
||||
throw new Error("device_edge_core_channel_template_id_mismatch");
|
||||
}
|
||||
descriptor.transitionId = patchId;
|
||||
const destination = join(payload, entry);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, `${JSON.stringify(descriptor, null, 2)}\n`, "utf8");
|
||||
continue;
|
||||
}
|
||||
await copySafe(resolve(devicePlaneRoot, entry), join(payload, entry), devicePlaneRoot);
|
||||
}
|
||||
|
||||
await validateDockerCopySources(
|
||||
join(payload, "services/device-control-core/Dockerfile"),
|
||||
payload,
|
||||
);
|
||||
for (const modulePath of [
|
||||
"services/device-control-core/src/sensitive-reference-management.mjs",
|
||||
"services/device-control-core/src/device-gateway-core-runtime.mjs",
|
||||
"packages/device-edge-channel-contract/src/index.mjs",
|
||||
]) {
|
||||
const imported = spawnSync(
|
||||
process.execPath,
|
||||
["--input-type=module", "--eval", `import(${JSON.stringify(pathToFileURL(join(payload, modulePath)).href)})`],
|
||||
{ cwd: payload, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
if (imported.status !== 0) {
|
||||
throw new Error(`device_edge_core_channel_staged_module_import_failed:${modulePath}:${imported.stderr || imported.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
const compose = await readFile(join(payload, composePath), "utf8");
|
||||
const commonComposeRequired = [
|
||||
"device-control-core:",
|
||||
];
|
||||
const composeRequired = upgradeV4
|
||||
? [
|
||||
...commonComposeRequired,
|
||||
" - device-plane-private",
|
||||
" - device-plane-control",
|
||||
]
|
||||
: [
|
||||
...commonComposeRequired,
|
||||
"DEVICE_EDGE_CHANNEL_ENABLED: \"true\"",
|
||||
"DEVICE_EDGE_CHANNEL_CORE_KEY_FILE: /run/nodedc-secrets/device-edge-channel/core-private-key.pem",
|
||||
"DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE: /run/nodedc-secrets/device-edge-channel/core-certificate.pem",
|
||||
"DEVICE_EDGE_CHANNEL_TRUST_ROOT: /run/nodedc-secrets/device-edge-channel/peers",
|
||||
"source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-private-key.pem",
|
||||
"source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-certificate.pem",
|
||||
"source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/peers",
|
||||
"name: nodedc-device-plane-egress",
|
||||
];
|
||||
for (const required of composeRequired) {
|
||||
if (!compose.includes(required)) {
|
||||
throw new Error(`device_edge_core_channel_compose_contract_missing:${required}`);
|
||||
}
|
||||
}
|
||||
if (upgradeV4) {
|
||||
const coreBlock = compose.split("\n device-control-core:", 2)[1]?.split("\n device-gateway:", 1)[0] || "";
|
||||
const gatewayBlock = compose.split("\n device-gateway:", 2)[1]?.split("\nnetworks:", 1)[0] || "";
|
||||
if (
|
||||
!coreBlock.includes(" - device-plane-private")
|
||||
|| coreBlock.includes(" - device-plane-control")
|
||||
|| !gatewayBlock.includes(" - device-plane-private")
|
||||
|| !gatewayBlock.includes(" - device-plane-control")
|
||||
) {
|
||||
throw new Error("device_edge_core_channel_v4_network_boundary_mismatch");
|
||||
}
|
||||
}
|
||||
const composeForbidden = upgradeV4
|
||||
? ["gw_priority:", "network_mode:", "privileged:"]
|
||||
: [
|
||||
"device-manager:",
|
||||
"device-gateway:",
|
||||
"device-postgres:",
|
||||
"PRIVATE KEY",
|
||||
"ports:",
|
||||
"network_mode:",
|
||||
"privileged:",
|
||||
];
|
||||
for (const forbidden of composeForbidden) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(`device_edge_core_channel_compose_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8"));
|
||||
const descriptorContractMatches = upgradeV4
|
||||
? (
|
||||
descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-upgrade.v4"
|
||||
&& descriptor.action === "upgrade"
|
||||
&& descriptor.composeActivation === "replace-core-network-membership-with-private-plus-egress"
|
||||
&& descriptor.identityRecovery === "forbidden-valid-existing-identity-required"
|
||||
&& descriptor.endpointPolicy === "public-ipv4-standard-https-tcp-443-only"
|
||||
&& descriptor.upgradePredecessor?.patchId === "device-edge-core-channel-upgrade-v2-20260812-021"
|
||||
&& descriptor.upgradePredecessor?.artifactSha256 === "e40a6fd24edfecac09e42cd82635a77850541bcf047788db3e9c55d2b9e58867"
|
||||
&& descriptor.failedAttempt?.patchId === "device-edge-core-channel-upgrade-v3-20260812-022"
|
||||
&& descriptor.failedAttempt?.artifactSha256 === "9e2b409a4b2d19711db434e90d03ac8e3db77bd74949f83cace7949f33caf613"
|
||||
&& descriptor.failedAttempt?.backupId === "device-plane-device-edge-core-channel-upgrade-v3-20260812-022-20260812-123620"
|
||||
&& JSON.stringify(descriptor.coreNetworks) === JSON.stringify(["device-plane-private", "device-plane-egress"])
|
||||
&& descriptor.removedCoreNetwork === "device-plane-control"
|
||||
&& descriptor.composeCompatibility === "synology-compose-v2.20-no-gw-priority"
|
||||
&& descriptor.edgeRegistrations === "preserved"
|
||||
&& descriptor.rollback === "restore-upgrade-v2-021-source-and-preapply-core-runtime"
|
||||
)
|
||||
: upgradeV2
|
||||
? (
|
||||
descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-upgrade.v2"
|
||||
&& descriptor.action === "upgrade"
|
||||
&& descriptor.composeActivation === "preserve-dedicated-additive-override"
|
||||
&& descriptor.identityRecovery === "forbidden-valid-existing-identity-required"
|
||||
&& descriptor.endpointPolicy === "public-ipv4-standard-https-tcp-443-only"
|
||||
&& descriptor.upgradePredecessor?.patchId === "device-edge-core-channel-upgrade-20260812-019"
|
||||
&& descriptor.upgradePredecessor?.artifactSha256 === "8e9a220275959f378c1c4b00be5c7192e79afe2134eaab808a64e515870a8438"
|
||||
&& descriptor.edgeRegistrations === "preserved"
|
||||
&& descriptor.rollback === "restore-upgrade-019-source-and-preapply-core-runtime"
|
||||
)
|
||||
: upgradeV1
|
||||
? (
|
||||
descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-upgrade.v1"
|
||||
&& descriptor.action === "upgrade"
|
||||
&& descriptor.composeActivation === "preserve-dedicated-additive-override"
|
||||
&& descriptor.identityRecovery === "forbidden-valid-existing-identity-required"
|
||||
&& descriptor.endpointPolicy === "public-ipv4-standard-https-tcp-443-only"
|
||||
&& descriptor.bootstrapPredecessor?.patchId === "device-edge-core-channel-bootstrap-20260812-018"
|
||||
&& descriptor.bootstrapPredecessor?.artifactSha256 === "5598b7388b491fe524ab46038ce476482a93a6cf07d8ca5e00206c69ded02931"
|
||||
)
|
||||
: (
|
||||
descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-bootstrap.v1"
|
||||
&& descriptor.action === "activate"
|
||||
&& descriptor.composeActivation === "dedicated-additive-override"
|
||||
&& descriptor.identityRecovery === "exact-invalid-unexported-failed-predecessor-only"
|
||||
);
|
||||
if (
|
||||
!descriptorContractMatches
|
||||
|| descriptor.transitionId !== patchId
|
||||
|| descriptor.service !== "device-control-core"
|
||||
|| descriptor.tlsPurpose !== "clientAuth"
|
||||
|| descriptor.direction !== "core-initiated"
|
||||
|| descriptor.publicIngress !== "none-on-synology"
|
||||
|| descriptor.commandTransport !== "disabled"
|
||||
|| descriptor.gelios !== "untouched"
|
||||
) {
|
||||
throw new Error("device_edge_core_channel_bootstrap_contract_mismatch");
|
||||
}
|
||||
|
||||
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, "utf8");
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
});
|
||||
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
const sha256 = createHash("sha256").update(await readFile(target)).digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
component: "device-plane",
|
||||
artifact: target,
|
||||
sha256,
|
||||
entries,
|
||||
services: ["device-control-core"],
|
||||
preserved: ["device-manager", "device-gateway", "device-postgres", "device-backhaul-target", "Gelios"],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function copySafe(source, destination, sourceBoundary) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(sourceBoundary, source)}`);
|
||||
if (sourceStat.isFile()) {
|
||||
if (source.endsWith(".test.mjs") || source.endsWith(".map")) return;
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${source}`);
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if ([".DS_Store", ".git", "node_modules", "test"].includes(entry.name) || entry.name.startsWith(".env")) continue;
|
||||
await copySafe(join(source, entry.name), join(destination, entry.name), sourceBoundary);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDockerCopySources(dockerfilePath, buildContext) {
|
||||
const dockerfile = await readFile(dockerfilePath, "utf8");
|
||||
for (const [index, rawLine] of dockerfile.split("\n").entries()) {
|
||||
const line = rawLine.trim();
|
||||
if (!/^COPY\s+/i.test(line)) continue;
|
||||
if (line.endsWith("\\") || /^COPY\s+\[/i.test(line)) {
|
||||
throw new Error(`unsupported_docker_copy_syntax:${dockerfilePath}:${index + 1}`);
|
||||
}
|
||||
const tokens = line.split(/\s+/).slice(1);
|
||||
while (tokens[0]?.startsWith("--")) tokens.shift();
|
||||
if (tokens.length < 2) throw new Error(`invalid_docker_copy:${dockerfilePath}:${index + 1}`);
|
||||
for (const source of tokens.slice(0, -1)) {
|
||||
if (/[*?[\]{}]/.test(source)) throw new Error(`docker_copy_glob_rejected:${dockerfilePath}:${index + 1}:${source}`);
|
||||
const resolvedSource = resolve(buildContext, source);
|
||||
const relativeSource = relative(buildContext, resolvedSource);
|
||||
if (!relativeSource || relativeSource.startsWith("..") || resolve(buildContext, relativeSource) !== resolvedSource) {
|
||||
throw new Error(`docker_copy_source_outside_context:${dockerfilePath}:${index + 1}:${source}`);
|
||||
}
|
||||
try {
|
||||
await lstat(resolvedSource);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") throw new Error(`docker_copy_source_missing:${dockerfilePath}:${index + 1}:${source}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-edge-admission-gate-20260804-002",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-edge-ingress-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml",
|
||||
"services/device-edge-relay/Dockerfile",
|
||||
"services/device-edge-relay/src",
|
||||
"deployment/device-edge-admission-gate-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
const descriptor = await assertBoundary();
|
||||
if (descriptor.ingressIpv4Approval !== "approved-outside-dhcp-pool") {
|
||||
throw new Error("device_edge_ingress_ipv4_approval_pending");
|
||||
}
|
||||
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-edge-ingress-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-device-edge-${patchId}.tgz`);
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
await copySafe(
|
||||
resolve(sourceRoot, sourceRelative),
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-edge\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{ encoding: "utf8", maxBuffer: 128 * 1024 * 1024 },
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-edge",
|
||||
transition: "reviewed-ipvlan-b2-relay-admission-gate",
|
||||
entries: files,
|
||||
services: ["device-edge-relay"],
|
||||
preservedRuntime: ["device-edge-backhaul", "tailnet", "Gelios"],
|
||||
ingress: {
|
||||
parent: descriptor.parentInterface,
|
||||
subnet: descriptor.lanSubnet,
|
||||
gateway: descriptor.lanGateway,
|
||||
ipv4: descriptor.ingressIpv4,
|
||||
ipv4Approval: descriptor.ingressIpv4Approval,
|
||||
tcp: 9921,
|
||||
hostPortPublication: "disabled",
|
||||
sourceAdmission: descriptor.sourceAdmission,
|
||||
maxTrackedSourceAddresses: descriptor.maxTrackedSourceAddresses,
|
||||
maxBytesPerDirection: descriptor.maxBytesPerDirection,
|
||||
lifecycle: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
},
|
||||
rollback: descriptor.rollback,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertBoundary() {
|
||||
const baseline = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-edge.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const ingress = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-edge.ingress.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-edge-admission-gate-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
|
||||
for (const fragment of [
|
||||
"DEVICE_EDGE_RELAY_HEALTH_HOST: 127.0.0.1",
|
||||
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "false"',
|
||||
"read_only: true",
|
||||
'user: "1000:1000"',
|
||||
"no-new-privileges:true",
|
||||
"cap_drop:",
|
||||
"- ALL",
|
||||
]) {
|
||||
if (!baseline.includes(fragment)) {
|
||||
throw new Error(`device_edge_baseline_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of ["ports:", "device-edge-control"]){
|
||||
if (baseline.includes(forbidden)) {
|
||||
throw new Error(`device_edge_baseline_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const fragment of [
|
||||
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"',
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul",
|
||||
'DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"',
|
||||
"DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only",
|
||||
'DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"',
|
||||
'DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"',
|
||||
"name: nodedc-device-edge-ingress",
|
||||
"driver: ipvlan",
|
||||
"parent: enp1s0f0",
|
||||
"ipvlan_mode: l2",
|
||||
"ipv4_address: 192.168.71.253",
|
||||
"gw_priority: 100",
|
||||
"subnet: 192.168.68.0/22",
|
||||
"gateway: 192.168.68.1",
|
||||
]) {
|
||||
if (!ingress.includes(fragment)) {
|
||||
throw new Error(`device_edge_ingress_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"ports:",
|
||||
"network_mode: host",
|
||||
"privileged: true",
|
||||
"DEVICE_EDGE_RELAY_COMMAND",
|
||||
"0.0.0.0:9921:9921",
|
||||
]) {
|
||||
if (ingress.includes(forbidden)) {
|
||||
throw new Error(`device_edge_ingress_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-edge.admission-gate.v1",
|
||||
mode: "single-nic-ipvlan-b2-relay-only",
|
||||
runtimeHost: "ndcmini12",
|
||||
component: "device-edge",
|
||||
selectedServices: ["device-edge-relay"],
|
||||
preservedServices: ["device-edge-backhaul", "tailnet"],
|
||||
composeProject: "nodedc-device-edge",
|
||||
composeFiles: [
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml",
|
||||
],
|
||||
parentInterface: "enp1s0f0",
|
||||
lanSubnet: "192.168.68.0/22",
|
||||
lanGateway: "192.168.68.1",
|
||||
ingressIpv4: "192.168.71.253",
|
||||
ingressIpv4Approval: "approved-outside-dhcp-pool",
|
||||
ingressNetwork: "nodedc-device-edge-ingress",
|
||||
deviceTcpListen: "192.168.71.253:9921",
|
||||
hostPortPublication: "disabled",
|
||||
healthPublication: "disabled",
|
||||
privateUpstream: "device-edge-backhaul:19921",
|
||||
sourceAdmission: "public-ipv4-only",
|
||||
maxTrackedSourceAddresses: 2048,
|
||||
maxBytesPerDirection: 262144,
|
||||
protocolInspection: "gateway-owned",
|
||||
identityTrust: "claimed-not-ownership-proof",
|
||||
discoveryLifecycle: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
amneziaHostFullTunnel: "preserved",
|
||||
routerNatFirewall: "separate-manual-gate",
|
||||
rollback: "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_edge_ingress_descriptor_mismatch");
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) {
|
||||
throw new Error(`source_type_rejected:${source}`);
|
||||
}
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const runtimeCache = resolve(
|
||||
process.env.NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR || "/tmp",
|
||||
);
|
||||
|
||||
const [phase, patchId, ...extra] = process.argv.slice(2);
|
||||
if (
|
||||
extra.length
|
||||
|| ![
|
||||
"foundation",
|
||||
"runtime-reconciliation",
|
||||
"backhaul",
|
||||
"relay",
|
||||
"core-channel",
|
||||
"tailscale-retirement",
|
||||
"tracker-ingress",
|
||||
"command-transport",
|
||||
].includes(phase)
|
||||
|| !/^[A-Za-z0-9._-]{1,96}$/.test(patchId || "")
|
||||
) {
|
||||
throw new Error(
|
||||
"usage: build-device-edge-vps-artifact.mjs <foundation|runtime-reconciliation|backhaul|relay|core-channel|tailscale-retirement|tracker-ingress|command-transport> <patch-id>",
|
||||
);
|
||||
}
|
||||
|
||||
const supersededTransportPhases = new Set(["backhaul", "relay"]);
|
||||
if (
|
||||
supersededTransportPhases.has(phase)
|
||||
&& process.env.NODEDC_ALLOW_SUPERSEDED_TRANSPORT !== "test-only"
|
||||
) {
|
||||
throw new Error("vps_initiated_transport_frozen:ADR-0001");
|
||||
}
|
||||
const acceptedSharedSourcePhases = new Set(["core-channel", "tracker-ingress"]);
|
||||
if (acceptedSharedSourcePhases.has(phase)) {
|
||||
throw new Error(`accepted_vps_phase_rebuild_frozen:${phase}:ADR-0001`);
|
||||
}
|
||||
|
||||
const nodeArchive = "node-v22.23.2-linux-x64.tar.xz";
|
||||
const tailscaleArchive = "tailscale_1.102.2_amd64.tgz";
|
||||
const runtimeDigests = new Map([
|
||||
[nodeArchive, "d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307"],
|
||||
[tailscaleArchive, "ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8"],
|
||||
]);
|
||||
|
||||
const entriesByPhase = {
|
||||
foundation: [
|
||||
"vps/config/00-nodedc-b2-vps.conf",
|
||||
"vps/config/nftables-foundation.conf",
|
||||
"vps/systemd/nodedc-b2-tailscaled.service",
|
||||
"deployment/device-edge-vps-foundation-v1.json",
|
||||
`vendor/${nodeArchive}`,
|
||||
`vendor/${tailscaleArchive}`,
|
||||
],
|
||||
"runtime-reconciliation": [
|
||||
"deployment/device-edge-vps-runtime-reconciliation-v1.json",
|
||||
],
|
||||
backhaul: [
|
||||
"vps/config/backhaul_ssh_config",
|
||||
"vps/systemd/nodedc-b2-backhaul.service",
|
||||
"deployment/device-edge-vps-backhaul-v1.json",
|
||||
],
|
||||
relay: [
|
||||
"vps/config/nftables-relay.conf",
|
||||
"vps/systemd/nodedc-b2-relay.service",
|
||||
"services/device-edge-relay/src",
|
||||
"deployment/device-edge-vps-relay-v1.json",
|
||||
],
|
||||
"core-channel": [
|
||||
"packages/device-protocol-contract/package.json",
|
||||
"packages/device-protocol-contract/src",
|
||||
"packages/device-edge-channel-contract/package.json",
|
||||
"packages/device-edge-channel-contract/src",
|
||||
"services/device-edge-channel/package.json",
|
||||
"services/device-edge-channel/src",
|
||||
"vps/config/nftables-core-channel.conf",
|
||||
"vps/systemd/nodedc-device-edge-channel.service",
|
||||
"deployment/device-edge-vps-core-channel-v1.json",
|
||||
],
|
||||
"tailscale-retirement": [
|
||||
"deployment/device-edge-vps-tailscale-retirement-v1.json",
|
||||
],
|
||||
"tracker-ingress": [
|
||||
"packages/device-adapter-runtime/package.json",
|
||||
"packages/device-adapter-runtime/src",
|
||||
"packages/device-adapter-catalog/package.json",
|
||||
"packages/device-adapter-catalog/src",
|
||||
"packages/arusnavi-b2-adapter/package.json",
|
||||
"packages/arusnavi-b2-adapter/src",
|
||||
"services/device-gateway/src/runtime.mjs",
|
||||
"vps/edge-process/device-edge-runtime.mjs",
|
||||
"vps/config/nftables-tracker-ingress.conf",
|
||||
"vps/systemd/nodedc-device-edge-runtime.service",
|
||||
"deployment/device-edge-vps-tracker-ingress-v1.json",
|
||||
],
|
||||
"command-transport": [
|
||||
"packages/device-edge-channel-contract/package.json",
|
||||
"packages/device-edge-channel-contract/src",
|
||||
"services/device-edge-channel/package.json",
|
||||
"services/device-edge-channel/src",
|
||||
"packages/device-adapter-runtime/package.json",
|
||||
"packages/device-adapter-runtime/src",
|
||||
"packages/device-adapter-catalog/package.json",
|
||||
"packages/device-adapter-catalog/src",
|
||||
"packages/arusnavi-b2-adapter/package.json",
|
||||
"packages/arusnavi-b2-adapter/src",
|
||||
"services/device-gateway/src/runtime.mjs",
|
||||
"vps/edge-process/device-edge-runtime.mjs",
|
||||
"deployment/device-edge-vps-command-transport-v1.json",
|
||||
],
|
||||
};
|
||||
const entries = entriesByPhase[phase];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
const stage = await mkdtemp(join(tmpdir(), `nodedc-device-edge-vps-${phase}-`));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-edge-vps-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith("vendor/")) {
|
||||
const name = basename(entry);
|
||||
const source = resolve(runtimeCache, name);
|
||||
const actual = createHash("sha256").update(await readFile(source)).digest("hex");
|
||||
if (actual !== runtimeDigests.get(name)) {
|
||||
throw new Error(`runtime_digest_mismatch:${name}:${actual}`);
|
||||
}
|
||||
await mkdir(dirname(join(payload, entry)), { recursive: true });
|
||||
await cp(source, join(payload, entry), { force: true });
|
||||
continue;
|
||||
}
|
||||
await copySafe(resolve(sourceRoot, entry), join(payload, entry));
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-edge-vps\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{ encoding: "utf8", maxBuffer: 256 * 1024 * 1024 },
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const bytes = await readFile(target);
|
||||
const digest = createHash("sha256").update(bytes).digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
phase,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
size: bytes.length,
|
||||
component: "device-edge-vps",
|
||||
entries,
|
||||
publicIngress: phase === "relay"
|
||||
? "tcp/9921"
|
||||
: ["core-channel", "tailscale-retirement"].includes(phase)
|
||||
? "tcp/443-mtls-only"
|
||||
: ["tracker-ingress", "command-transport"].includes(phase)
|
||||
? "tcp/443-mtls+tcp/9921-telemetry"
|
||||
: "disabled",
|
||||
commandTransport: phase === "command-transport"
|
||||
? "typed-service-ping-v1"
|
||||
: "disabled",
|
||||
gelios: "untouched",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertBoundary() {
|
||||
const descriptorPath = resolve(
|
||||
sourceRoot,
|
||||
`deployment/device-edge-vps-${phase}-v1.json`,
|
||||
);
|
||||
const descriptor = JSON.parse(await readFile(descriptorPath, "utf8"));
|
||||
if (
|
||||
descriptor.component !== "device-edge-vps"
|
||||
|| descriptor.runtimeHost !== "koffyvngij"
|
||||
|| descriptor.commandTransport !== (phase === "command-transport"
|
||||
? "typed-service-ping-v1"
|
||||
: "disabled")
|
||||
|| !String(descriptor.gelios || "").startsWith("untouched")
|
||||
|| !String(descriptor.rollback || "").length
|
||||
) {
|
||||
throw new Error(`descriptor_boundary_mismatch:${phase}`);
|
||||
}
|
||||
|
||||
const selectedText = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => !entry.startsWith("vendor/") && !entry.endsWith("/src"))
|
||||
.map((entry) => readFile(resolve(sourceRoot, entry), "utf8")),
|
||||
);
|
||||
const combined = selectedText.join("\n");
|
||||
for (const forbidden of [
|
||||
"PRIVATE KEY",
|
||||
"AuthKey",
|
||||
"TS_AUTHKEY",
|
||||
"PasswordAuthentication yes",
|
||||
"commandTransport\": \"enabled",
|
||||
"device.dc.ru",
|
||||
]) {
|
||||
if (combined.includes(forbidden)) {
|
||||
throw new Error(`vps_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === "foundation") {
|
||||
for (const required of [
|
||||
"PermitRootLogin prohibit-password",
|
||||
"PasswordAuthentication no",
|
||||
"AllowTcpForwarding no",
|
||||
"policy drop",
|
||||
"tcp dport 22",
|
||||
"--tun=userspace-networking",
|
||||
"--socks5-server=127.0.0.1:1055",
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`foundation_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
if (combined.includes("tcp dport 9921")) {
|
||||
throw new Error("foundation_must_not_open_9921");
|
||||
}
|
||||
}
|
||||
if (phase === "runtime-reconciliation") {
|
||||
for (const required of [
|
||||
"recover-exact-runtime-executable-modes-after-failed-core-channel-publish",
|
||||
"restore-root-owned-executable-mode-0755-for-exact-known-binaries",
|
||||
'"publicCoreChannel": "disabled"',
|
||||
'"trackerIngress": "disabled"',
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`runtime_reconciliation_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase === "backhaul") {
|
||||
for (const required of [
|
||||
"\"runtimeUser\": \"nodedc-backhaul\"",
|
||||
"User=nodedc-backhaul",
|
||||
"HostName 100.109.216.21",
|
||||
"Port 2222",
|
||||
"StrictHostKeyChecking yes",
|
||||
"LocalForward 127.0.0.1:19921 127.0.0.1:9921",
|
||||
"ProxyCommand /usr/bin/nc -X 5 -x 127.0.0.1:1055",
|
||||
"MemoryMax=64M",
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`backhaul_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase === "relay") {
|
||||
for (const required of [
|
||||
"\"runtimeUser\": \"nodedc-relay\"",
|
||||
"User=nodedc-relay",
|
||||
"tcp dport 9921",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_HOST=127.0.0.1",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921",
|
||||
"DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only",
|
||||
"MemoryMax=192M",
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`relay_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase === "core-channel") {
|
||||
for (const required of [
|
||||
"\"runtimeUser\": \"nodedc-channel\"",
|
||||
"\"trackerIngress\": \"disabled\"",
|
||||
"User=nodedc-channel",
|
||||
"ExecStart=/opt/nodedc-b2-vps/runtime/node/bin/node /opt/nodedc-b2-vps/services/device-edge-channel/src/server.mjs",
|
||||
"MemoryDenyWriteExecute=no",
|
||||
"CapabilityBoundingSet=CAP_NET_BIND_SERVICE",
|
||||
"AmbientCapabilities=CAP_NET_BIND_SERVICE",
|
||||
"tcp dport 443",
|
||||
"MemoryMax=128M",
|
||||
"MemorySwapMax=0",
|
||||
"CPUQuota=50%",
|
||||
"TasksMax=64",
|
||||
"LimitNOFILE=1024",
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`core_channel_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"tcp dport 9921",
|
||||
"LocalForward",
|
||||
"tailscale-userspace",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM",
|
||||
"--jitless",
|
||||
]) {
|
||||
if (combined.includes(forbidden)) {
|
||||
throw new Error(`core_channel_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase === "tailscale-retirement") {
|
||||
for (const required of [
|
||||
'"predecessorPatch": "device-edge-vps-core-channel-20260812-010"',
|
||||
'"runtimeAction": "stop-disable-remove-userspace-tailscale-runtime-state-and-superseded-trust"',
|
||||
'"trackerIngress": "disabled"',
|
||||
'"externalRevocation": "delete-exact-nodedc-b2-vps-machine-in-tailnet-after-deploy-ok"',
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`tailscale_retirement_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"tcp dport 9921",
|
||||
"LocalForward",
|
||||
"commandTransport\": \"enabled",
|
||||
]) {
|
||||
if (combined.includes(forbidden)) {
|
||||
throw new Error(`tailscale_retirement_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase === "tracker-ingress") {
|
||||
for (const required of [
|
||||
'"predecessorPatch": "device-edge-vps-tailscale-retirement-20260812-011"',
|
||||
'"runtimeComposition": "single-process-core-channel-plus-universal-device-gateway"',
|
||||
'"trackerIngress": "enabled:allowlisted-adapters-only"',
|
||||
'"acknowledgementBoundary": "tracker-ack-only-after-core-durable-acceptance"',
|
||||
'"initialAdapterProfile": "arusnavi.b2.internal.v1"',
|
||||
"createDeviceGatewayRuntime",
|
||||
"DEVICE_ADAPTER_CATALOG.registry",
|
||||
"onDiscovery: (signal) => channel.submitDiscovery(signal)",
|
||||
"onMessage: (message) => channel.submitAdapterMessage(message)",
|
||||
"tcp dport 9921",
|
||||
"User=nodedc-channel",
|
||||
"MemoryMax=192M",
|
||||
"MemorySwapMax=0",
|
||||
"CPUQuota=75%",
|
||||
"TasksMax=128",
|
||||
"LimitNOFILE=1024",
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`tracker_ingress_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"LocalForward",
|
||||
"tailscale-userspace",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM",
|
||||
'commandTransport": "enabled',
|
||||
"device.dc.ru",
|
||||
]) {
|
||||
if (combined.includes(forbidden)) {
|
||||
throw new Error(`tracker_ingress_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase === "command-transport") {
|
||||
for (const required of [
|
||||
'"predecessorPatch": "device-edge-vps-tracker-ingress-20260812-012"',
|
||||
'"runtimeService": "nodedc-device-edge-channel.service"',
|
||||
'"runtimeComposition": "single-process-core-channel-plus-universal-device-gateway"',
|
||||
'"commandTransport": "typed-service-ping-v1"',
|
||||
'"commandCatalog": "allowlisted-adapter-typed-commands-only"',
|
||||
'"responseBoundary": "exact-adapter-parser-serv-ok-only"',
|
||||
"buildTypedCommand",
|
||||
"parseTypedCommandResponse",
|
||||
"submitCommandStatus",
|
||||
'"service.ping"',
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`command_transport_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"LocalForward",
|
||||
"tailscale-userspace",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM",
|
||||
"device.dc.ru",
|
||||
"PRIVATE KEY",
|
||||
"TS_AUTHKEY",
|
||||
]) {
|
||||
if (combined.includes(forbidden)) {
|
||||
throw new Error(`command_transport_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) {
|
||||
throw new Error(`source_type_rejected:${source}`);
|
||||
}
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if (ignoredBasenames.has(entry.name) || entry.name.startsWith(".env")) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`source_symlink_rejected:${relative(sourceRoot, childSource)}`);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const devicePlaneRoot = platformRoot;
|
||||
const managerRoot = resolve(platformRoot, "apps/device-manager");
|
||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||
const [patchId = "device-manager-release-v3-20260812-026", ...extra] = process.argv.slice(2);
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-device-manager-control-plane-artifact.mjs [patch-id]");
|
||||
|
||||
const descriptorPath = patchId.startsWith("device-manager-release-v3-")
|
||||
? "deployment/device-manager-release-v3.json"
|
||||
: "deployment/device-manager-release-v1.json";
|
||||
|
||||
const isV3 = descriptorPath.endsWith("release-v3.json");
|
||||
const entries = isV3 ? [
|
||||
"docker-compose.device-manager.yml",
|
||||
"services/device-manager",
|
||||
descriptorPath,
|
||||
] : [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-manager.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/device-edge-channel-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway/package.json",
|
||||
"services/device-edge-relay/package.json",
|
||||
"services/device-manager",
|
||||
descriptorPath,
|
||||
];
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-control-plane-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
|
||||
try {
|
||||
const build = spawnSync("npm", ["run", "build", "--workspace", "@nodedc/device-manager"], {
|
||||
cwd: platformRoot,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (build.status !== 0) throw new Error(`device_manager_build_failed:${build.stderr || build.stdout}`);
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
if (entry === descriptorPath) {
|
||||
const descriptor = JSON.parse(await readFile(resolve(devicePlaneRoot, entry), "utf8"));
|
||||
if (descriptor.releaseId !== "__PATCH_ID__") {
|
||||
throw new Error("device_manager_release_template_id_mismatch");
|
||||
}
|
||||
descriptor.releaseId = patchId;
|
||||
const destination = join(payload, entry);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, `${JSON.stringify(descriptor, null, 2)}\n`, "utf8");
|
||||
continue;
|
||||
}
|
||||
if (entry === "services/device-manager") {
|
||||
const destination = join(payload, entry);
|
||||
await mkdir(destination, { recursive: true });
|
||||
await copySafe(resolve(devicePlaneRoot, "services/device-manager/Dockerfile"), join(destination, "Dockerfile"), devicePlaneRoot);
|
||||
await copySafe(resolve(managerRoot, "server"), join(destination, "server"), managerRoot);
|
||||
await copySafe(resolve(managerRoot, "dist"), join(destination, "dist"), managerRoot);
|
||||
continue;
|
||||
}
|
||||
await copySafe(resolve(devicePlaneRoot, entry), join(payload, entry), devicePlaneRoot);
|
||||
}
|
||||
if (!isV3) {
|
||||
await validateDockerCopySources(
|
||||
join(payload, "services/device-control-core/Dockerfile"),
|
||||
payload,
|
||||
);
|
||||
}
|
||||
await validateDockerCopySources(
|
||||
join(payload, "services/device-manager/Dockerfile"),
|
||||
join(payload, "services/device-manager"),
|
||||
);
|
||||
for (const modulePath of isV3 ? [] : [
|
||||
"services/device-control-core/src/sensitive-reference-management.mjs",
|
||||
"services/device-control-core/src/device-gateway-core-runtime.mjs",
|
||||
"packages/device-edge-channel-contract/src/index.mjs",
|
||||
]) {
|
||||
const coreImport = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
`import(${JSON.stringify(pathToFileURL(join(payload, modulePath)).href)})`,
|
||||
],
|
||||
{
|
||||
cwd: payload,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (coreImport.status !== 0) {
|
||||
throw new Error(
|
||||
`device_control_core_staged_module_import_failed:${modulePath}:${coreImport.stderr || coreImport.stdout}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const compose = await readFile(join(payload, "docker-compose.device-manager.yml"), "utf8");
|
||||
for (const required of [
|
||||
"device-manager:",
|
||||
"DEVICE_MANAGEMENT_API_ENABLED: \"true\"",
|
||||
"NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token",
|
||||
"NODEDC_DEVICE_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token",
|
||||
"name: nodedc-platform_edge",
|
||||
]) if (!compose.includes(required)) throw new Error(`device_manager_compose_contract_missing:${required}`);
|
||||
for (const forbidden of [
|
||||
"NODEDC_INTERNAL_ACCESS_TOKEN:",
|
||||
"NODEDC_PLATFORM_SERVICE_TOKEN:",
|
||||
"PRIVATE KEY",
|
||||
"DEVICE_EDGE_CHANNEL_",
|
||||
"device-edge-channel/",
|
||||
"nodedc-device-plane-egress",
|
||||
"0.0.0.0:18122",
|
||||
"0.0.0.0:9921:9921",
|
||||
"- \"9921:9921\"",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) throw new Error(`device_manager_compose_boundary_violation:${forbidden}`);
|
||||
}
|
||||
const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8"));
|
||||
const predecessor = descriptor.predecessor;
|
||||
const commonContractInvalid = (
|
||||
descriptor.releaseId !== patchId
|
||||
|| !["activate", "upgrade"].includes(descriptor.action)
|
||||
|| !predecessor
|
||||
|| !["reconciliation", "release"].includes(predecessor.kind)
|
||||
|| !/^[A-Za-z0-9._-]{1,96}$/.test(predecessor.patchId || "")
|
||||
|| !/^[a-f0-9]{64}$/.test(predecessor.artifactSha256 || "")
|
||||
|| (descriptor.action === "activate") !== (predecessor.kind === "reconciliation")
|
||||
|| descriptor.healthGate !== "bounded-container-grace+core-contract"
|
||||
|| descriptor.rollback !== "restore-preapply-snapshot"
|
||||
);
|
||||
if (commonContractInvalid) throw new Error("device_manager_activation_successor_contract_mismatch");
|
||||
if (descriptorPath.endsWith("release-v3.json")) {
|
||||
if (
|
||||
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v3"
|
||||
|| descriptor.commandTransport !== "typed-service-ping-v1"
|
||||
|| descriptor.commandCatalog !== "allowlisted-adapter-typed-commands-only"
|
||||
|| descriptor.credentialBoundary !== "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned"
|
||||
|| descriptor.controlCorePredecessor?.patchId !== "device-control-core-release-v2-20260812-025"
|
||||
|| descriptor.controlCorePredecessor?.artifactSha256 !== "c61b1f0de1bae23de0caa7289036865ea419ff5705611416f736ca929d1592db"
|
||||
|| descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023"
|
||||
|| descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|
||||
|| descriptor.edgeChannel !== "preserve-active-v4-core-initiated-pinned-mtls"
|
||||
|| descriptor.edgeChannelEgress !== "preserve-dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-443-only"
|
||||
|| descriptor.gelios !== "untouched-legacy-only"
|
||||
) throw new Error("device_manager_v3_typed_command_contract_mismatch");
|
||||
} else if (
|
||||
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v1"
|
||||
|| descriptor.commandTransport !== "disabled"
|
||||
|| descriptor.gelios !== "untouched"
|
||||
) {
|
||||
throw new Error("device_manager_v1_contract_mismatch");
|
||||
}
|
||||
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, "utf8");
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], { encoding: "utf8", maxBuffer: 128 * 1024 * 1024 });
|
||||
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
const sha256 = createHash("sha256").update(await readFile(target)).digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
component: "device-plane",
|
||||
artifact: target,
|
||||
sha256,
|
||||
entries,
|
||||
services: isV3 ? ["device-manager"] : ["device-control-core", "device-manager"],
|
||||
preserved: ["device-postgres", "device-gateway", "device-backhaul-target", "Gelios"],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function copySafe(source, destination, sourceBoundary) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(sourceBoundary, source)}`);
|
||||
if (sourceStat.isFile()) {
|
||||
if (source.endsWith(".test.mjs") || source.endsWith(".map")) return;
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${source}`);
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if ([".DS_Store", ".git", "node_modules", "test"].includes(entry.name) || entry.name.startsWith(".env")) continue;
|
||||
await copySafe(join(source, entry.name), join(destination, entry.name), sourceBoundary);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDockerCopySources(dockerfilePath, buildContext) {
|
||||
const dockerfile = await readFile(dockerfilePath, "utf8");
|
||||
for (const [index, rawLine] of dockerfile.split("\n").entries()) {
|
||||
const line = rawLine.trim();
|
||||
if (!/^COPY\s+/i.test(line)) continue;
|
||||
if (line.endsWith("\\") || /^COPY\s+\[/i.test(line)) {
|
||||
throw new Error(`unsupported_docker_copy_syntax:${dockerfilePath}:${index + 1}`);
|
||||
}
|
||||
const tokens = line.split(/\s+/).slice(1);
|
||||
while (tokens[0]?.startsWith("--")) tokens.shift();
|
||||
if (tokens.length < 2) {
|
||||
throw new Error(`invalid_docker_copy:${dockerfilePath}:${index + 1}`);
|
||||
}
|
||||
for (const source of tokens.slice(0, -1)) {
|
||||
if (/[*?[\]{}]/.test(source)) {
|
||||
throw new Error(`docker_copy_glob_rejected:${dockerfilePath}:${index + 1}:${source}`);
|
||||
}
|
||||
const resolvedSource = resolve(buildContext, source);
|
||||
const relativeSource = relative(buildContext, resolvedSource);
|
||||
if (!relativeSource || relativeSource.startsWith("..") || resolve(buildContext, relativeSource) !== resolvedSource) {
|
||||
throw new Error(`docker_copy_source_outside_context:${dockerfilePath}:${index + 1}:${source}`);
|
||||
}
|
||||
try {
|
||||
await lstat(resolvedSource);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
throw new Error(`docker_copy_source_missing:${dockerfilePath}:${index + 1}:${source}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-manager-control-plane-reconciliation-20260811-002",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-manager-control-plane-reconciliation-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const entry = "deployment/device-manager-control-plane-reconciliation-v1.json";
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-reconciliation-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = resolve(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
try {
|
||||
const descriptor = JSON.parse(await readFile(resolve(sourceRoot, entry), "utf8"));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.device-manager-control-plane-reconciliation.v1",
|
||||
mode: "failed-control-plane-baseline-adoption",
|
||||
failedPatchId: "device-manager-control-plane-20260810-001",
|
||||
failedArtifactSha256:
|
||||
"50e275c1085286bcb3bb2b273aefc8bbba70f446ca2c7bd464dc745710a291a6",
|
||||
backupId:
|
||||
"device-plane-device-manager-control-plane-20260810-001-20260811-000321",
|
||||
sourceAction: "publish-reconciliation-marker-only",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
preservedServices: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target",
|
||||
],
|
||||
absentService: "device-manager",
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
publicIngress: "disabled",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
rollback: "marker-only-runtime-unchanged",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_manager_reconciliation_descriptor_mismatch");
|
||||
}
|
||||
|
||||
await mkdir(dirname(join(payload, entry)), { recursive: true });
|
||||
await cp(resolve(sourceRoot, entry), join(payload, entry), { force: true });
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${entry}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{ encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
const sha256 = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
component: "device-plane",
|
||||
artifact: target,
|
||||
sha256,
|
||||
entries: [entry],
|
||||
build: [],
|
||||
services: [],
|
||||
transition: "failed-control-plane-baseline-adoption",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
sourceAction: "publish-reconciliation-marker-only",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-manager-control-plane-v2-reconciliation-20260811-004",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-manager-control-plane-v2-reconciliation-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const entry = "deployment/device-manager-control-plane-v2-reconciliation-v1.json";
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-v2-reconciliation-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = resolve(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
|
||||
|
||||
try {
|
||||
const descriptor = JSON.parse(await readFile(resolve(sourceRoot, entry), "utf8"));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.device-manager-control-plane-v2-reconciliation.v1",
|
||||
mode: "failed-v2-control-plane-baseline-adoption",
|
||||
failedPatchId: "device-manager-control-plane-20260811-003",
|
||||
failedArtifactSha256:
|
||||
"ba29618ffbfed55448768794f28b18dda439ddb39a1d2a4f1dece19de7f29990",
|
||||
backupId:
|
||||
"device-plane-device-manager-control-plane-20260811-003-20260811-012505",
|
||||
failureClass: "deterministic-runtime-module-resolution",
|
||||
missingModule: "/packages/external-provider-contract/src/credential-reference.mjs",
|
||||
correctiveAction: "runtime-local-contract-adapter+staged-module-import-gate",
|
||||
sourceAction: "publish-reconciliation-marker-only",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
preservedServices: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target",
|
||||
],
|
||||
absentService: "device-manager",
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
publicIngress: "disabled",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
rollback: "marker-only-runtime-unchanged",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_manager_v2_reconciliation_descriptor_mismatch");
|
||||
}
|
||||
|
||||
await mkdir(dirname(join(payload, entry)), { recursive: true });
|
||||
await cp(resolve(sourceRoot, entry), join(payload, entry), { force: true });
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${entry}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{ encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
const sha256 = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
component: "device-plane",
|
||||
artifact: target,
|
||||
sha256,
|
||||
entries: [entry],
|
||||
build: [],
|
||||
services: [],
|
||||
transition: "failed-v2-control-plane-baseline-adoption",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
sourceAction: "publish-reconciliation-marker-only",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const failedFoundationCompose = resolve(
|
||||
scriptDir,
|
||||
"fixtures/device-plane-foundation-internal-only-v1.yml",
|
||||
);
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "device-plane-foundation-20260725-001", ...extra] =
|
||||
process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error("usage: build-device-plane-artifact.mjs [patch-id]");
|
||||
}
|
||||
|
||||
const files = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
];
|
||||
const ignoredBasenames = new Set([
|
||||
".DS_Store",
|
||||
".git",
|
||||
"node_modules",
|
||||
]);
|
||||
const ignoredDirectoryNames = new Set(["test"]);
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-plane-artifact-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
|
||||
|
||||
await assertSourceBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
const source = sourceRelative === "docker-compose.device-plane.yml"
|
||||
? failedFoundationCompose
|
||||
: resolve(sourceRoot, sourceRelative);
|
||||
await copySafe(
|
||||
source,
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
entries: files,
|
||||
services: ["device-control-core", "device-gateway"],
|
||||
preserved: [
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
excluded: [
|
||||
".env*",
|
||||
"node_modules",
|
||||
"**/test",
|
||||
"docs",
|
||||
"runtime",
|
||||
"secrets",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertSourceBoundary() {
|
||||
const composeSource = failedFoundationCompose;
|
||||
const compose = await readFile(
|
||||
composeSource,
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "false"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "false"',
|
||||
'"127.0.0.1:18120:18120"',
|
||||
'"127.0.0.1:18121:18121"',
|
||||
"source: /volume1/docker/nodedc-device-plane/secrets/postgres-password",
|
||||
"create_host_path: false",
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
"pull_policy: never",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(`device_plane_compose_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"9921:9921",
|
||||
"0.0.0.0:9921",
|
||||
"DEVICE_DISCOVERY_INGEST_ENABLED: \"true\"",
|
||||
"DEVICE_GATEWAY_LISTEN_ENABLED: \"true\"",
|
||||
"POSTGRES_PASSWORD:",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(`device_plane_compose_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, source)}`,
|
||||
);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) {
|
||||
throw new Error(`source_type_rejected:${source}`);
|
||||
}
|
||||
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| (entry.isDirectory() && ignoredDirectoryNames.has(entry.name))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const composeFixtureRelative =
|
||||
"infra/deploy-runner/fixtures/device-plane-b2-discovery-ingress-v1.yml";
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-b2-discovery-loopback-20260726-002",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-b2-discovery-ingress-artifact.mjs "
|
||||
+ "[patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
"services/device-edge-relay/package.json",
|
||||
"deployment/device-plane-b2-discovery-ingress-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
const ignoredDirectoryNames = new Set(["test"]);
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-b2-discovery-ingress-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
const source = sourceRelative === "docker-compose.device-plane.yml"
|
||||
? resolve(sourceRoot, composeFixtureRelative)
|
||||
: resolve(sourceRoot, sourceRelative);
|
||||
await copySafe(
|
||||
source,
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(stage, "files.txt"),
|
||||
`${files.join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "verified-b2-loopback-discovery-only",
|
||||
entries: files,
|
||||
services: ["device-control-core", "device-gateway"],
|
||||
preservedRuntime: [
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
ingress: {
|
||||
transport: "tcp",
|
||||
published: "127.0.0.1:9921:9921",
|
||||
mode: "loopback-discovery-only",
|
||||
framing: "verified-read-only",
|
||||
lifecycle: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
},
|
||||
rollback: "restore-source-and-predecessor-stateless-runtime",
|
||||
excluded: [
|
||||
".env*",
|
||||
"node_modules",
|
||||
"**/test",
|
||||
"docs",
|
||||
"runtime",
|
||||
"secrets",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertBoundary() {
|
||||
const compose = await readFile(
|
||||
resolve(sourceRoot, composeFixtureRelative),
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
|
||||
'DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"',
|
||||
"DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token",
|
||||
'"127.0.0.1:18120:18120"',
|
||||
'"127.0.0.1:18121:18121"',
|
||||
'"127.0.0.1:9921:9921"',
|
||||
"name: nodedc-device-plane-private",
|
||||
"internal: true",
|
||||
"name: nodedc-device-plane-control",
|
||||
"internal: false",
|
||||
'com.docker.network.bridge.enable_ip_masquerade: "false"',
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
"pull_policy: never",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(
|
||||
`device_plane_b2_ingress_boundary_missing:${fragment}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"POSTGRES_PASSWORD:",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN:",
|
||||
"DEVICE_IDENTIFIER_PEPPER:",
|
||||
"DEVICE_GATEWAY_COMMAND",
|
||||
"9921:9921/udp",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(
|
||||
`device_plane_b2_ingress_boundary_violation:${forbidden}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-plane-b2-discovery-ingress-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.b2-discovery-ingress.v1",
|
||||
mode: "verified-b2-loopback-discovery-only",
|
||||
predecessorPatchId:
|
||||
"device-plane-foundation-network-publication-20260725-003",
|
||||
predecessorArtifactSha256:
|
||||
"6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf",
|
||||
sourceAction: "publish-verified-b2-loopback-discovery-source",
|
||||
runtimeAction: "build-and-recreate-stateless-services",
|
||||
selectedServices: ["device-control-core", "device-gateway"],
|
||||
preservedServices: ["device-postgres"],
|
||||
privateNetwork: "nodedc-device-plane-private",
|
||||
controlNetwork: "nodedc-device-plane-control",
|
||||
publishedPorts: [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
"127.0.0.1:9921:9921/tcp",
|
||||
],
|
||||
protocolProfile: "arusnavi.b2.internal.v1",
|
||||
framingSpecification:
|
||||
"arusnavi.internal.protocol-sheet.gid-12.v1",
|
||||
identityTrust: "claimed-not-ownership-proof",
|
||||
discoveryLifecycle: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
rollback: "restore-source-and-predecessor-stateless-runtime",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_b2_ingress_descriptor_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, source)}`,
|
||||
);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
force: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) {
|
||||
throw new Error(`source_type_rejected:${source}`);
|
||||
}
|
||||
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| (
|
||||
entry.isDirectory()
|
||||
&& ignoredDirectoryNames.has(entry.name)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const descriptorRelative =
|
||||
"deployment/device-plane-b2-discovery-loopback-recovery-v1.json";
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-b2-discovery-loopback-recovery-20260802-004",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: "
|
||||
+ "build-device-plane-b2-discovery-loopback-recovery-artifact.mjs "
|
||||
+ "[patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [descriptorRelative];
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-b2-loopback-recovery-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertRecoveryDescriptor();
|
||||
|
||||
try {
|
||||
const source = resolve(sourceRoot, descriptorRelative);
|
||||
const sourceStat = await lstat(source);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||
throw new Error("device_plane_b2_recovery_descriptor_unsafe");
|
||||
}
|
||||
await mkdir(dirname(join(payload, descriptorRelative)), {
|
||||
recursive: true,
|
||||
});
|
||||
await cp(source, join(payload, descriptorRelative), {
|
||||
force: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(stage, "files.txt"),
|
||||
`${files.join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "failed-b2-loopback-build-reconciliation",
|
||||
entries: files,
|
||||
build: [],
|
||||
services: [],
|
||||
preservedRuntime: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
sourceAction: "publish-reconciliation-marker-only",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
ingress: "disabled:127.0.0.1:9921/tcp:closed",
|
||||
rollback: "marker-only-runtime-unchanged",
|
||||
excluded: [
|
||||
"application-source",
|
||||
"compose",
|
||||
"Dockerfile",
|
||||
"secrets",
|
||||
"runtime",
|
||||
"database",
|
||||
"Gelios",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertRecoveryDescriptor() {
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(sourceRoot, descriptorRelative),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.b2-discovery-loopback-recovery.v1",
|
||||
mode: "failed-b2-loopback-build-reconciliation",
|
||||
failedPatchId: "device-plane-b2-discovery-loopback-20260801-003",
|
||||
failedArtifactSha256:
|
||||
"7273c5bf67fe6bc1f1da66ad726009240d39ee3aee58201b96c23d6f707a3d84",
|
||||
failedBackupId:
|
||||
"device-plane-device-plane-b2-discovery-loopback-20260801-003-20260802-154311",
|
||||
sourceAction: "publish-reconciliation-marker-only",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
preservedServices: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
],
|
||||
expectedLoopbackPorts: [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
],
|
||||
closedPort: "127.0.0.1:9921/tcp",
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
rollback: "marker-only-runtime-unchanged",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_b2_recovery_descriptor_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-backhaul-target-tailnet-serve-20260804-002",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-backhaul-target-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
"docker-compose.device-plane.backhaul-target.yml",
|
||||
"services/device-backhaul-target",
|
||||
"deployment/device-plane-backhaul-target-tailnet-serve-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-backhaul-target-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
await copySafe(
|
||||
resolve(sourceRoot, sourceRelative),
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{ encoding: "utf8", maxBuffer: 128 * 1024 * 1024 },
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "failed-backhaul-target-to-loopback-tailnet-serve",
|
||||
entries: files,
|
||||
services: ["device-backhaul-target"],
|
||||
preservedRuntime: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
ingress: {
|
||||
loopbackListen: "127.0.0.1:2222/tcp",
|
||||
tailnetListen: "100.109.216.21:2222/tcp",
|
||||
transport: "tailscale-serve-private-ssh",
|
||||
serveTarget: "tcp://127.0.0.1:2222",
|
||||
permittedTarget: "127.0.0.1:9921",
|
||||
dockerPortPublication: "disabled",
|
||||
routerNatFirewall: "unchanged",
|
||||
edgePublicIngress: "disabled",
|
||||
funnel: "disabled",
|
||||
commandTransport: "disabled",
|
||||
},
|
||||
runtimeTrust: "runner-managed-not-in-artifact",
|
||||
rollback: "remove-tailnet-serve-target-and-restore-source",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertBoundary() {
|
||||
const compose = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-plane.backhaul-target.yml"),
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
"device-backhaul-target:",
|
||||
"image: nodedc/device-backhaul-target:local",
|
||||
"network_mode: host",
|
||||
'"127.0.0.1", "2222"',
|
||||
"/secrets/backhaul-target/ssh_host_ed25519_key",
|
||||
"/secrets/backhaul-target/authorized_keys",
|
||||
"no-new-privileges:true",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(`device_plane_backhaul_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"PasswordAuthentication yes",
|
||||
"0.0.0.0:2222",
|
||||
"9921:9921/udp",
|
||||
"DEVICE_GATEWAY_COMMAND",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(`device_plane_backhaul_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sshd = await readFile(
|
||||
resolve(sourceRoot, "services/device-backhaul-target/sshd_config"),
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
"ListenAddress 127.0.0.1",
|
||||
"PasswordAuthentication no",
|
||||
"KbdInteractiveAuthentication no",
|
||||
"AllowTcpForwarding local",
|
||||
"PermitOpen 127.0.0.1:9921",
|
||||
"GatewayPorts no",
|
||||
"PermitTunnel no",
|
||||
"AllowAgentForwarding no",
|
||||
"PermitTTY no",
|
||||
"ForceCommand /bin/false",
|
||||
]) {
|
||||
if (!sshd.includes(fragment)) {
|
||||
throw new Error(`device_plane_backhaul_sshd_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-plane-backhaul-target-tailnet-serve-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.backhaul-target-tailnet-serve.v1",
|
||||
mode: "failed-backhaul-target-to-loopback-tailnet-serve",
|
||||
failedPatchId: "device-plane-backhaul-target-20260803-001",
|
||||
failedArtifactSha256:
|
||||
"ed0bda4110a756c32be68990e2e0f647409d5a77eec7e26c18502bafbdc1bb76",
|
||||
failedBackupId:
|
||||
"device-plane-device-plane-backhaul-target-20260803-001-20260804-035519",
|
||||
predecessorPatchId:
|
||||
"device-plane-b2-discovery-loopback-20260803-006",
|
||||
predecessorArtifactSha256:
|
||||
"25f9e9e55e283e9b7bb5e128ff14a244f848b1c063acca9724a23206131c9adf",
|
||||
sourceAction: "publish-loopback-backhaul-target-source",
|
||||
runtimeAction: "build-create-target-and-register-private-tailnet-serve",
|
||||
composeOverlay: "docker-compose.device-plane.backhaul-target.yml",
|
||||
selectedServices: ["device-backhaul-target"],
|
||||
preservedServices: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
],
|
||||
loopbackListenAddress: "127.0.0.1",
|
||||
listenPort: 2222,
|
||||
tailnetAddress: "100.109.216.21",
|
||||
tailnetExposure: "tailscale-serve-private",
|
||||
tailscaleServeTarget: "tcp://127.0.0.1:2222",
|
||||
permittedTarget: "127.0.0.1:9921",
|
||||
networkMode: "host",
|
||||
dockerPortPublication: "disabled",
|
||||
routerNatFirewall: "unchanged",
|
||||
edgePublicIngress: "disabled",
|
||||
funnel: "disabled",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
runtimeTrust: "runner-managed",
|
||||
rollback: "remove-tailnet-serve-target-and-restore-source",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_backhaul_descriptor_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) {
|
||||
throw new Error(`source_type_rejected:${source}`);
|
||||
}
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| entry.name.endsWith("~")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await copySafe(join(source, entry.name), join(destination, entry.name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const descriptorRelative =
|
||||
"deployment/device-plane-backhaul-vps-enrollment-v1.json";
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-backhaul-vps-enrollment-20260806-001",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-backhaul-vps-enrollment-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.NODEDC_ALLOW_SUPERSEDED_TRANSPORT !== "test-only") {
|
||||
throw new Error("vps_initiated_transport_frozen:ADR-0001");
|
||||
}
|
||||
|
||||
const files = [descriptorRelative];
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-vps-enrollment-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertDescriptor();
|
||||
|
||||
try {
|
||||
const source = resolve(sourceRoot, descriptorRelative);
|
||||
const sourceStat = await lstat(source);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||
throw new Error("device_plane_vps_enrollment_descriptor_unsafe");
|
||||
}
|
||||
await mkdir(dirname(join(payload, descriptorRelative)), {
|
||||
recursive: true,
|
||||
});
|
||||
await cp(source, join(payload, descriptorRelative), {
|
||||
force: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const bytes = await readFile(target);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256,
|
||||
component: "device-plane",
|
||||
transition: "rotate-backhaul-client-mini-to-vps",
|
||||
entries: files,
|
||||
build: [],
|
||||
services: ["device-backhaul-target"],
|
||||
preservedRuntime: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Tailscale Serve",
|
||||
"Gelios",
|
||||
],
|
||||
publicIngress: "disabled",
|
||||
commandTransport: "disabled",
|
||||
runtimeKeyMaterial: "external-enrollment-only",
|
||||
rollback: "restore-previous-authorized-key-and-recreate-target",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertDescriptor() {
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(sourceRoot, descriptorRelative),
|
||||
"utf8",
|
||||
));
|
||||
if (
|
||||
descriptor.schemaVersion
|
||||
!== "nodedc.device-plane.backhaul-vps-enrollment.v1"
|
||||
|| descriptor.mode !== "rotate-backhaul-client-mini-to-vps"
|
||||
|| descriptor.predecessorPatchId
|
||||
!== "device-plane-backhaul-target-tailnet-serve-20260804-002"
|
||||
|| descriptor.predecessorArtifactSha256
|
||||
!== "219408705dd4d80a962ed00eeb53a69df0b9ab6458443734d5c9cd1d1f795eba"
|
||||
|| descriptor.nextKeyFingerprint
|
||||
!== "SHA256:HHTiDYiCRxSiKjBLCip6JMSzGfLGrDz5g8SIkosJcVw"
|
||||
|| descriptor.commandTransport !== "disabled"
|
||||
|| descriptor.gelios !== "untouched"
|
||||
|| descriptor.edgePublicIngress !== "disabled"
|
||||
) {
|
||||
throw new Error("device_plane_vps_enrollment_descriptor_mismatch");
|
||||
}
|
||||
const text = JSON.stringify(descriptor);
|
||||
for (const forbidden of [
|
||||
"PRIVATE KEY",
|
||||
"authorized_keys",
|
||||
"TS_AUTHKEY",
|
||||
"password",
|
||||
]) {
|
||||
if (text.includes(forbidden)) {
|
||||
throw new Error(`device_plane_vps_enrollment_boundary:${forbidden}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const networkPublicationCompose = resolve(
|
||||
scriptDir,
|
||||
"fixtures/device-plane-foundation-network-publication-v1.yml",
|
||||
);
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-foundation-network-publication-20260725-003",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-foundation-network-publication-artifact.mjs "
|
||||
+ "[patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
"deployment/device-plane-foundation-network-publication-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
const ignoredDirectoryNames = new Set(["test"]);
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-network-publication-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
const source = sourceRelative === "docker-compose.device-plane.yml"
|
||||
? networkPublicationCompose
|
||||
: resolve(sourceRoot, sourceRelative);
|
||||
await copySafe(
|
||||
source,
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(stage, "files.txt"),
|
||||
`${files.join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "failed-foundation-network-publication-correction",
|
||||
entries: files,
|
||||
build: [],
|
||||
services: ["device-control-core", "device-gateway"],
|
||||
preservedRuntime: [
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
networkChange: {
|
||||
private: "preserved:internal",
|
||||
control: "create:non-internal:no-masquerade",
|
||||
published: [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
],
|
||||
disabled: ["9921", "public-ingress", "command-transport"],
|
||||
},
|
||||
rollback:
|
||||
"restore-partial-source-and-internal-only-stateless-runtime",
|
||||
excluded: [
|
||||
".env*",
|
||||
"node_modules",
|
||||
"**/test",
|
||||
"docs",
|
||||
"runtime",
|
||||
"secrets",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertBoundary() {
|
||||
const compose = await readFile(
|
||||
networkPublicationCompose,
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "false"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "false"',
|
||||
'"127.0.0.1:18120:18120"',
|
||||
'"127.0.0.1:18121:18121"',
|
||||
"name: nodedc-device-plane-private",
|
||||
"internal: true",
|
||||
"name: nodedc-device-plane-control",
|
||||
"internal: false",
|
||||
'com.docker.network.bridge.enable_ip_masquerade: "false"',
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
"pull_policy: never",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(
|
||||
`device_plane_network_publication_boundary_missing:${fragment}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"9921:9921",
|
||||
"0.0.0.0:9921",
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
|
||||
"POSTGRES_PASSWORD:",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(
|
||||
`device_plane_network_publication_boundary_violation:${forbidden}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-plane-foundation-network-publication-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion:
|
||||
"nodedc.device-plane.foundation-network-publication.v1",
|
||||
mode: "failed-foundation-network-publication-correction",
|
||||
failedRecoveryPatchId:
|
||||
"device-plane-foundation-recovery-20260725-002",
|
||||
failedRecoveryArtifactSha256:
|
||||
"9183cc385142584bfd12510bb0a3e6b833b2fd26607436f2486a564c628ea1bf",
|
||||
failedRecoveryBackupId:
|
||||
"device-plane-device-plane-foundation-recovery-20260725-002-20260725-232447",
|
||||
sourceAction: "publish-network-corrected-foundation-source",
|
||||
runtimeAction: "recreate-stateless-services-no-build",
|
||||
selectedServices: ["device-control-core", "device-gateway"],
|
||||
preservedServices: ["device-postgres"],
|
||||
privateNetwork: "nodedc-device-plane-private",
|
||||
controlNetwork: "nodedc-device-plane-control",
|
||||
publishedLoopbackPorts: [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
],
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
rollback:
|
||||
"restore-partial-source-and-internal-only-stateless-runtime",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
"device_plane_network_publication_descriptor_mismatch",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, source)}`,
|
||||
);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
force: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) {
|
||||
throw new Error(`source_type_rejected:${source}`);
|
||||
}
|
||||
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| (
|
||||
entry.isDirectory()
|
||||
&& ignoredDirectoryNames.has(entry.name)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const predecessorCompose = resolve(
|
||||
scriptDir,
|
||||
"fixtures/device-plane-foundation-internal-only-v1.yml",
|
||||
);
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-foundation-recovery-20260725-002",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-foundation-recovery-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
"deployment/device-plane-foundation-recovery-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([
|
||||
".DS_Store",
|
||||
".git",
|
||||
"node_modules",
|
||||
]);
|
||||
const ignoredDirectoryNames = new Set(["test"]);
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-foundation-recovery-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertRecoveryBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
const source = sourceRelative === "docker-compose.device-plane.yml"
|
||||
? predecessorCompose
|
||||
: resolve(sourceRoot, sourceRelative);
|
||||
await copySafe(
|
||||
source,
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(stage, "files.txt"),
|
||||
`${files.join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "failed-foundation-live-runtime-adoption",
|
||||
entries: files,
|
||||
build: [],
|
||||
services: [],
|
||||
preservedRuntime: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
],
|
||||
sourceAction: "publish-exact-failed-artifact-source",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
excluded: [
|
||||
".env*",
|
||||
"node_modules",
|
||||
"**/test",
|
||||
"docs",
|
||||
"runtime",
|
||||
"secrets",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertRecoveryBoundary() {
|
||||
const compose = await readFile(
|
||||
predecessorCompose,
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "false"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "false"',
|
||||
'"127.0.0.1:18120:18120"',
|
||||
'"127.0.0.1:18121:18121"',
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(
|
||||
`device_plane_recovery_compose_boundary_missing:${fragment}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"9921:9921",
|
||||
"0.0.0.0:9921",
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
|
||||
"POSTGRES_PASSWORD:",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(
|
||||
`device_plane_recovery_compose_boundary_violation:${forbidden}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-plane-foundation-recovery-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.foundation-recovery.v1",
|
||||
mode: "failed-foundation-live-runtime-adoption",
|
||||
failedPatchId: "device-plane-foundation-20260725-001",
|
||||
failedArtifactSha256:
|
||||
"23d428de547854ad8b1a026671e2f850386ab0be98bde80f016f1e9db631ee24",
|
||||
backupId:
|
||||
"device-plane-device-plane-foundation-20260725-001-20260725-223441",
|
||||
sourceAction: "publish-exact-failed-artifact-source",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
preservedServices: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
],
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
rollback: "source-only-runtime-unchanged",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_recovery_descriptor_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, source)}`,
|
||||
);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
force: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) {
|
||||
throw new Error(`source_type_rejected:${source}`);
|
||||
}
|
||||
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| (
|
||||
entry.isDirectory()
|
||||
&& ignoredDirectoryNames.has(entry.name)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "device-plane-postgres-bootstrap-20260725-001", ...extra] =
|
||||
process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-postgres-bootstrap-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
"docker-compose.device-plane.yml",
|
||||
"deployment/device-postgres-bootstrap-v1.json",
|
||||
];
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-postgres-bootstrap-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertSourceBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const relativePath of files) {
|
||||
const source = resolve(sourceRoot, relativePath);
|
||||
const sourceStat = await lstat(source);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||
throw new Error(`bootstrap_source_file_required:${relativePath}`);
|
||||
}
|
||||
const destination = join(payload, relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
force: true,
|
||||
verbatimSymlinks: false,
|
||||
});
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const sha256 = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256,
|
||||
component: "device-plane",
|
||||
entries: files,
|
||||
services: ["device-postgres"],
|
||||
mode: "create-if-absent",
|
||||
rollbackVolumePolicy: "preserve",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertSourceBoundary() {
|
||||
const descriptor = JSON.parse(
|
||||
await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-postgres-bootstrap-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.postgres-bootstrap.v1",
|
||||
service: "device-postgres",
|
||||
volume: "nodedc-device-plane-postgres-data",
|
||||
mode: "create-if-absent",
|
||||
ordinaryApplicationSelection: "forbidden",
|
||||
rollbackVolumePolicy: "preserve",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_postgres_bootstrap_descriptor_mismatch");
|
||||
}
|
||||
|
||||
const compose = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-plane.yml"),
|
||||
"utf8",
|
||||
);
|
||||
for (const required of [
|
||||
"device-postgres:",
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
"POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password",
|
||||
"create_host_path: false",
|
||||
]) {
|
||||
if (!compose.includes(required)) {
|
||||
throw new Error(`device_plane_postgres_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
const postgresStart = compose.indexOf(" device-postgres:");
|
||||
const postgresEnd = compose.indexOf("\n device-control-core:");
|
||||
if (
|
||||
postgresStart < 0
|
||||
|| postgresEnd <= postgresStart
|
||||
|| compose.slice(postgresStart, postgresEnd).includes("\n ports:")
|
||||
) {
|
||||
throw new Error("device_plane_postgres_host_port_forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
services:
|
||||
device-postgres:
|
||||
image: postgres:16-alpine
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: device_plane
|
||||
POSTGRES_USER: device_plane
|
||||
POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
volumes:
|
||||
- type: volume
|
||||
source: device-plane-postgres-data
|
||||
target: /var/lib/postgresql/data
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
networks:
|
||||
- device-plane-private
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-control-core:
|
||||
image: nodedc/device-control-core:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "18120"
|
||||
DEVICE_DATABASE_HOST: device-postgres
|
||||
DEVICE_DATABASE_PORT: "5432"
|
||||
DEVICE_DATABASE_NAME: device_plane
|
||||
DEVICE_DATABASE_USER: device_plane
|
||||
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
DEVICE_DATABASE_POOL_SIZE: "10"
|
||||
DEVICE_DISCOVERY_INGEST_ENABLED: "true"
|
||||
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
|
||||
DEVICE_IDENTIFIER_PEPPER_FILE: /run/nodedc-secrets/identifier-pepper
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token
|
||||
target: /run/nodedc-secrets/gateway-core-token
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/identifier-pepper
|
||||
target: /run/nodedc-secrets/identifier-pepper
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18120:18120"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
depends_on:
|
||||
device-postgres:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-gateway:
|
||||
image: nodedc/device-gateway:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
|
||||
DEVICE_GATEWAY_HEALTH_PORT: "18121"
|
||||
DEVICE_GATEWAY_LISTEN_ENABLED: "true"
|
||||
DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"
|
||||
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
|
||||
DEVICE_GATEWAY_TCP_PORT: "9921"
|
||||
DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120
|
||||
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
|
||||
DEVICE_GATEWAY_CORE_TIMEOUT_MS: "5000"
|
||||
DEVICE_GATEWAY_MAX_BUFFERED_BYTES: "65536"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS: "100"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS: "10"
|
||||
DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
|
||||
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token
|
||||
target: /run/nodedc-secrets/gateway-core-token
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18121:18121"
|
||||
- "127.0.0.1:9921:9921"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
depends_on:
|
||||
device-control-core:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-plane-private:
|
||||
name: nodedc-device-plane-private
|
||||
internal: true
|
||||
device-plane-control:
|
||||
name: nodedc-device-plane-control
|
||||
driver: bridge
|
||||
internal: false
|
||||
driver_opts:
|
||||
com.docker.network.bridge.enable_ip_masquerade: "false"
|
||||
|
||||
volumes:
|
||||
device-plane-postgres-data:
|
||||
name: nodedc-device-plane-postgres-data
|
||||
@@ -0,0 +1,118 @@
|
||||
services:
|
||||
device-postgres:
|
||||
image: postgres:16-alpine
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: device_plane
|
||||
POSTGRES_USER: device_plane
|
||||
POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
volumes:
|
||||
- type: volume
|
||||
source: device-plane-postgres-data
|
||||
target: /var/lib/postgresql/data
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
networks:
|
||||
- device-plane-private
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-control-core:
|
||||
image: nodedc/device-control-core:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "18120"
|
||||
DEVICE_DATABASE_HOST: device-postgres
|
||||
DEVICE_DATABASE_PORT: "5432"
|
||||
DEVICE_DATABASE_NAME: device_plane
|
||||
DEVICE_DATABASE_USER: device_plane
|
||||
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
DEVICE_DATABASE_POOL_SIZE: "10"
|
||||
DEVICE_DISCOVERY_INGEST_ENABLED: "false"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18120:18120"
|
||||
networks:
|
||||
- device-plane-private
|
||||
depends_on:
|
||||
device-postgres:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-gateway:
|
||||
image: nodedc/device-gateway:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
|
||||
DEVICE_GATEWAY_HEALTH_PORT: "18121"
|
||||
DEVICE_GATEWAY_LISTEN_ENABLED: "false"
|
||||
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
|
||||
DEVICE_GATEWAY_TCP_PORT: "9921"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS: "100"
|
||||
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
|
||||
ports:
|
||||
- "127.0.0.1:18121:18121"
|
||||
networks:
|
||||
- device-plane-private
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-plane-private:
|
||||
name: nodedc-device-plane-private
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
device-plane-postgres-data:
|
||||
name: nodedc-device-plane-postgres-data
|
||||
@@ -0,0 +1,126 @@
|
||||
services:
|
||||
device-postgres:
|
||||
image: postgres:16-alpine
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: device_plane
|
||||
POSTGRES_USER: device_plane
|
||||
POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
volumes:
|
||||
- type: volume
|
||||
source: device-plane-postgres-data
|
||||
target: /var/lib/postgresql/data
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
networks:
|
||||
- device-plane-private
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-control-core:
|
||||
image: nodedc/device-control-core:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "18120"
|
||||
DEVICE_DATABASE_HOST: device-postgres
|
||||
DEVICE_DATABASE_PORT: "5432"
|
||||
DEVICE_DATABASE_NAME: device_plane
|
||||
DEVICE_DATABASE_USER: device_plane
|
||||
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
DEVICE_DATABASE_POOL_SIZE: "10"
|
||||
DEVICE_DISCOVERY_INGEST_ENABLED: "false"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18120:18120"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
depends_on:
|
||||
device-postgres:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-gateway:
|
||||
image: nodedc/device-gateway:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
|
||||
DEVICE_GATEWAY_HEALTH_PORT: "18121"
|
||||
DEVICE_GATEWAY_LISTEN_ENABLED: "false"
|
||||
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
|
||||
DEVICE_GATEWAY_TCP_PORT: "9921"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS: "100"
|
||||
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
|
||||
ports:
|
||||
- "127.0.0.1:18121:18121"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-plane-private:
|
||||
name: nodedc-device-plane-private
|
||||
internal: true
|
||||
device-plane-control:
|
||||
name: nodedc-device-plane-control
|
||||
driver: bridge
|
||||
internal: false
|
||||
driver_opts:
|
||||
com.docker.network.bridge.enable_ip_masquerade: "false"
|
||||
|
||||
volumes:
|
||||
device-plane-postgres-data:
|
||||
name: nodedc-device-plane-postgres-data
|
||||
Executable
+2106
File diff suppressed because it is too large
Load Diff
Executable
+868
@@ -0,0 +1,868 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Canonical data-only deploy runner for the dedicated NODE.DC Device Edge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
RUNNER_PATH = Path("/usr/local/sbin/nodedc-edge-deploy")
|
||||
LIVE_ROOT = Path("/home/ndcsudo/nodedc-device-edge/source")
|
||||
INBOX_ROOT = Path("/home/ndcsudo/nodedc-device-edge/deploy/inbox")
|
||||
STATE_ROOT = Path("/var/lib/nodedc-edge-deploy")
|
||||
APPLIED_ROOT = STATE_ROOT / "applied"
|
||||
FAILED_ROOT = STATE_ROOT / "failed"
|
||||
BACKUP_ROOT = STATE_ROOT / "backups"
|
||||
APPLIED_JOURNAL = STATE_ROOT / "state/applied.jsonl"
|
||||
FAILED_JOURNAL = STATE_ROOT / "state/failed.jsonl"
|
||||
DEPLOY_LOCK = STATE_ROOT / "state/deploy.lock"
|
||||
|
||||
DOCKER = "/usr/bin/docker"
|
||||
COMPONENT = "device-edge"
|
||||
ARTIFACT_TYPE = "app-overlay"
|
||||
PATCH_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
|
||||
MAX_ARTIFACT_BYTES = 16 * 1024 * 1024
|
||||
|
||||
COMPOSE_PROJECT = "nodedc-device-edge"
|
||||
BASE_COMPOSE = LIVE_ROOT / "docker-compose.device-edge.yml"
|
||||
INGRESS_COMPOSE = LIVE_ROOT / "docker-compose.device-edge.ingress.yml"
|
||||
RELAY_SERVICE = "device-edge-relay"
|
||||
RELAY_CONTAINER = "nodedc-device-edge-device-edge-relay-1"
|
||||
BACKHAUL_CONTAINER = "nodedc-device-edge-device-edge-backhaul-1"
|
||||
TAILNET_CONTAINER = "nodedc-device-edge-tailnet-1"
|
||||
RELAY_IMAGE = "nodedc/device-edge-relay:local"
|
||||
|
||||
INGRESS_PARENT = "enp1s0f0"
|
||||
INGRESS_SUBNET = "192.168.68.0/22"
|
||||
INGRESS_GATEWAY = "192.168.68.1"
|
||||
INGRESS_IPV4 = "192.168.71.253"
|
||||
INGRESS_PORT = 9921
|
||||
INGRESS_NETWORK = "nodedc-device-edge-ingress"
|
||||
INGRESS_IPV4_APPROVED = True
|
||||
INGRESS_IPV4_APPROVAL = "approved-outside-dhcp-pool"
|
||||
|
||||
ENTRIES = (
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml",
|
||||
"services/device-edge-relay/Dockerfile",
|
||||
"services/device-edge-relay/src",
|
||||
"deployment/device-edge-admission-gate-v1.json",
|
||||
)
|
||||
|
||||
PAYLOAD_FILE_SHA256 = {
|
||||
"docker-compose.device-edge.yml":
|
||||
"666945ffd9512355e610ecd36a9df96936477315150555def93e0243e8ff1e22",
|
||||
"docker-compose.device-edge.ingress.yml":
|
||||
"11bedfd7fdea749ca1bdb3b35b9c136c86b330f51a9001f0b38c4618f6f96108",
|
||||
"services/device-edge-relay/Dockerfile":
|
||||
"f2f15b7618ac2ab3a1d4dd40041d06d1e9a695edcfc168d8835f9560b4897e70",
|
||||
"services/device-edge-relay/src/runtime.mjs":
|
||||
"21e83678980aa61127bf9f3d77982dd485c4aaae208c43818db7bb1cc150b83a",
|
||||
"services/device-edge-relay/src/server.mjs":
|
||||
"1b99ec944f1d3fbadded045b159f08624e2829620cec39a97f6b4b8cdcd2be22",
|
||||
"deployment/device-edge-admission-gate-v1.json":
|
||||
"e6c1f21ff297b451c42b6746bc2063484874435dfa9f1614410a7cbe84f0ce6f",
|
||||
}
|
||||
|
||||
PREDECESSOR_FILE_SHA256 = {
|
||||
"docker-compose.device-edge.yml":
|
||||
"7f13c11d6d4d541964053c0a8cf791e401947d34c42e0f7c26f9f9df26fa00b5",
|
||||
"docker-compose.device-edge.ingress.yml":
|
||||
"a4afd04755530fc3b9be64d1a65f0f7282a9539bcc1985bfd880aa904e1c4d8f",
|
||||
"services/device-edge-relay/Dockerfile":
|
||||
"f2f15b7618ac2ab3a1d4dd40041d06d1e9a695edcfc168d8835f9560b4897e70",
|
||||
"services/device-edge-relay/src/runtime.mjs":
|
||||
"ae8bf8b55603bab266b6fa6e9bc65c9f310a9d94a54db04e2130704e38622ffc",
|
||||
"services/device-edge-relay/src/server.mjs":
|
||||
"e4b051b74f934bd37322440e6a013fb6774a76607da08f9cc1e844fc109c83c1",
|
||||
"deployment/device-edge-ingress-ipvlan-v1.json":
|
||||
"b9ce402db0c059a76f07a8d4a34297aff2250fd1c0d1aed9970b3a88f4e75d7f",
|
||||
}
|
||||
|
||||
PREDECESSOR_ABSENT = {
|
||||
"deployment/device-edge-admission-gate-v1.json",
|
||||
}
|
||||
|
||||
|
||||
class DeployError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def die(message: str) -> None:
|
||||
raise DeployError(message)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def run(command, *, check=True, capture=True, cwd=None, timeout=180):
|
||||
result = subprocess.run(
|
||||
[str(value) for value in command],
|
||||
check=False,
|
||||
capture_output=capture,
|
||||
text=True,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
timeout=timeout,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "command failed").strip()
|
||||
die(f"command failed: {command[0]}: {detail}")
|
||||
return result
|
||||
|
||||
|
||||
def docker_json(*args):
|
||||
result = run([DOCKER, *args])
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as error:
|
||||
die(f"Docker JSON response invalid: {error}")
|
||||
|
||||
|
||||
def expected_descriptor():
|
||||
return {
|
||||
"schemaVersion": "nodedc.device-edge.admission-gate.v1",
|
||||
"mode": "single-nic-ipvlan-b2-relay-only",
|
||||
"runtimeHost": "ndcmini12",
|
||||
"component": COMPONENT,
|
||||
"selectedServices": [RELAY_SERVICE],
|
||||
"preservedServices": ["device-edge-backhaul", "tailnet"],
|
||||
"composeProject": COMPOSE_PROJECT,
|
||||
"composeFiles": [
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml",
|
||||
],
|
||||
"parentInterface": INGRESS_PARENT,
|
||||
"lanSubnet": INGRESS_SUBNET,
|
||||
"lanGateway": INGRESS_GATEWAY,
|
||||
"ingressIpv4": INGRESS_IPV4,
|
||||
"ingressIpv4Approval": INGRESS_IPV4_APPROVAL,
|
||||
"ingressNetwork": INGRESS_NETWORK,
|
||||
"deviceTcpListen": f"{INGRESS_IPV4}:{INGRESS_PORT}",
|
||||
"hostPortPublication": "disabled",
|
||||
"healthPublication": "disabled",
|
||||
"privateUpstream": "device-edge-backhaul:19921",
|
||||
"sourceAdmission": "public-ipv4-only",
|
||||
"maxTrackedSourceAddresses": 2048,
|
||||
"maxBytesPerDirection": 262144,
|
||||
"protocolInspection": "gateway-owned",
|
||||
"identityTrust": "claimed-not-ownership-proof",
|
||||
"discoveryLifecycle": "quarantine",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"amneziaHostFullTunnel": "preserved",
|
||||
"routerNatFirewall": "separate-manual-gate",
|
||||
"rollback": "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation",
|
||||
}
|
||||
|
||||
|
||||
def assert_root():
|
||||
if os.geteuid() != 0:
|
||||
die("nodedc-edge-deploy must run as root")
|
||||
|
||||
|
||||
def assert_regular_nonsymlink(path: Path, label: str):
|
||||
if not path.exists() or path.is_symlink() or not path.is_file():
|
||||
die(f"{label} must be a regular non-symlink file")
|
||||
|
||||
|
||||
def parse_manifest(raw: str):
|
||||
values = {}
|
||||
for line in raw.splitlines():
|
||||
if not line or "=" not in line:
|
||||
die("artifact manifest is malformed")
|
||||
key, value = line.split("=", 1)
|
||||
if key in values or key not in {"id", "component", "type"}:
|
||||
die("artifact manifest key set is invalid")
|
||||
values[key] = value
|
||||
if set(values) != {"id", "component", "type"}:
|
||||
die("artifact manifest key set is incomplete")
|
||||
if not PATCH_ID_RE.fullmatch(values["id"]):
|
||||
die("artifact patch id is invalid")
|
||||
if values["component"] != COMPONENT or values["type"] != ARTIFACT_TYPE:
|
||||
die("artifact component/type mismatch")
|
||||
return values
|
||||
|
||||
|
||||
def safe_tar_member(member: tarfile.TarInfo):
|
||||
path = PurePosixPath(member.name)
|
||||
if path.is_absolute() or ".." in path.parts or not path.parts:
|
||||
die("artifact contains an unsafe path")
|
||||
if not (member.isfile() or member.isdir()):
|
||||
die("artifact contains a non-file/non-directory member")
|
||||
lowered = {part.lower() for part in path.parts}
|
||||
if any(
|
||||
part.startswith(".env")
|
||||
or part in {
|
||||
".git",
|
||||
"node_modules",
|
||||
"secrets",
|
||||
"keys",
|
||||
"trust",
|
||||
"runtime",
|
||||
"logs",
|
||||
"uploads",
|
||||
}
|
||||
for part in lowered
|
||||
):
|
||||
die("artifact contains a forbidden boundary")
|
||||
if any(part.startswith("._") for part in path.parts):
|
||||
die("artifact contains AppleDouble metadata")
|
||||
|
||||
|
||||
def load_artifact(artifact: Path, extraction_root: Path):
|
||||
artifact = artifact.resolve(strict=True)
|
||||
if artifact.parent != INBOX_ROOT.resolve(strict=True):
|
||||
die("artifact must be an explicit file in the Device Edge inbox")
|
||||
assert_regular_nonsymlink(artifact, "artifact")
|
||||
if artifact.suffix != ".tgz" or artifact.stat().st_size > MAX_ARTIFACT_BYTES:
|
||||
die("artifact extension/size rejected")
|
||||
|
||||
seen = set()
|
||||
with tarfile.open(artifact, "r:gz") as archive:
|
||||
for member in archive.getmembers():
|
||||
safe_tar_member(member)
|
||||
if member.name in seen:
|
||||
die("artifact contains duplicate members")
|
||||
seen.add(member.name)
|
||||
required = {"manifest.env", "files.txt", "payload"}
|
||||
if not required.issubset(seen):
|
||||
die("artifact top-level contract is incomplete")
|
||||
if any(name.split("/", 1)[0] not in required for name in seen):
|
||||
die("artifact contains an unexpected top-level member")
|
||||
archive.extractall(extraction_root, filter="data")
|
||||
|
||||
manifest = parse_manifest(
|
||||
(extraction_root / "manifest.env").read_text(encoding="utf-8")
|
||||
)
|
||||
entries = tuple(
|
||||
line for line in
|
||||
(extraction_root / "files.txt").read_text(encoding="utf-8").splitlines()
|
||||
if line
|
||||
)
|
||||
if entries != ENTRIES or len(entries) != len(set(entries)):
|
||||
die("Device Edge artifact file selection mismatch")
|
||||
payload = extraction_root / "payload"
|
||||
validate_payload(payload)
|
||||
return manifest, entries, payload, sha256_file(artifact), artifact
|
||||
|
||||
|
||||
def validate_payload(payload: Path):
|
||||
actual_files = {
|
||||
path.relative_to(payload).as_posix(): sha256_file(path)
|
||||
for path in payload.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
if actual_files != PAYLOAD_FILE_SHA256:
|
||||
die("Device Edge artifact payload digest set mismatch")
|
||||
descriptor = json.loads(
|
||||
(payload / "deployment/device-edge-admission-gate-v1.json")
|
||||
.read_text(encoding="utf-8")
|
||||
)
|
||||
if descriptor != expected_descriptor():
|
||||
die("Device Edge ingress descriptor mismatch")
|
||||
|
||||
|
||||
def journal_records(path: Path):
|
||||
if not path.exists():
|
||||
return []
|
||||
records = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
records.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
die(f"journal is malformed: {path}")
|
||||
return records
|
||||
|
||||
|
||||
def assert_new_identity(patch_id: str, artifact_sha256: str):
|
||||
records = journal_records(APPLIED_JOURNAL) + journal_records(FAILED_JOURNAL)
|
||||
if any(record.get("patch") == patch_id for record in records):
|
||||
die("Device Edge patch id is terminally recorded")
|
||||
if any(record.get("sha256") == artifact_sha256 for record in records):
|
||||
die("Device Edge artifact digest is terminally recorded")
|
||||
|
||||
|
||||
def current_source_state():
|
||||
state = {}
|
||||
for relative, expected in PREDECESSOR_FILE_SHA256.items():
|
||||
path = LIVE_ROOT / relative
|
||||
assert_regular_nonsymlink(path, f"predecessor {relative}")
|
||||
state[relative] = sha256_file(path)
|
||||
if state[relative] != expected:
|
||||
die(f"Device Edge predecessor drift: {relative}")
|
||||
for relative in PREDECESSOR_ABSENT:
|
||||
if (LIVE_ROOT / relative).exists():
|
||||
die(f"Device Edge predecessor unexpected path: {relative}")
|
||||
return state
|
||||
|
||||
|
||||
def inspect_container(name: str):
|
||||
response = docker_json("inspect", name)
|
||||
if len(response) != 1:
|
||||
die(f"container inspect cardinality mismatch: {name}")
|
||||
return response[0]
|
||||
|
||||
|
||||
def container_health(container):
|
||||
health = container.get("State", {}).get("Health")
|
||||
return health.get("Status") if health else None
|
||||
|
||||
|
||||
def preserved_runtime_snapshot():
|
||||
snapshot = {}
|
||||
for name in (BACKHAUL_CONTAINER, TAILNET_CONTAINER):
|
||||
container = inspect_container(name)
|
||||
if container.get("State", {}).get("Status") != "running":
|
||||
die(f"preserved Device Edge service is not running: {name}")
|
||||
if name == BACKHAUL_CONTAINER and container_health(container) != "healthy":
|
||||
die("Device Edge backhaul is not healthy")
|
||||
snapshot[name] = {
|
||||
"Id": container.get("Id"),
|
||||
"Image": container.get("Image"),
|
||||
"StartedAt": container.get("State", {}).get("StartedAt"),
|
||||
"RestartCount": container.get("RestartCount"),
|
||||
"PortBindings": container.get("HostConfig", {}).get("PortBindings"),
|
||||
}
|
||||
return snapshot
|
||||
|
||||
|
||||
def assert_preserved_runtime(snapshot):
|
||||
current_snapshot = preserved_runtime_snapshot()
|
||||
for name, expected in snapshot.items():
|
||||
current = current_snapshot[name]
|
||||
if current != expected:
|
||||
die(f"preserved Device Edge runtime changed: {name}")
|
||||
|
||||
|
||||
def validate_predecessor_runtime():
|
||||
relay = inspect_container(RELAY_CONTAINER)
|
||||
if relay.get("State", {}).get("Status") != "running":
|
||||
die("Device Edge IPvlan predecessor relay is not running")
|
||||
if container_health(relay) != "healthy":
|
||||
die("Device Edge IPvlan predecessor relay is not healthy")
|
||||
environment = set(relay.get("Config", {}).get("Env") or [])
|
||||
required = {
|
||||
"DEVICE_EDGE_RELAY_HEALTH_HOST=127.0.0.1",
|
||||
"DEVICE_EDGE_RELAY_INGRESS_ENABLED=true",
|
||||
"DEVICE_EDGE_RELAY_TCP_HOST=0.0.0.0",
|
||||
"DEVICE_EDGE_RELAY_TCP_PORT=9921",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_HOST=device-edge-backhaul",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921",
|
||||
}
|
||||
if not required.issubset(environment):
|
||||
die("Device Edge IPvlan predecessor environment mismatch")
|
||||
bindings = relay.get("HostConfig", {}).get("PortBindings") or {}
|
||||
if bindings not in ({}, None):
|
||||
die("Device Edge IPvlan predecessor host publication mismatch")
|
||||
validate_network_runtime(relay)
|
||||
|
||||
|
||||
def validate_host_network_boundary():
|
||||
if socket.gethostname() != "ndcmini12":
|
||||
die("Device Edge runtime host mismatch")
|
||||
route = run(["/usr/sbin/ip", "-4", "route", "show"]).stdout
|
||||
for line in (
|
||||
"0.0.0.0/1 dev amn0 metric 1",
|
||||
"128.0.0.0/1 dev amn0 metric 1",
|
||||
"default via 192.168.68.1 dev enp1s0f0",
|
||||
"192.168.68.0/22 dev enp1s0f0",
|
||||
):
|
||||
if line not in route:
|
||||
die(f"Device Edge host route boundary mismatch: {line}")
|
||||
if run(["/usr/bin/systemctl", "is-active", "AmneziaVPN.service"]).stdout.strip() != "active":
|
||||
die("AmneziaVPN must remain active for this transition")
|
||||
interface = run([
|
||||
"/usr/sbin/ip", "-4", "-brief", "address", "show", "dev", INGRESS_PARENT,
|
||||
]).stdout
|
||||
if "192.168.68.54/22" not in interface or "UP" not in interface:
|
||||
die("Device Edge physical interface boundary mismatch")
|
||||
|
||||
|
||||
def arp_duplicate_detected(target_ip: str, interface: str, attempts=3):
|
||||
protocol = 0x0806
|
||||
raw = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(protocol))
|
||||
try:
|
||||
raw.bind((interface, 0))
|
||||
source_mac = raw.getsockname()[4]
|
||||
target = socket.inet_aton(target_ip)
|
||||
ethernet = b"\xff" * 6 + source_mac + struct.pack("!H", protocol)
|
||||
arp = struct.pack(
|
||||
"!HHBBH6s4s6s4s",
|
||||
1,
|
||||
0x0800,
|
||||
6,
|
||||
4,
|
||||
1,
|
||||
source_mac,
|
||||
b"\x00" * 4,
|
||||
b"\x00" * 6,
|
||||
target,
|
||||
)
|
||||
raw.setblocking(False)
|
||||
for _ in range(attempts):
|
||||
raw.send(ethernet + arp)
|
||||
deadline = time.monotonic() + 0.7
|
||||
while time.monotonic() < deadline:
|
||||
ready, _, _ = select.select([raw], [], [], deadline - time.monotonic())
|
||||
if not ready:
|
||||
break
|
||||
packet = raw.recv(2048)
|
||||
if len(packet) < 42 or packet[12:14] != b"\x08\x06":
|
||||
continue
|
||||
if packet[28:32] == target and packet[22:28] != source_mac:
|
||||
return True
|
||||
return False
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def preflight(manifest, artifact_sha256):
|
||||
if not INGRESS_IPV4_APPROVED:
|
||||
die("Device Edge ingress IPv4 approval is not granted")
|
||||
if INGRESS_IPV4_APPROVAL != "approved-outside-dhcp-pool":
|
||||
die("Device Edge ingress IPv4 approval contract mismatch")
|
||||
assert_new_identity(manifest["id"], artifact_sha256)
|
||||
current_source_state()
|
||||
validate_predecessor_runtime()
|
||||
preserved = preserved_runtime_snapshot()
|
||||
validate_host_network_boundary()
|
||||
return preserved
|
||||
|
||||
|
||||
def compose_command(*args, baseline=False):
|
||||
command = [
|
||||
DOCKER,
|
||||
"compose",
|
||||
"--project-name",
|
||||
COMPOSE_PROJECT,
|
||||
"--file",
|
||||
str(BASE_COMPOSE),
|
||||
]
|
||||
if not baseline:
|
||||
command.extend(["--file", str(INGRESS_COMPOSE)])
|
||||
command.extend(args)
|
||||
return command
|
||||
|
||||
|
||||
def ensure_state_directories():
|
||||
for path in (
|
||||
APPLIED_ROOT,
|
||||
FAILED_ROOT,
|
||||
BACKUP_ROOT,
|
||||
APPLIED_JOURNAL.parent,
|
||||
):
|
||||
path.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
os.chmod(path, 0o750)
|
||||
|
||||
|
||||
def acquire_lock():
|
||||
ensure_state_directories()
|
||||
try:
|
||||
descriptor = os.open(
|
||||
DEPLOY_LOCK,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
0o600,
|
||||
)
|
||||
except FileExistsError:
|
||||
die("Device Edge deploy lock is present")
|
||||
os.write(descriptor, f"pid={os.getpid()}\n".encode())
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def release_lock():
|
||||
try:
|
||||
DEPLOY_LOCK.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def create_backup(patch_id: str):
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
backup_id = f"{patch_id}-{timestamp}"
|
||||
backup = BACKUP_ROOT / backup_id
|
||||
backup.mkdir(parents=False, mode=0o750)
|
||||
present = []
|
||||
absent = []
|
||||
for relative in ENTRIES:
|
||||
source = LIVE_ROOT / relative
|
||||
target = backup / "payload" / relative
|
||||
if not source.exists():
|
||||
absent.append(relative)
|
||||
continue
|
||||
present.append(relative)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, target, symlinks=False)
|
||||
else:
|
||||
shutil.copy2(source, target, follow_symlinks=False)
|
||||
(backup / "backup.json").write_text(json.dumps({
|
||||
"schemaVersion": "nodedc.device-edge.backup.v1",
|
||||
"patch": patch_id,
|
||||
"present": present,
|
||||
"absent": absent,
|
||||
}, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
return backup_id, backup
|
||||
|
||||
|
||||
def publish_payload(payload: Path):
|
||||
for relative in ENTRIES:
|
||||
source = payload / relative
|
||||
target = LIVE_ROOT / relative
|
||||
if target.exists():
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, target, symlinks=False)
|
||||
else:
|
||||
shutil.copy2(source, target, follow_symlinks=False)
|
||||
|
||||
|
||||
def restore_backup(backup: Path):
|
||||
descriptor = json.loads((backup / "backup.json").read_text(encoding="utf-8"))
|
||||
for relative in ENTRIES:
|
||||
target = LIVE_ROOT / relative
|
||||
if target.exists():
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
for relative in descriptor["present"]:
|
||||
source = backup / "payload" / relative
|
||||
target = LIVE_ROOT / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, target, symlinks=False)
|
||||
else:
|
||||
shutil.copy2(source, target, follow_symlinks=False)
|
||||
|
||||
|
||||
def build_relay():
|
||||
run([
|
||||
DOCKER,
|
||||
"build",
|
||||
"--no-cache",
|
||||
"--network=host",
|
||||
"--file",
|
||||
"services/device-edge-relay/Dockerfile",
|
||||
"--tag",
|
||||
RELAY_IMAGE,
|
||||
".",
|
||||
], cwd=LIVE_ROOT, timeout=900, capture=False)
|
||||
|
||||
|
||||
def wait_healthy(name: str, timeout_seconds=150):
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
container = inspect_container(name)
|
||||
except DeployError:
|
||||
time.sleep(2)
|
||||
continue
|
||||
if (
|
||||
container.get("State", {}).get("Status") == "running"
|
||||
and container_health(container) == "healthy"
|
||||
):
|
||||
return container
|
||||
if container.get("State", {}).get("Status") in {"exited", "dead"}:
|
||||
die(f"container stopped before health acceptance: {name}")
|
||||
time.sleep(2)
|
||||
die(f"container health timeout: {name}")
|
||||
|
||||
|
||||
def validate_network_runtime(relay):
|
||||
networks = relay.get("NetworkSettings", {}).get("Networks") or {}
|
||||
if set(networks) != {"nodedc-device-edge-private", INGRESS_NETWORK}:
|
||||
die("Device Edge relay network set mismatch")
|
||||
if networks[INGRESS_NETWORK].get("IPAddress") != INGRESS_IPV4:
|
||||
die("Device Edge relay IPvlan address mismatch")
|
||||
response = docker_json("network", "inspect", INGRESS_NETWORK)
|
||||
if len(response) != 1:
|
||||
die("Device Edge ingress network cardinality mismatch")
|
||||
network = response[0]
|
||||
if network.get("Driver") != "ipvlan" or network.get("Internal") is True:
|
||||
die("Device Edge ingress network driver mismatch")
|
||||
options = network.get("Options") or {}
|
||||
if options.get("parent") != INGRESS_PARENT or options.get("ipvlan_mode") != "l2":
|
||||
die("Device Edge ingress network option mismatch")
|
||||
configs = network.get("IPAM", {}).get("Config") or []
|
||||
if len(configs) != 1:
|
||||
die("Device Edge ingress IPAM cardinality mismatch")
|
||||
if configs[0].get("Subnet") != INGRESS_SUBNET or configs[0].get("Gateway") != INGRESS_GATEWAY:
|
||||
die("Device Edge ingress IPAM mismatch")
|
||||
|
||||
|
||||
def validate_relay_runtime(preserved):
|
||||
relay = wait_healthy(RELAY_CONTAINER)
|
||||
if relay.get("Config", {}).get("User") != "1000:1000":
|
||||
die("Device Edge relay user mismatch")
|
||||
host = relay.get("HostConfig", {})
|
||||
if host.get("ReadonlyRootfs") is not True or host.get("Privileged") is not False:
|
||||
die("Device Edge relay filesystem/privilege mismatch")
|
||||
if set(host.get("CapDrop") or []) != {"ALL"}:
|
||||
die("Device Edge relay capability mismatch")
|
||||
if host.get("PortBindings") not in ({}, None):
|
||||
die("Device Edge relay host port publication detected")
|
||||
environment = set(relay.get("Config", {}).get("Env") or [])
|
||||
required = {
|
||||
"DEVICE_EDGE_RELAY_HEALTH_HOST=127.0.0.1",
|
||||
"DEVICE_EDGE_RELAY_INGRESS_ENABLED=true",
|
||||
"DEVICE_EDGE_RELAY_TCP_HOST=0.0.0.0",
|
||||
"DEVICE_EDGE_RELAY_TCP_PORT=9921",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_HOST=device-edge-backhaul",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921",
|
||||
"DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only",
|
||||
"DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES=2048",
|
||||
"DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION=262144",
|
||||
}
|
||||
if not required.issubset(environment):
|
||||
die("Device Edge relay environment mismatch")
|
||||
validate_network_runtime(relay)
|
||||
health_result = run([
|
||||
DOCKER,
|
||||
"exec",
|
||||
RELAY_CONTAINER,
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:18221/healthz').then(async r=>{if(!r.ok)process.exit(2);console.log(await r.text())}).catch(()=>process.exit(3))",
|
||||
])
|
||||
try:
|
||||
health = json.loads(health_result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
die("Device Edge relay health JSON invalid")
|
||||
expected_health = {
|
||||
"ok": True,
|
||||
"service": "nodedc-device-edge-relay",
|
||||
"ingress": "relay-only",
|
||||
"protocolInspection": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"sourceAdmission": "public-ipv4-only",
|
||||
}
|
||||
for key, expected in expected_health.items():
|
||||
if health.get(key) != expected:
|
||||
die(f"Device Edge relay health contract mismatch: {key}")
|
||||
run([
|
||||
DOCKER,
|
||||
"exec",
|
||||
RELAY_CONTAINER,
|
||||
"node",
|
||||
"-e",
|
||||
"const n=require('node:net');const s=n.connect({host:'device-edge-backhaul',port:19921});s.setTimeout(5000);s.once('connect',()=>{s.destroy();process.exit(0)});s.once('timeout',()=>process.exit(2));s.once('error',()=>process.exit(3))",
|
||||
])
|
||||
validate_host_network_boundary()
|
||||
assert_preserved_runtime(preserved)
|
||||
|
||||
|
||||
def write_journal(path: Path, record):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def archive_artifact(artifact: Path, destination_root: Path):
|
||||
destination = destination_root / artifact.name
|
||||
if destination.exists():
|
||||
die("Device Edge artifact archive collision")
|
||||
os.replace(artifact, destination)
|
||||
return destination
|
||||
|
||||
|
||||
def rollback(backup: Path, preserved):
|
||||
restore_backup(backup)
|
||||
run(compose_command(
|
||||
"up",
|
||||
"--detach",
|
||||
"--no-deps",
|
||||
"--force-recreate",
|
||||
"--pull",
|
||||
"never",
|
||||
RELAY_SERVICE,
|
||||
), cwd=LIVE_ROOT, timeout=300, capture=False)
|
||||
wait_healthy(RELAY_CONTAINER)
|
||||
current_source_state()
|
||||
validate_predecessor_runtime()
|
||||
assert_preserved_runtime(preserved)
|
||||
|
||||
|
||||
def plan_artifact(artifact_argument: str):
|
||||
assert_root()
|
||||
artifact = Path(artifact_argument)
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-edge-plan-") as directory:
|
||||
manifest, entries, _payload, digest, resolved = load_artifact(
|
||||
artifact,
|
||||
Path(directory),
|
||||
)
|
||||
preflight(manifest, digest)
|
||||
print("== plan ==")
|
||||
print(f"artifact={resolved.name}")
|
||||
print(f"sha256={digest}")
|
||||
print(f"id={manifest['id']}")
|
||||
print(f"component={COMPONENT}")
|
||||
print(f"type={ARTIFACT_TYPE}")
|
||||
print(f"payload_root={LIVE_ROOT}")
|
||||
print(f"compose_root={LIVE_ROOT}")
|
||||
print(f"compose_project={COMPOSE_PROJECT}")
|
||||
print("compose_files=docker-compose.device-edge.yml docker-compose.device-edge.ingress.yml")
|
||||
print("build=/usr/bin/docker build --no-cache --network=host -f services/device-edge-relay/Dockerfile -t nodedc/device-edge-relay:local .")
|
||||
print("services=device-edge-relay")
|
||||
print("preserved_services=device-edge-backhaul tailnet")
|
||||
print(f"device_edge_ingress=ipvlan:l2:{INGRESS_PARENT}:{INGRESS_IPV4}:{INGRESS_PORT}/tcp")
|
||||
print(f"device_edge_lan={INGRESS_SUBNET}:gateway:{INGRESS_GATEWAY}")
|
||||
print(f"device_edge_ingress_ipv4_approval={INGRESS_IPV4_APPROVAL}")
|
||||
print("device_edge_host_port_publication=disabled")
|
||||
print("device_edge_health_publication=disabled")
|
||||
print("device_edge_private_upstream=device-edge-backhaul:19921")
|
||||
print("device_edge_source_admission=public-ipv4-only")
|
||||
print("device_edge_source_table_limit=2048")
|
||||
print("device_edge_byte_limit_per_direction=262144")
|
||||
print("device_edge_command_transport=disabled")
|
||||
print("device_edge_discovery_lifecycle=quarantine")
|
||||
print("device_edge_gelios=untouched")
|
||||
print("device_edge_amnezia=preserved:active:host-full-tunnel")
|
||||
print("device_edge_router_nat_firewall=unchanged")
|
||||
print("device_edge_rollback=restore-reviewed-ipvlan-predecessor-no-router-mutation")
|
||||
print("state=new")
|
||||
print("== files ==")
|
||||
for entry in entries:
|
||||
print(f" {entry}")
|
||||
|
||||
|
||||
def apply_artifact(artifact_argument: str):
|
||||
assert_root()
|
||||
artifact = Path(artifact_argument)
|
||||
acquire_lock()
|
||||
manifest = None
|
||||
digest = None
|
||||
resolved = None
|
||||
backup_id = None
|
||||
backup = None
|
||||
preserved = None
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-edge-apply-") as directory:
|
||||
manifest, _entries, payload, digest, resolved = load_artifact(
|
||||
artifact,
|
||||
Path(directory),
|
||||
)
|
||||
preserved = preflight(manifest, digest)
|
||||
backup_id, backup = create_backup(manifest["id"])
|
||||
publish_payload(payload)
|
||||
build_relay()
|
||||
run(compose_command(
|
||||
"up",
|
||||
"--detach",
|
||||
"--no-deps",
|
||||
"--force-recreate",
|
||||
"--pull",
|
||||
"never",
|
||||
RELAY_SERVICE,
|
||||
), cwd=LIVE_ROOT, timeout=300, capture=False)
|
||||
validate_relay_runtime(preserved)
|
||||
archived = archive_artifact(resolved, APPLIED_ROOT)
|
||||
write_journal(APPLIED_JOURNAL, {
|
||||
"status": "ok",
|
||||
"patch": manifest["id"],
|
||||
"component": COMPONENT,
|
||||
"sha256": digest,
|
||||
"artifact": archived.name,
|
||||
"backup": backup_id,
|
||||
"appliedAt": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
print(
|
||||
f"deploy-ok patch={manifest['id']} component={COMPONENT} "
|
||||
f"backup={backup_id}"
|
||||
)
|
||||
except Exception as error:
|
||||
rollback_status = "not-started"
|
||||
if backup is not None and preserved is not None:
|
||||
try:
|
||||
rollback(backup, preserved)
|
||||
rollback_status = "ok"
|
||||
except Exception as rollback_error:
|
||||
rollback_status = f"failed:{type(rollback_error).__name__}"
|
||||
if resolved is not None and resolved.exists():
|
||||
failed_name = (
|
||||
FAILED_ROOT
|
||||
/ f"{resolved.name}.{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
|
||||
)
|
||||
os.replace(resolved, failed_name)
|
||||
if manifest is not None and digest is not None:
|
||||
write_journal(FAILED_JOURNAL, {
|
||||
"status": "failed",
|
||||
"patch": manifest["id"],
|
||||
"component": COMPONENT,
|
||||
"sha256": digest,
|
||||
"backup": backup_id,
|
||||
"rollback": rollback_status,
|
||||
"error": type(error).__name__,
|
||||
"failedAt": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
if rollback_status.startswith("failed"):
|
||||
die(f"apply failed and rollback failed: {error}")
|
||||
die(f"apply failed; automatic rollback={rollback_status}: {error}")
|
||||
finally:
|
||||
release_lock()
|
||||
|
||||
|
||||
def verify_install():
|
||||
assert_root()
|
||||
path = RUNNER_PATH if RUNNER_PATH.exists() else Path(__file__).resolve()
|
||||
assert_regular_nonsymlink(path, "runner")
|
||||
docker_version = run([DOCKER, "version", "--format", "{{.Server.Version}}"]).stdout.strip()
|
||||
compose_version = run([DOCKER, "compose", "version", "--short"]).stdout.strip()
|
||||
print(f"path={path}")
|
||||
print(f"sha256={sha256_file(path)}")
|
||||
print(f"python={sys.version.split()[0]}")
|
||||
print(f"docker={docker_version}")
|
||||
print(f"compose={compose_version}")
|
||||
print(f"device_edge_ingress_ipv4={INGRESS_IPV4}")
|
||||
print(f"device_edge_ingress_ipv4_approval={INGRESS_IPV4_APPROVAL}")
|
||||
print("device_edge_source_admission=public-ipv4-only")
|
||||
print("verify-install-ok")
|
||||
|
||||
|
||||
def main(arguments):
|
||||
if len(arguments) == 1 and arguments[0] == "verify-install":
|
||||
verify_install()
|
||||
return 0
|
||||
if len(arguments) == 2 and arguments[0] == "plan":
|
||||
plan_artifact(arguments[1])
|
||||
return 0
|
||||
if len(arguments) == 2 and arguments[0] == "apply":
|
||||
apply_artifact(arguments[1])
|
||||
return 0
|
||||
print(
|
||||
"usage: nodedc-edge-deploy verify-install | plan <artifact.tgz> | apply <artifact.tgz>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
except DeployError as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
RUNNER_PATH = Path(os.environ.get("NODEDC_DEPLOY_RUNNER", SCRIPT_DIR.parents[2] / "platform" / "infra" / "deploy-runner" / "nodedc-deploy"))
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_edge_core_channel_deploy_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DeviceEdgeCoreChannelBootstrapTest(unittest.TestCase):
|
||||
def test_identity_generation_ignores_synology_global_ca_extensions(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-openssl-",
|
||||
) as directory:
|
||||
root = Path(directory)
|
||||
malicious = root / "synology-openssl.cnf"
|
||||
malicious.write_text(
|
||||
"""[ req ]
|
||||
prompt = no
|
||||
distinguished_name = dn
|
||||
x509_extensions = v3_ca
|
||||
|
||||
[ dn ]
|
||||
CN = synology-global-default
|
||||
|
||||
[ v3_ca ]
|
||||
basicConstraints = critical,CA:TRUE
|
||||
keyUsage = critical,keyCertSign,cRLSign
|
||||
""",
|
||||
encoding="ascii",
|
||||
)
|
||||
private_key = root / "core-private-key.pem"
|
||||
certificate = root / "core-certificate.pem"
|
||||
with (
|
||||
mock.patch.dict(
|
||||
os.environ,
|
||||
{"OPENSSL_CONF": str(malicious)},
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"resolve_openssl_binary",
|
||||
return_value=Path(shutil.which("openssl")),
|
||||
),
|
||||
):
|
||||
RUNNER.generate_device_edge_channel_core_identity(
|
||||
private_key,
|
||||
certificate,
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.validate_device_edge_channel_certificate_extensions(
|
||||
certificate
|
||||
),
|
||||
"exact-clientAuth",
|
||||
)
|
||||
text = RUNNER.device_edge_channel_certificate_text(certificate)
|
||||
self.assertEqual(
|
||||
text.count("X509v3 Basic Constraints: critical"),
|
||||
1,
|
||||
)
|
||||
self.assertIn("CA:FALSE", text)
|
||||
self.assertNotIn("CA:TRUE", text)
|
||||
RUNNER.run_openssl(
|
||||
[
|
||||
"verify",
|
||||
"-purpose",
|
||||
"sslclient",
|
||||
"-CAfile",
|
||||
str(certificate),
|
||||
str(certificate),
|
||||
],
|
||||
"unit Device Edge client certificate",
|
||||
)
|
||||
|
||||
def test_bootstrap_acceptance_is_core_only_and_preserves_manager(self):
|
||||
entries = RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_ENTRIES
|
||||
services = ("device-control-core",)
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service_with_grace",
|
||||
) as service_health,
|
||||
mock.patch.object(RUNNER, "healthcheck_url") as url_health,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_manager_control_plane_runtime",
|
||||
) as runtime_acceptance,
|
||||
):
|
||||
RUNNER.run_healthchecks("device-plane", entries, services)
|
||||
self.assertEqual(
|
||||
[call.args for call in service_health.call_args_list],
|
||||
[
|
||||
("device-plane", "device-control-core"),
|
||||
("device-plane", "device-manager"),
|
||||
("device-plane", "device-gateway"),
|
||||
("device-plane", "device-postgres"),
|
||||
],
|
||||
)
|
||||
url_health.assert_called_once_with(
|
||||
RUNNER.component_healthchecks(
|
||||
"device-plane",
|
||||
entries,
|
||||
services,
|
||||
)[0]
|
||||
)
|
||||
runtime_acceptance.assert_called_once_with(
|
||||
require_edge_channel=True
|
||||
)
|
||||
|
||||
def test_exact_failed_016_recovery_is_required_for_replacement(self):
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_edge_channel_invalid_identity_is_exact_recoverable",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"does not match the exact unexported failed-016",
|
||||
):
|
||||
RUNNER.recover_invalid_device_edge_channel_core_identity()
|
||||
|
||||
def test_failed_016_recovery_accepts_actual_synology_constraint_shape(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-failed-016-",
|
||||
) as directory:
|
||||
root = Path(directory)
|
||||
private_key = root / "core-private-key.pem"
|
||||
certificate = root / "core-certificate.pem"
|
||||
peers = root / "peers"
|
||||
private_key.write_text("private-placeholder\n", encoding="ascii")
|
||||
certificate.write_text("certificate-placeholder\n", encoding="ascii")
|
||||
peers.mkdir()
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_CORE_PRIVATE_KEY_FILE",
|
||||
private_key,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE",
|
||||
certificate,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_PEER_TRUST_DIR",
|
||||
peers,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_CERTIFICATE_FILE",
|
||||
root / "exported-certificate.pem",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_FINGERPRINT_FILE",
|
||||
root / "exported-fingerprint.txt",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_edge_channel_certificate_fingerprint",
|
||||
return_value=(
|
||||
RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_INVALID_CERTIFICATE_FINGERPRINT
|
||||
),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_edge_channel_certificate_text",
|
||||
return_value="""
|
||||
X509v3 Basic Constraints:
|
||||
CA:TRUE
|
||||
X509v3 Basic Constraints: critical
|
||||
CA:FALSE
|
||||
X509v3 Key Usage: critical
|
||||
Digital Signature
|
||||
X509v3 Extended Key Usage:
|
||||
TLS Web Client Authentication
|
||||
""",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"capture_openssl",
|
||||
side_effect=[b"same-public-key", b"same-public-key"],
|
||||
),
|
||||
):
|
||||
self.assertTrue(
|
||||
RUNNER.device_edge_channel_invalid_identity_is_exact_recoverable()
|
||||
)
|
||||
|
||||
def test_failed_016_recovery_rejects_ambiguous_constraint_shape(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-ambiguous-",
|
||||
) as directory:
|
||||
root = Path(directory)
|
||||
private_key = root / "core-private-key.pem"
|
||||
certificate = root / "core-certificate.pem"
|
||||
peers = root / "peers"
|
||||
private_key.write_text("private-placeholder\n", encoding="ascii")
|
||||
certificate.write_text("certificate-placeholder\n", encoding="ascii")
|
||||
peers.mkdir()
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_CORE_PRIVATE_KEY_FILE",
|
||||
private_key,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE",
|
||||
certificate,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_PEER_TRUST_DIR",
|
||||
peers,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_CERTIFICATE_FILE",
|
||||
root / "exported-certificate.pem",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_FINGERPRINT_FILE",
|
||||
root / "exported-fingerprint.txt",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_edge_channel_certificate_fingerprint",
|
||||
return_value=(
|
||||
RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_INVALID_CERTIFICATE_FINGERPRINT
|
||||
),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_edge_channel_certificate_text",
|
||||
return_value="""
|
||||
X509v3 Basic Constraints:
|
||||
CA:TRUE
|
||||
X509v3 Basic Constraints: critical
|
||||
CA:FALSE
|
||||
X509v3 Basic Constraints: critical
|
||||
CA:FALSE
|
||||
""",
|
||||
),
|
||||
):
|
||||
self.assertFalse(
|
||||
RUNNER.device_edge_channel_invalid_identity_is_exact_recoverable()
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = SCRIPT_DIR / "build-device-edge-ingress-artifact.mjs"
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-edge-deploy"
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_edge_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DeviceEdgeIngressArtifactTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
return subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
|
||||
def test_builder_is_deterministic_narrow_and_secret_free(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-artifact-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
patch_id = "device-edge-ingress-ipvlan-unit-001"
|
||||
first_result = self.build(artifact_dir, patch_id)
|
||||
self.assertEqual(first_result.returncode, 0, first_result.stderr)
|
||||
first = json.loads(first_result.stdout)
|
||||
first_bytes = Path(first["artifact"]).read_bytes()
|
||||
second_result = self.build(artifact_dir, patch_id)
|
||||
self.assertEqual(second_result.returncode, 0, second_result.stderr)
|
||||
second = json.loads(second_result.stdout)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(first["sha256"], second["sha256"])
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(first["component"], "device-edge")
|
||||
self.assertEqual(first["entries"], list(RUNNER.ENTRIES))
|
||||
self.assertEqual(first["services"], ["device-edge-relay"])
|
||||
self.assertEqual(
|
||||
first["ingress"]["ipv4Approval"],
|
||||
"approved-outside-dhcp-pool",
|
||||
)
|
||||
|
||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
names = {member.name for member in members}
|
||||
manifest = archive.extractfile("manifest.env").read().decode()
|
||||
files = archive.extractfile("files.txt").read().decode().splitlines()
|
||||
payload_bytes = b"\n".join(
|
||||
archive.extractfile(member).read()
|
||||
for member in members
|
||||
if member.isfile()
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
manifest,
|
||||
f"id={patch_id}\ncomponent=device-edge\ntype=app-overlay\n",
|
||||
)
|
||||
self.assertEqual(files, list(RUNNER.ENTRIES))
|
||||
self.assertIn(
|
||||
"payload/docker-compose.device-edge.ingress.yml",
|
||||
names,
|
||||
)
|
||||
self.assertNotIn(b"PRIVATE KEY", payload_bytes)
|
||||
self.assertFalse(any(
|
||||
"/test/" in name
|
||||
or "/secrets/" in name
|
||||
or "/keys/" in name
|
||||
or "/trust/" in name
|
||||
or "/node_modules/" in name
|
||||
or Path(name).name.startswith(".env")
|
||||
for name in names
|
||||
))
|
||||
|
||||
def test_production_builder_accepts_the_explicitly_approved_address(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-address-gate-",
|
||||
) as directory:
|
||||
result = self.build(
|
||||
Path(directory),
|
||||
"device-edge-admission-gate-20260804-002",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
built = json.loads(result.stdout)
|
||||
self.assertEqual(
|
||||
built["ingress"]["ipv4Approval"],
|
||||
"approved-outside-dhcp-pool",
|
||||
)
|
||||
self.assertTrue(Path(built["artifact"]).is_file())
|
||||
|
||||
def test_runner_loads_exact_artifact_and_enters_runtime_preflight(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-runner-load-",
|
||||
) as directory:
|
||||
workspace = Path(directory)
|
||||
inbox = workspace / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"device-edge-admission-gate-20260804-003",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
extracted = workspace / "extracted"
|
||||
extracted.mkdir()
|
||||
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
manifest, entries, payload, digest, resolved = (
|
||||
RUNNER.load_artifact(artifact, extracted)
|
||||
)
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
|
||||
self.assertEqual(manifest["component"], "device-edge")
|
||||
self.assertEqual(entries, RUNNER.ENTRIES)
|
||||
self.assertEqual(resolved, artifact.resolve())
|
||||
self.assertEqual(digest, hashlib.sha256(artifact.read_bytes()).hexdigest())
|
||||
self.assertEqual(
|
||||
json.loads(
|
||||
(payload / "deployment/device-edge-admission-gate-v1.json")
|
||||
.read_text(encoding="utf-8")
|
||||
),
|
||||
RUNNER.expected_descriptor(),
|
||||
)
|
||||
preserved = {
|
||||
RUNNER.BACKHAUL_CONTAINER: {"Id": "backhaul"},
|
||||
RUNNER.TAILNET_CONTAINER: {"Id": "tailnet"},
|
||||
}
|
||||
with patch.object(RUNNER, "assert_new_identity"), patch.object(
|
||||
RUNNER,
|
||||
"current_source_state",
|
||||
), patch.object(RUNNER, "validate_predecessor_runtime"), patch.object(
|
||||
RUNNER,
|
||||
"preserved_runtime_snapshot",
|
||||
return_value=preserved,
|
||||
), patch.object(RUNNER, "validate_host_network_boundary"), patch.object(
|
||||
RUNNER,
|
||||
"arp_duplicate_detected",
|
||||
return_value=False,
|
||||
), patch.object(
|
||||
RUNNER,
|
||||
"run",
|
||||
return_value=subprocess.CompletedProcess([], 1, "", ""),
|
||||
):
|
||||
self.assertEqual(RUNNER.preflight(manifest, digest), preserved)
|
||||
|
||||
def test_backup_restore_preserves_exact_predecessor_partition(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-backup-",
|
||||
) as directory:
|
||||
workspace = Path(directory)
|
||||
live = workspace / "live"
|
||||
backups = workspace / "backups"
|
||||
live.mkdir()
|
||||
backups.mkdir()
|
||||
for relative in RUNNER.ENTRIES:
|
||||
if relative in RUNNER.PREDECESSOR_ABSENT:
|
||||
continue
|
||||
target = live / relative
|
||||
if relative.endswith("/src"):
|
||||
target.mkdir(parents=True)
|
||||
(target / "server.mjs").write_text("old\n", encoding="utf-8")
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(f"old:{relative}\n", encoding="utf-8")
|
||||
|
||||
old_live = RUNNER.LIVE_ROOT
|
||||
old_backups = RUNNER.BACKUP_ROOT
|
||||
RUNNER.LIVE_ROOT = live
|
||||
RUNNER.BACKUP_ROOT = backups
|
||||
try:
|
||||
_backup_id, backup = RUNNER.create_backup("unit-backup")
|
||||
for relative in RUNNER.ENTRIES:
|
||||
target = live / relative
|
||||
if target.exists():
|
||||
if target.is_dir():
|
||||
import shutil
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("candidate\n", encoding="utf-8")
|
||||
RUNNER.restore_backup(backup)
|
||||
finally:
|
||||
RUNNER.LIVE_ROOT = old_live
|
||||
RUNNER.BACKUP_ROOT = old_backups
|
||||
|
||||
for relative in RUNNER.PREDECESSOR_ABSENT:
|
||||
self.assertFalse((live / relative).exists())
|
||||
self.assertEqual(
|
||||
(live / "docker-compose.device-edge.yml").read_text(),
|
||||
"old:docker-compose.device-edge.yml\n",
|
||||
)
|
||||
self.assertEqual(
|
||||
(live / "services/device-edge-relay/src/server.mjs").read_text(),
|
||||
"old\n",
|
||||
)
|
||||
|
||||
def test_runner_selection_and_compose_commands_are_exact(self):
|
||||
self.assertTrue(RUNNER.INGRESS_IPV4_APPROVED)
|
||||
self.assertEqual(
|
||||
RUNNER.INGRESS_IPV4_APPROVAL,
|
||||
"approved-outside-dhcp-pool",
|
||||
)
|
||||
self.assertEqual(RUNNER.RELAY_SERVICE, "device-edge-relay")
|
||||
self.assertEqual(
|
||||
RUNNER.expected_descriptor()["preservedServices"],
|
||||
["device-edge-backhaul", "tailnet"],
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.compose_command(
|
||||
"up",
|
||||
"--detach",
|
||||
"--no-deps",
|
||||
"--force-recreate",
|
||||
"--pull",
|
||||
"never",
|
||||
RUNNER.RELAY_SERVICE,
|
||||
),
|
||||
[
|
||||
RUNNER.DOCKER,
|
||||
"compose",
|
||||
"--project-name",
|
||||
RUNNER.COMPOSE_PROJECT,
|
||||
"--file",
|
||||
str(RUNNER.BASE_COMPOSE),
|
||||
"--file",
|
||||
str(RUNNER.INGRESS_COMPOSE),
|
||||
"up",
|
||||
"--detach",
|
||||
"--no-deps",
|
||||
"--force-recreate",
|
||||
"--pull",
|
||||
"never",
|
||||
RUNNER.RELAY_SERVICE,
|
||||
],
|
||||
)
|
||||
self.assertNotIn("down", RUNNER_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
def test_preserved_runtime_is_compared_from_one_atomic_snapshot(self):
|
||||
expected = {
|
||||
RUNNER.BACKHAUL_CONTAINER: {"Id": "backhaul"},
|
||||
RUNNER.TAILNET_CONTAINER: {"Id": "tailnet"},
|
||||
}
|
||||
with patch.object(
|
||||
RUNNER,
|
||||
"preserved_runtime_snapshot",
|
||||
return_value=expected,
|
||||
) as snapshot:
|
||||
RUNNER.assert_preserved_runtime(expected)
|
||||
snapshot.assert_called_once_with()
|
||||
|
||||
def test_network_acceptance_rejects_any_non_ipvlan_substitution(self):
|
||||
relay = {
|
||||
"NetworkSettings": {
|
||||
"Networks": {
|
||||
"nodedc-device-edge-private": {"IPAddress": "172.18.0.4"},
|
||||
RUNNER.INGRESS_NETWORK: {"IPAddress": RUNNER.INGRESS_IPV4},
|
||||
},
|
||||
},
|
||||
}
|
||||
accepted_network = [{
|
||||
"Driver": "ipvlan",
|
||||
"Internal": False,
|
||||
"Options": {
|
||||
"parent": RUNNER.INGRESS_PARENT,
|
||||
"ipvlan_mode": "l2",
|
||||
},
|
||||
"IPAM": {
|
||||
"Config": [{
|
||||
"Subnet": RUNNER.INGRESS_SUBNET,
|
||||
"Gateway": RUNNER.INGRESS_GATEWAY,
|
||||
}],
|
||||
},
|
||||
}]
|
||||
with patch.object(RUNNER, "docker_json", return_value=accepted_network):
|
||||
RUNNER.validate_network_runtime(relay)
|
||||
|
||||
rejected_network = json.loads(json.dumps(accepted_network))
|
||||
rejected_network[0]["Driver"] = "bridge"
|
||||
with patch.object(RUNNER, "docker_json", return_value=rejected_network):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"ingress network driver mismatch",
|
||||
):
|
||||
RUNNER.validate_network_runtime(relay)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,773 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = SCRIPT_DIR / "build-device-edge-vps-artifact.mjs"
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-b2-vps-deploy"
|
||||
DEFAULT_RUNTIME_CACHE = Path(
|
||||
os.environ.get("NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR", "/tmp")
|
||||
)
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_b2_vps_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, phase, patch_id, runtime_cache=None):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
environment["NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR"] = str(
|
||||
runtime_cache or DEFAULT_RUNTIME_CACHE
|
||||
)
|
||||
if phase in {"backhaul", "relay"}:
|
||||
environment["NODEDC_ALLOW_SUPERSEDED_TRANSPORT"] = "test-only"
|
||||
return subprocess.run(
|
||||
["node", str(BUILDER), phase, patch_id],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
|
||||
def test_superseded_transport_builds_fail_closed_by_default(self):
|
||||
environment = os.environ.copy()
|
||||
environment.pop("NODEDC_ALLOW_SUPERSEDED_TRANSPORT", None)
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-frozen-") as directory:
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = directory
|
||||
for phase in ("backhaul", "relay"):
|
||||
with self.subTest(phase=phase):
|
||||
result = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
str(BUILDER),
|
||||
phase,
|
||||
f"device-edge-vps-{phase}-frozen-001",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn(
|
||||
"vps_initiated_transport_frozen:ADR-0001",
|
||||
result.stderr,
|
||||
)
|
||||
|
||||
def test_runner_rejects_superseded_transport_before_host_preflight(self):
|
||||
for phase in ("backhaul", "relay"):
|
||||
with self.subTest(phase=phase), self.assertRaises(RUNNER.DeployError):
|
||||
RUNNER.preflight({"phase": phase})
|
||||
|
||||
def test_accepted_shared_source_phases_cannot_be_rebuilt(self):
|
||||
for phase in ("core-channel", "tracker-ingress"):
|
||||
with self.subTest(phase=phase), tempfile.TemporaryDirectory(
|
||||
prefix=f"nodedc-vps-frozen-{phase}-"
|
||||
) as directory:
|
||||
result = self.build(
|
||||
Path(directory),
|
||||
phase,
|
||||
f"device-edge-vps-{phase}-frozen-001",
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn(
|
||||
f"accepted_vps_phase_rebuild_frozen:{phase}:ADR-0001",
|
||||
result.stderr,
|
||||
)
|
||||
|
||||
def require_runtime_cache(self):
|
||||
missing = []
|
||||
for name, digest in (
|
||||
(RUNNER.NODE_ARCHIVE, RUNNER.NODE_ARCHIVE_SHA256),
|
||||
(RUNNER.TAILSCALE_ARCHIVE, RUNNER.TAILSCALE_ARCHIVE_SHA256),
|
||||
):
|
||||
path = DEFAULT_RUNTIME_CACHE / name
|
||||
if not path.is_file():
|
||||
missing.append(str(path))
|
||||
continue
|
||||
self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), digest)
|
||||
if missing:
|
||||
self.skipTest(
|
||||
"immutable VPS runtime cache is not available: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
def test_builders_are_deterministic_narrow_and_secret_free(self):
|
||||
self.require_runtime_cache()
|
||||
for phase in (
|
||||
"command-transport",
|
||||
):
|
||||
with self.subTest(phase=phase), tempfile.TemporaryDirectory(
|
||||
prefix=f"nodedc-vps-{phase}-"
|
||||
) as directory:
|
||||
root = Path(directory)
|
||||
patch_id = f"device-edge-vps-{phase}-unit-001"
|
||||
first = self.build(root, phase, patch_id)
|
||||
self.assertEqual(first.returncode, 0, first.stderr)
|
||||
first_result = json.loads(first.stdout)
|
||||
first_bytes = Path(first_result["artifact"]).read_bytes()
|
||||
second = self.build(root, phase, patch_id)
|
||||
self.assertEqual(second.returncode, 0, second.stderr)
|
||||
second_result = json.loads(second.stdout)
|
||||
second_bytes = Path(second_result["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(first_result["sha256"], second_result["sha256"])
|
||||
self.assertEqual(
|
||||
first_result["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(first_result["entries"], list(RUNNER.PHASE_ENTRIES[phase]))
|
||||
|
||||
with tarfile.open(first_result["artifact"], "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
names = {member.name for member in members}
|
||||
payload = b"\n".join(
|
||||
archive.extractfile(member).read()
|
||||
for member in members
|
||||
if member.isfile() and member.size < 2 * 1024 * 1024
|
||||
)
|
||||
self.assertIn("manifest.env", names)
|
||||
self.assertIn("files.txt", names)
|
||||
self.assertFalse(any(
|
||||
"/secrets/" in name
|
||||
or "/keys/" in name
|
||||
or "/trust/" in name
|
||||
or "/runtime/" in name
|
||||
or "/node_modules/" in name
|
||||
or Path(name).name.startswith(".env")
|
||||
for name in names
|
||||
))
|
||||
self.assertNotIn(b"PRIVATE KEY", payload)
|
||||
self.assertNotIn(b"TS_AUTHKEY", payload)
|
||||
|
||||
def test_foundation_builder_rejects_modified_runtime_archive(self):
|
||||
self.require_runtime_cache()
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-corrupt-") as directory:
|
||||
cache = Path(directory) / "cache"
|
||||
artifacts = Path(directory) / "artifacts"
|
||||
cache.mkdir()
|
||||
for name in (RUNNER.NODE_ARCHIVE, RUNNER.TAILSCALE_ARCHIVE):
|
||||
(cache / name).write_bytes((DEFAULT_RUNTIME_CACHE / name).read_bytes())
|
||||
with (cache / RUNNER.NODE_ARCHIVE).open("ab") as handle:
|
||||
handle.write(b"corrupt")
|
||||
result = self.build(
|
||||
artifacts,
|
||||
"foundation",
|
||||
"device-edge-vps-foundation-corrupt-001",
|
||||
runtime_cache=cache,
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("runtime_digest_mismatch", result.stderr)
|
||||
|
||||
def test_runner_loads_each_exact_phase(self):
|
||||
self.require_runtime_cache()
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-load-") as directory:
|
||||
inbox = Path(directory) / "inbox"
|
||||
inbox.mkdir()
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
for phase in (
|
||||
"command-transport",
|
||||
):
|
||||
result = self.build(
|
||||
inbox,
|
||||
phase,
|
||||
f"device-edge-vps-{phase}-load-001",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
extraction = Path(directory) / f"extract-{phase}"
|
||||
extraction.mkdir()
|
||||
loaded = RUNNER.load_artifact(artifact, extraction)
|
||||
self.assertEqual(loaded["phase"], phase)
|
||||
self.assertEqual(loaded["entries"], RUNNER.PHASE_ENTRIES[phase])
|
||||
self.assertEqual(
|
||||
loaded["sha256"],
|
||||
hashlib.sha256(artifact.read_bytes()).hexdigest(),
|
||||
)
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
|
||||
def test_plan_is_exact_and_never_claims_dns_or_b2_mutation(self):
|
||||
self.require_runtime_cache()
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-plan-") as directory:
|
||||
inbox = Path(directory) / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"foundation",
|
||||
"device-edge-vps-foundation-plan-001",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
with patch.object(RUNNER, "assert_root"), patch.object(
|
||||
RUNNER,
|
||||
"preflight",
|
||||
return_value={"predecessor": "unit-predecessor"},
|
||||
), patch("builtins.print") as output:
|
||||
RUNNER.plan_artifact(str(artifact))
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
rendered = "\n".join(
|
||||
" ".join(str(arg) for arg in call.args)
|
||||
for call in output.call_args_list
|
||||
)
|
||||
self.assertIn("phase=foundation", rendered)
|
||||
self.assertIn("public_b2_ingress=disabled", rendered)
|
||||
self.assertIn("dns=unchanged", rendered)
|
||||
self.assertIn("b2_routes=unchanged", rendered)
|
||||
self.assertIn("command_transport=disabled", rendered)
|
||||
|
||||
@unittest.skip("accepted core-channel builder generation is frozen")
|
||||
def test_core_channel_plan_is_exact_and_keeps_tracker_ingress_closed(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-channel-plan-") as directory:
|
||||
inbox = Path(directory) / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"core-channel",
|
||||
"device-edge-vps-core-channel-plan-001",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
with patch.object(RUNNER, "assert_root"), patch.object(
|
||||
RUNNER,
|
||||
"preflight",
|
||||
return_value={"predecessor": "accepted-foundation-closed-channel"},
|
||||
), patch("builtins.print") as output:
|
||||
RUNNER.plan_artifact(str(artifact))
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
rendered = "\n".join(
|
||||
" ".join(str(arg) for arg in call.args)
|
||||
for call in output.call_args_list
|
||||
)
|
||||
self.assertIn("phase=core-channel", rendered)
|
||||
self.assertIn(
|
||||
"predecessor=accepted-foundation-closed-channel",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn(
|
||||
"public_core_channel=155.212.211.15:443/tcp:tls13-mtls-h2",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn("tracker_tcp_9921=closed", rendered)
|
||||
self.assertIn("public_b2_ingress=disabled", rendered)
|
||||
self.assertIn(
|
||||
"peer_trust=preprovisioned-pinned-self-signed-core-certificate+fingerprint",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn("command_transport=disabled", rendered)
|
||||
self.assertIn("gelios=untouched", rendered)
|
||||
|
||||
def test_runtime_reconciliation_plan_is_exact_and_opens_no_port(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-reconcile-plan-") as directory:
|
||||
inbox = Path(directory) / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"runtime-reconciliation",
|
||||
"device-edge-vps-runtime-reconciliation-plan-001",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
with patch.object(RUNNER, "assert_root"), patch.object(
|
||||
RUNNER,
|
||||
"preflight",
|
||||
return_value={
|
||||
"predecessor": (
|
||||
"failed-core-channel-001-rollback-runtime-mode-drift"
|
||||
),
|
||||
},
|
||||
), patch("builtins.print") as output:
|
||||
RUNNER.plan_artifact(str(artifact))
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
rendered = "\n".join(
|
||||
" ".join(str(arg) for arg in call.args)
|
||||
for call in output.call_args_list
|
||||
)
|
||||
self.assertIn("phase=runtime-reconciliation", rendered)
|
||||
self.assertIn(
|
||||
"runtime_reconciliation=exact-known-binaries:0644=>0755",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn("public_core_channel=disabled", rendered)
|
||||
self.assertIn("tracker_tcp_9921=closed", rendered)
|
||||
|
||||
def test_tailscale_retirement_plan_preserves_channel_and_opens_no_tracker_port(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-retirement-plan-") as directory:
|
||||
inbox = Path(directory) / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"tailscale-retirement",
|
||||
"device-edge-vps-tailscale-retirement-plan-001",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
with patch.object(RUNNER, "assert_root"), patch.object(
|
||||
RUNNER,
|
||||
"preflight",
|
||||
return_value={
|
||||
"predecessor": "accepted-core-channel-010-with-live-tailnet",
|
||||
},
|
||||
), patch("builtins.print") as output:
|
||||
RUNNER.plan_artifact(str(artifact))
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
rendered = "\n".join(
|
||||
" ".join(str(arg) for arg in call.args)
|
||||
for call in output.call_args_list
|
||||
)
|
||||
self.assertIn("phase=tailscale-retirement", rendered)
|
||||
self.assertIn(
|
||||
"predecessor=accepted-core-channel-010-with-live-tailnet",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn(
|
||||
"public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn(
|
||||
"tailscale=stop+disable+destroy-local-runtime-state",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn("tailscale_socks_1055=removed", rendered)
|
||||
self.assertIn("superseded_backhaul_private_key=removed", rendered)
|
||||
self.assertIn("tracker_tcp_9921=closed", rendered)
|
||||
self.assertIn("external_tailnet_machine_cleanup=required-after-deploy-ok", rendered)
|
||||
self.assertIn("command_transport=disabled", rendered)
|
||||
self.assertIn("gelios=untouched", rendered)
|
||||
|
||||
@unittest.skip("accepted tracker-ingress builder generation is frozen")
|
||||
def test_tracker_ingress_plan_is_single_process_bounded_and_command_free(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-ingress-plan-") as directory:
|
||||
inbox = Path(directory) / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"tracker-ingress",
|
||||
"device-edge-vps-tracker-ingress-plan-001",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
with patch.object(RUNNER, "assert_root"), patch.object(
|
||||
RUNNER,
|
||||
"preflight",
|
||||
return_value={"predecessor": "accepted-tailscale-retirement-011"},
|
||||
), patch("builtins.print") as output:
|
||||
RUNNER.plan_artifact(str(artifact))
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
rendered = "\n".join(
|
||||
" ".join(str(arg) for arg in call.args)
|
||||
for call in output.call_args_list
|
||||
)
|
||||
self.assertIn("phase=tracker-ingress", rendered)
|
||||
self.assertIn("predecessor=accepted-tailscale-retirement-011", rendered)
|
||||
self.assertIn(
|
||||
"public_b2_ingress=155.212.211.15:9921/tcp:telemetry-only",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn("tracker_adapter=arusnavi-b2", rendered)
|
||||
self.assertIn("tracker_ack=after-core-durable-acceptance-only", rendered)
|
||||
self.assertIn("runtime_composition=single-non-root-process", rendered)
|
||||
self.assertIn("tailscale=preserved:absent", rendered)
|
||||
self.assertIn("command_transport=disabled", rendered)
|
||||
self.assertIn("gelios=untouched", rendered)
|
||||
|
||||
def test_command_transport_plan_is_typed_single_process_and_bounded(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-command-plan-") as directory:
|
||||
inbox = Path(directory) / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"command-transport",
|
||||
"device-edge-vps-command-transport-plan-001",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
with patch.object(RUNNER, "assert_root"), patch.object(
|
||||
RUNNER,
|
||||
"preflight",
|
||||
return_value={"predecessor": "accepted-tracker-ingress-012"},
|
||||
), patch("builtins.print") as output:
|
||||
RUNNER.plan_artifact(str(artifact))
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
rendered = "\n".join(
|
||||
" ".join(str(arg) for arg in call.args)
|
||||
for call in output.call_args_list
|
||||
)
|
||||
self.assertIn("phase=command-transport", rendered)
|
||||
self.assertIn("predecessor=accepted-tracker-ingress-012", rendered)
|
||||
self.assertIn("command_transport=typed-service-ping-v1", rendered)
|
||||
self.assertIn("command_catalog=allowlisted-adapter-typed-commands-only", rendered)
|
||||
self.assertIn("runtime_composition=single-non-root-process", rendered)
|
||||
self.assertIn("public_b2_ingress=155.212.211.15:9921/tcp:bidirectional-session", rendered)
|
||||
self.assertIn("gelios=untouched-legacy-only", rendered)
|
||||
|
||||
def test_publish_payload_preserves_unselected_executable_modes(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-publish-scope-") as directory:
|
||||
root = Path(directory)
|
||||
live = root / "live"
|
||||
payload = root / "payload"
|
||||
runtime = live / "runtime/node/bin/node"
|
||||
marker = payload / "deployment/reconciliation.json"
|
||||
runtime.parent.mkdir(parents=True)
|
||||
marker.parent.mkdir(parents=True)
|
||||
runtime.write_bytes(b"runtime-binary")
|
||||
runtime.chmod(0o755)
|
||||
marker.write_text("{}\n", encoding="utf-8")
|
||||
old_live = RUNNER.LIVE_ROOT
|
||||
RUNNER.LIVE_ROOT = live
|
||||
try:
|
||||
with patch.object(RUNNER.os, "chown"):
|
||||
RUNNER.publish_payload(payload, ("deployment/reconciliation.json",))
|
||||
finally:
|
||||
RUNNER.LIVE_ROOT = old_live
|
||||
self.assertEqual(runtime.stat().st_mode & 0o777, 0o755)
|
||||
self.assertEqual(
|
||||
(live / "deployment/reconciliation.json").stat().st_mode & 0o777,
|
||||
0o644,
|
||||
)
|
||||
|
||||
def test_source_baseline_is_pinned_to_the_exact_accepted_predecessor(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-baseline-") as directory:
|
||||
journal = Path(directory) / "applied.jsonl"
|
||||
old_journal = RUNNER.APPLIED_JOURNAL
|
||||
RUNNER.APPLIED_JOURNAL = journal
|
||||
try:
|
||||
journal.write_text(json.dumps({
|
||||
"patch": "device-edge-vps-foundation-20260806-003",
|
||||
"phase": "foundation",
|
||||
"sha256": (
|
||||
"1be852f144e9f0fea32af70bebd07a2607b6a1818825094bd4c1b4062064716a"
|
||||
),
|
||||
"status": "ok",
|
||||
}) + "\n", encoding="utf-8")
|
||||
expected = RUNNER.phase_file_sha256("foundation")
|
||||
self.assertEqual(
|
||||
expected["vps/config/00-nodedc-b2-vps.conf"],
|
||||
"cc94d0579f85d0af9746b9ce760bc72980f4a22fb59027e1f5f9c7bf3aaebd64",
|
||||
)
|
||||
self.assertEqual(
|
||||
expected["vps/config/nftables-foundation.conf"],
|
||||
"4d44f902d8d98d1aa8506fca9d9582e700f6424def2b1d667ab2cd5a5ee84934",
|
||||
)
|
||||
self.assertEqual(
|
||||
expected["deployment/device-edge-vps-foundation-v1.json"],
|
||||
"317c98b42520fff3238275908482de7aa611b4ee41c6b1f8062abd2730ab072a",
|
||||
)
|
||||
|
||||
journal.write_text(json.dumps({
|
||||
"patch": "device-edge-vps-foundation-20260806-003",
|
||||
"phase": "foundation",
|
||||
"sha256": "0" * 64,
|
||||
"status": "ok",
|
||||
}) + "\n", encoding="utf-8")
|
||||
unexpected = RUNNER.phase_file_sha256("foundation")
|
||||
self.assertEqual(
|
||||
unexpected["vps/config/00-nodedc-b2-vps.conf"],
|
||||
RUNNER.PHASE_FILE_SHA256[
|
||||
"foundation"
|
||||
]["vps/config/00-nodedc-b2-vps.conf"],
|
||||
)
|
||||
finally:
|
||||
RUNNER.APPLIED_JOURNAL = old_journal
|
||||
|
||||
def test_units_and_firewalls_keep_the_required_boundaries(self):
|
||||
source_root = SCRIPT_DIR.parents[1]
|
||||
foundation = (source_root / "vps/config/nftables-foundation.conf").read_text()
|
||||
relay = (source_root / "vps/config/nftables-relay.conf").read_text()
|
||||
channel = (source_root / "vps/config/nftables-core-channel.conf").read_text()
|
||||
tracker = (source_root / "vps/config/nftables-tracker-ingress.conf").read_text()
|
||||
sshd = (source_root / "vps/config/00-nodedc-b2-vps.conf").read_text()
|
||||
backhaul = (source_root / "vps/config/backhaul_ssh_config").read_text()
|
||||
tailscale_unit = (
|
||||
source_root / "vps/systemd/nodedc-b2-tailscaled.service"
|
||||
).read_text()
|
||||
relay_unit = (source_root / "vps/systemd/nodedc-b2-relay.service").read_text()
|
||||
backhaul_unit = (
|
||||
source_root / "vps/systemd/nodedc-b2-backhaul.service"
|
||||
).read_text()
|
||||
channel_unit = (
|
||||
source_root / "vps/systemd/nodedc-device-edge-channel.service"
|
||||
).read_text()
|
||||
tracker_unit = (
|
||||
source_root / "vps/systemd/nodedc-device-edge-runtime.service"
|
||||
).read_text()
|
||||
|
||||
self.assertIn("policy drop", foundation)
|
||||
self.assertIn("tcp dport 22", foundation)
|
||||
self.assertNotIn("tcp dport 9921", foundation)
|
||||
self.assertIn("tcp dport 9921", relay)
|
||||
self.assertIn("tcp dport 443", channel)
|
||||
self.assertNotIn("tcp dport 9921", channel)
|
||||
self.assertIn("tcp dport 443", tracker)
|
||||
self.assertIn("tcp dport 9921", tracker)
|
||||
self.assertIn("PasswordAuthentication no", sshd)
|
||||
self.assertIn("AllowTcpForwarding no", sshd)
|
||||
self.assertIn("StrictHostKeyChecking yes", backhaul)
|
||||
self.assertIn("ProxyCommand /usr/bin/nc -X 5 -x 127.0.0.1:1055", backhaul)
|
||||
self.assertIn("AF_NETLINK", tailscale_unit)
|
||||
self.assertIn("User=nodedc-edge", tailscale_unit)
|
||||
self.assertIn("StateDirectoryMode=0700", tailscale_unit)
|
||||
self.assertIn("User=nodedc-backhaul", backhaul_unit)
|
||||
self.assertNotIn("User=nodedc-edge", backhaul_unit)
|
||||
self.assertIn("User=nodedc-relay", relay_unit)
|
||||
self.assertNotIn("User=nodedc-edge", relay_unit)
|
||||
self.assertIn("DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only", relay_unit)
|
||||
self.assertIn("MemoryMax=192M", relay_unit)
|
||||
self.assertIn("User=nodedc-channel", channel_unit)
|
||||
self.assertIn(
|
||||
"ExecStart=/opt/nodedc-b2-vps/runtime/node/bin/node "
|
||||
"/opt/nodedc-b2-vps/services/device-edge-channel/src/server.mjs",
|
||||
channel_unit,
|
||||
)
|
||||
self.assertIn("MemoryDenyWriteExecute=no", channel_unit)
|
||||
self.assertIn(
|
||||
"CapabilityBoundingSet=CAP_NET_BIND_SERVICE",
|
||||
channel_unit,
|
||||
)
|
||||
self.assertIn(
|
||||
"AmbientCapabilities=CAP_NET_BIND_SERVICE",
|
||||
channel_unit,
|
||||
)
|
||||
self.assertNotIn("--jitless", channel_unit)
|
||||
self.assertIn("MemoryMax=128M", channel_unit)
|
||||
self.assertIn("MemorySwapMax=0", channel_unit)
|
||||
self.assertIn("CPUQuota=50%", channel_unit)
|
||||
self.assertIn("TasksMax=64", channel_unit)
|
||||
self.assertIn("LimitNOFILE=1024", channel_unit)
|
||||
self.assertNotIn("LocalForward", channel_unit)
|
||||
self.assertNotIn("DEVICE_EDGE_RELAY_UPSTREAM", channel_unit)
|
||||
self.assertIn("User=nodedc-channel", tracker_unit)
|
||||
self.assertIn("vps/edge-process/device-edge-runtime.mjs", tracker_unit)
|
||||
self.assertIn("DEVICE_GATEWAY_TCP_PORT=9921", tracker_unit)
|
||||
self.assertIn("DEVICE_GATEWAY_MAX_SESSIONS=128", tracker_unit)
|
||||
self.assertIn("MemoryMax=192M", tracker_unit)
|
||||
self.assertIn("MemorySwapMax=0", tracker_unit)
|
||||
self.assertIn("CPUQuota=75%", tracker_unit)
|
||||
self.assertIn("TasksMax=128", tracker_unit)
|
||||
self.assertIn("LimitNOFILE=1024", tracker_unit)
|
||||
self.assertNotIn("LocalForward", tracker_unit)
|
||||
self.assertNotIn("DEVICE_EDGE_RELAY_UPSTREAM", tracker_unit)
|
||||
|
||||
def test_runner_has_registered_rollback_and_no_generic_latest(self):
|
||||
source = RUNNER_PATH.read_text(encoding="utf-8")
|
||||
self.assertIn("def rollback(", source)
|
||||
self.assertIn('TAILSCALE_REQUIRED_TAG = "tag:device-edge-vps"', source)
|
||||
self.assertIn("assign_backhaul_trust", source)
|
||||
self.assertIn("deploy-ok patch=", source)
|
||||
self.assertNotIn("apply-latest", source)
|
||||
self.assertNotIn("compose down", source)
|
||||
self.assertNotIn("docker system prune", source)
|
||||
|
||||
def test_executable_preflight_accepts_a_valid_alternatives_symlink(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-tool-") as directory:
|
||||
root = Path(directory)
|
||||
target = root / "netcat.openbsd"
|
||||
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
target.chmod(0o755)
|
||||
command = root / "nc"
|
||||
command.symlink_to(target.name)
|
||||
|
||||
self.assertEqual(
|
||||
RUNNER.assert_executable_command_path(command, "test command"),
|
||||
target.resolve(),
|
||||
)
|
||||
|
||||
def test_executable_preflight_rejects_a_broken_symlink(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-tool-") as directory:
|
||||
command = Path(directory) / "nc"
|
||||
command.symlink_to("missing-netcat")
|
||||
with self.assertRaises(RUNNER.DeployError):
|
||||
RUNNER.assert_executable_command_path(command, "test command")
|
||||
|
||||
def test_backup_restore_preserves_the_exact_relay_partition(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-backup-") as directory:
|
||||
root = Path(directory)
|
||||
live = root / "live"
|
||||
backups = root / "backups"
|
||||
nft = root / "etc/nftables.conf"
|
||||
relay_unit = root / "etc/nodedc-b2-relay.service"
|
||||
backups.mkdir()
|
||||
nft.parent.mkdir(parents=True)
|
||||
nft.write_text("foundation-firewall\n", encoding="utf-8")
|
||||
relay_unit.write_text("old-unit\n", encoding="utf-8")
|
||||
for relative in RUNNER.RELAY_ENTRIES:
|
||||
target = live / relative
|
||||
if relative.endswith("/src"):
|
||||
target.mkdir(parents=True)
|
||||
(target / "server.mjs").write_text("old-source\n", encoding="utf-8")
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(f"old:{relative}\n", encoding="utf-8")
|
||||
|
||||
old_live = RUNNER.LIVE_ROOT
|
||||
old_backups = RUNNER.BACKUP_ROOT
|
||||
old_nft = RUNNER.NFTABLES_CONFIG
|
||||
old_relay_unit = RUNNER.RELAY_UNIT
|
||||
RUNNER.LIVE_ROOT = live
|
||||
RUNNER.BACKUP_ROOT = backups
|
||||
RUNNER.NFTABLES_CONFIG = nft
|
||||
RUNNER.RELAY_UNIT = relay_unit
|
||||
completed = subprocess.CompletedProcess([], 0, "table inet old {}\n", "")
|
||||
try:
|
||||
with patch.object(RUNNER, "run", return_value=completed), patch.object(
|
||||
RUNNER,
|
||||
"service_active",
|
||||
return_value=False,
|
||||
), patch.object(
|
||||
RUNNER,
|
||||
"systemctl",
|
||||
return_value=completed,
|
||||
), patch.object(
|
||||
RUNNER,
|
||||
"user_exists",
|
||||
return_value=False,
|
||||
):
|
||||
_backup_id, backup = RUNNER.create_backup("relay-unit", "relay")
|
||||
nft.write_text("candidate-firewall\n", encoding="utf-8")
|
||||
relay_unit.write_text("candidate-unit\n", encoding="utf-8")
|
||||
(live / "services/device-edge-relay/src/server.mjs").write_text(
|
||||
"candidate-source\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
RUNNER.restore_backup(backup, "relay")
|
||||
finally:
|
||||
RUNNER.LIVE_ROOT = old_live
|
||||
RUNNER.BACKUP_ROOT = old_backups
|
||||
RUNNER.NFTABLES_CONFIG = old_nft
|
||||
RUNNER.RELAY_UNIT = old_relay_unit
|
||||
|
||||
self.assertEqual(nft.read_text(), "foundation-firewall\n")
|
||||
self.assertEqual(relay_unit.read_text(), "old-unit\n")
|
||||
self.assertEqual(
|
||||
(live / "services/device-edge-relay/src/server.mjs").read_text(),
|
||||
"old-source\n",
|
||||
)
|
||||
|
||||
def test_backup_restore_preserves_core_channel_source_trust_and_firewall(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-channel-backup-") as directory:
|
||||
root = Path(directory)
|
||||
live = root / "live"
|
||||
backups = root / "backups"
|
||||
nft = root / "etc/nftables.conf"
|
||||
channel_unit = root / "etc/nodedc-device-edge-channel.service"
|
||||
trust = root / "state/channel-trust"
|
||||
backups.mkdir()
|
||||
nft.parent.mkdir(parents=True)
|
||||
trust.mkdir(parents=True)
|
||||
nft.write_text("foundation-firewall\n", encoding="utf-8")
|
||||
channel_unit.parent.mkdir(parents=True, exist_ok=True)
|
||||
channel_unit.write_text("old-channel-unit\n", encoding="utf-8")
|
||||
(trust / "runtime.json").write_text("old-runtime\n", encoding="utf-8")
|
||||
for relative in RUNNER.CORE_CHANNEL_ENTRIES:
|
||||
target = live / relative
|
||||
if relative.endswith("/src"):
|
||||
target.mkdir(parents=True)
|
||||
(target / "server.mjs").write_text("old-channel-source\n", encoding="utf-8")
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(f"old:{relative}\n", encoding="utf-8")
|
||||
|
||||
old_live = RUNNER.LIVE_ROOT
|
||||
old_backups = RUNNER.BACKUP_ROOT
|
||||
old_nft = RUNNER.NFTABLES_CONFIG
|
||||
old_unit = RUNNER.CHANNEL_UNIT
|
||||
old_trust = RUNNER.CHANNEL_TRUST_ROOT
|
||||
RUNNER.LIVE_ROOT = live
|
||||
RUNNER.BACKUP_ROOT = backups
|
||||
RUNNER.NFTABLES_CONFIG = nft
|
||||
RUNNER.CHANNEL_UNIT = channel_unit
|
||||
RUNNER.CHANNEL_TRUST_ROOT = trust
|
||||
completed = subprocess.CompletedProcess([], 0, "table inet old {}\n", "")
|
||||
try:
|
||||
with patch.object(RUNNER, "run", return_value=completed), patch.object(
|
||||
RUNNER,
|
||||
"service_active",
|
||||
return_value=False,
|
||||
), patch.object(
|
||||
RUNNER,
|
||||
"systemctl",
|
||||
return_value=completed,
|
||||
), patch.object(
|
||||
RUNNER,
|
||||
"user_exists",
|
||||
return_value=False,
|
||||
):
|
||||
_backup_id, backup = RUNNER.create_backup(
|
||||
"channel-unit",
|
||||
"core-channel",
|
||||
)
|
||||
nft.write_text("candidate-firewall\n", encoding="utf-8")
|
||||
channel_unit.write_text("candidate-channel-unit\n", encoding="utf-8")
|
||||
(trust / "runtime.json").write_text("candidate-runtime\n", encoding="utf-8")
|
||||
(live / "services/device-edge-channel/src/server.mjs").write_text(
|
||||
"candidate-channel-source\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
RUNNER.restore_backup(backup, "core-channel")
|
||||
finally:
|
||||
RUNNER.LIVE_ROOT = old_live
|
||||
RUNNER.BACKUP_ROOT = old_backups
|
||||
RUNNER.NFTABLES_CONFIG = old_nft
|
||||
RUNNER.CHANNEL_UNIT = old_unit
|
||||
RUNNER.CHANNEL_TRUST_ROOT = old_trust
|
||||
|
||||
self.assertEqual(nft.read_text(), "foundation-firewall\n")
|
||||
self.assertEqual(channel_unit.read_text(), "old-channel-unit\n")
|
||||
self.assertEqual((trust / "runtime.json").read_text(), "old-runtime\n")
|
||||
self.assertEqual(
|
||||
(live / "services/device-edge-channel/src/server.mjs").read_text(),
|
||||
"old-channel-source\n",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user