From 29ba5de92ef11abdb3ab5216ff864fbaa67f4049 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 22:00:59 +0300 Subject: [PATCH] feat(device-plane): add canonical Device Manager runtime --- .../device-manager-control-plane-v1.json | 10 + .../docker-compose.device-manager.yml | 76 +++ .../test/management-runtime-contract.test.mjs | 18 +- .../services/device-manager/Dockerfile | 16 + ...-device-manager-control-plane-artifact.mjs | 120 +++++ .../build-launcher-device-core-artifact.mjs | 63 +++ ...latform-device-core-hub-trust-artifact.mjs | 72 +++ ...platform-device-manager-route-artifact.mjs | 54 ++ infra/deploy-runner/nodedc-deploy | 498 +++++++++++++++++- ..._device_manager_control_plane_artifacts.py | 169 ++++++ .../deployment/device-core-hub-trust-v1.json | 11 + .../device-manager-public-route-v1.json | 8 + infra/synology/Caddyfile.http | 10 + .../synology/docker-compose.platform-http.yml | 7 + 14 files changed, 1122 insertions(+), 10 deletions(-) create mode 100644 device-plane/deployment/device-manager-control-plane-v1.json create mode 100644 device-plane/docker-compose.device-manager.yml create mode 100644 device-plane/services/device-manager/Dockerfile create mode 100644 infra/deploy-runner/build-device-manager-control-plane-artifact.mjs create mode 100644 infra/deploy-runner/build-launcher-device-core-artifact.mjs create mode 100644 infra/deploy-runner/build-platform-device-core-hub-trust-artifact.mjs create mode 100644 infra/deploy-runner/build-platform-device-manager-route-artifact.mjs create mode 100644 infra/deploy-runner/test_device_manager_control_plane_artifacts.py create mode 100644 infra/deployment/device-core-hub-trust-v1.json create mode 100644 infra/deployment/device-manager-public-route-v1.json diff --git a/device-plane/deployment/device-manager-control-plane-v1.json b/device-plane/deployment/device-manager-control-plane-v1.json new file mode 100644 index 0000000..d9e0635 --- /dev/null +++ b/device-plane/deployment/device-manager-control-plane-v1.json @@ -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" +} diff --git a/device-plane/docker-compose.device-manager.yml b/device-plane/docker-compose.device-manager.yml new file mode 100644 index 0000000..f62052a --- /dev/null +++ b/device-plane/docker-compose.device-manager.yml @@ -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 diff --git a/device-plane/services/device-control-core/test/management-runtime-contract.test.mjs b/device-plane/services/device-control-core/test/management-runtime-contract.test.mjs index d1a06c8..29e8b67 100644 --- a/device-plane/services/device-control-core/test/management-runtime-contract.test.mjs +++ b/device-plane/services/device-control-core/test/management-runtime-contract.test.mjs @@ -5,7 +5,7 @@ import test from "node:test"; const appUrl = new URL("../src/app.mjs", import.meta.url); const serverUrl = new URL("../src/server.mjs", import.meta.url); const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); -const composeUrl = new URL("../../../docker-compose.device-plane.yml", import.meta.url); +const managerComposeUrl = new URL("../../../docker-compose.device-manager.yml", import.meta.url); test("management surface is internal, POST-only and disabled by default", async () => { const source = await readFile(appUrl, "utf8"); @@ -22,14 +22,22 @@ test("management surface is internal, POST-only and disabled by default", async assert.doesNotMatch(source, /device-commands:(?:plan|confirm|dispatch)/); }); -test("management token remains file-backed and is not enabled by current Compose", async () => { +test("management API is enabled only through a runner-owned file token", async () => { const server = await readFile(serverUrl, "utf8"); - const compose = await readFile(composeUrl, "utf8"); + const compose = await readFile(managerComposeUrl, "utf8"); assert.match(server, /DEVICE_MANAGEMENT_API_ENABLED/); assert.match(server, /DEVICE_MANAGEMENT_CORE_TOKEN_FILE/); - assert.doesNotMatch(compose, /DEVICE_MANAGEMENT_API_ENABLED/); - assert.doesNotMatch(compose, /DEVICE_MANAGEMENT_CORE_TOKEN_FILE/); + assert.match(compose, /DEVICE_MANAGEMENT_API_ENABLED: "true"/); + assert.match( + compose, + /DEVICE_MANAGEMENT_CORE_TOKEN_FILE: \/run\/nodedc-secrets\/management-core-token/, + ); + assert.match( + compose, + /source: \/volume1\/docker\/nodedc-device-plane\/secrets\/management-core-token/, + ); + assert.doesNotMatch(compose, /DEVICE_MANAGEMENT_CORE_TOKEN:\s/); }); test("repository pins idempotency, audit and last-owner checks inside one transaction", async () => { diff --git a/device-plane/services/device-manager/Dockerfile b/device-plane/services/device-manager/Dockerfile new file mode 100644 index 0000000..01e20c2 --- /dev/null +++ b/device-plane/services/device-manager/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine + +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=18122 + +WORKDIR /app + +COPY server ./server +COPY dist ./dist + +USER node + +EXPOSE 18122 + +CMD ["node", "server/device-manager-server.mjs"] diff --git a/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs b/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs new file mode 100644 index 0000000..23b07b8 --- /dev/null +++ b/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs @@ -0,0 +1,120 @@ +#!/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 devicePlaneRoot = resolve(platformRoot, "device-plane"); +const designRoot = resolve(process.env.NODEDC_DEVICE_MANAGER_SOURCE_ROOT || resolve(platformRoot, "../NODEDC_DESIGN_GUIDELINE")); +const managerRoot = resolve(designRoot, "apps/device-manager"); +const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); +const [patchId = "device-manager-control-plane-20260810-001", ...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 entries = [ + ".dockerignore", + "package.json", + "package-lock.json", + "docker-compose.device-manager.yml", + "packages/device-protocol-contract", + "packages/arusnavi-b2-adapter", + "services/device-control-core", + "services/device-gateway/package.json", + "services/device-edge-relay/package.json", + "services/device-manager", + "deployment/device-manager-control-plane-v1.json", +]; +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: designRoot, + 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 === "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); + } + 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:", + "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}`); + } + 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", "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); + } +} + +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"); +} diff --git a/infra/deploy-runner/build-launcher-device-core-artifact.mjs b/infra/deploy-runner/build-launcher-device-core-artifact.mjs new file mode 100644 index 0000000..98d3518 --- /dev/null +++ b/infra/deploy-runner/build-launcher-device-core-artifact.mjs @@ -0,0 +1,63 @@ +#!/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 launcherRoot = resolve(process.env.NODEDC_LAUNCHER_REPO || resolve(scriptDir, "../../../../data/nodedc_launcher")); +const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); +const [patchId = "launcher-device-core-session-20260810-001", ...extra] = process.argv.slice(2); +if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-launcher-device-core-artifact.mjs [patch-id]"); + +const entries = [ + "server/control-plane-store.mjs", + "server/dev-server.mjs", + "server/device-core-session-access.mjs", + "server/internal-request-auth.mjs", + "src/shared/api/adminApi.ts", +]; +const stage = await mkdtemp(join(tmpdir(), "nodedc-launcher-device-core-")); +const payload = join(stage, "payload"); +const target = join(artifactDir, `nodedc-launcher-${patchId}.tgz`); +try { + await mkdir(payload, { recursive: true }); + for (const entry of entries) { + const source = resolve(launcherRoot, entry); + const sourceStat = await lstat(source); + if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) throw new Error(`launcher_source_rejected:${entry}`); + const destination = join(payload, entry); + await mkdir(dirname(destination), { recursive: true }); + await cp(source, destination, { force: true }); + } + const server = await readFile(join(payload, "server/dev-server.mjs"), "utf8"); + for (const required of ["resolveDeviceCoreSessionAccess", "NODEDC_DEVICE_CORE_INTERNAL_TOKEN_FILE", "deviceCoreInternalAccessConfigured"]) { + if (!server.includes(required)) throw new Error(`launcher_device_core_contract_missing:${required}`); + } + await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=launcher\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" }); + 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: "launcher", artifact: target, sha256, entries, services: ["launcher"] }, 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"); +} diff --git a/infra/deploy-runner/build-platform-device-core-hub-trust-artifact.mjs b/infra/deploy-runner/build-platform-device-core-hub-trust-artifact.mjs new file mode 100644 index 0000000..0030265 --- /dev/null +++ b/infra/deploy-runner/build-platform-device-core-hub-trust-artifact.mjs @@ -0,0 +1,72 @@ +#!/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 artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); +const [patchId = "platform-device-core-hub-trust-20260810-001", ...extra] = process.argv.slice(2); +if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-platform-device-core-hub-trust-artifact.mjs [patch-id]"); + +const entries = [ + "platform/docker-compose.platform-http.yml", + "platform/deployment/device-core-hub-trust-v1.json", +]; +const sources = new Map([ + [entries[0], resolve(platformRoot, "infra/synology/docker-compose.platform-http.yml")], + [entries[1], resolve(platformRoot, "infra/deployment/device-core-hub-trust-v1.json")], +]); + +await buildArtifact({ patchId, component: "platform", entries, sources }); + +async function buildArtifact({ patchId, component, entries, sources }) { + const stage = await mkdtemp(join(tmpdir(), "nodedc-platform-device-core-trust-")); + const payload = join(stage, "payload"); + const target = join(artifactDir, `nodedc-${component}-${patchId}.tgz`); + try { + await mkdir(payload, { recursive: true }); + for (const entry of entries) { + const destination = join(payload, entry); + await mkdir(dirname(destination), { recursive: true }); + await cp(sources.get(entry), destination, { force: true }); + } + const compose = await readFile(join(payload, entries[0]), "utf8"); + for (const required of [ + "NODEDC_DEVICE_CORE_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token", + "source: /volume1/docker/nodedc-platform/secrets/device-core-internal-token", + "create_host_path: false", + ]) if (!compose.includes(required)) throw new Error(`hub_trust_compose_contract_missing:${required}`); + await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=${component}\ntype=app-overlay\n`, "utf8"); + await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8"); + await mkdir(artifactDir, { recursive: true }); + canonicalTar(target, stage); + const sha256 = createHash("sha256").update(await readFile(target)).digest("hex"); + console.log(JSON.stringify({ ok: true, patchId, component, artifact: target, sha256, entries, services: ["launcher"] }, null, 2)); + } finally { + await rm(stage, { recursive: true, force: true }); + } +} + +function canonicalTar(target, stage) { + const result = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], { encoding: "utf8" }); + if (result.status !== 0) throw new Error(`tar_failed:${result.stderr || result.stdout}`); +} + +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"); +} diff --git a/infra/deploy-runner/build-platform-device-manager-route-artifact.mjs b/infra/deploy-runner/build-platform-device-manager-route-artifact.mjs new file mode 100644 index 0000000..863be6a --- /dev/null +++ b/infra/deploy-runner/build-platform-device-manager-route-artifact.mjs @@ -0,0 +1,54 @@ +#!/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 artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); +const [patchId = "platform-device-manager-public-route-20260810-001", ...extra] = process.argv.slice(2); +if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-platform-device-manager-route-artifact.mjs [patch-id]"); + +const entries = [ + "platform/Caddyfile.http", + "platform/deployment/device-manager-public-route-v1.json", +]; +const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-route-")); +const payload = join(stage, "payload"); +const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`); +try { + await mkdir(join(payload, "platform/deployment"), { recursive: true }); + await cp(resolve(platformRoot, "infra/synology/Caddyfile.http"), join(payload, entries[0]), { force: true }); + await cp(resolve(platformRoot, "infra/deployment/device-manager-public-route-v1.json"), join(payload, entries[1]), { force: true }); + const caddy = await readFile(join(payload, entries[0]), "utf8"); + for (const required of ["http://device.nodedc.ru", "reverse_proxy device-manager:18122", "X-Forwarded-Proto https"]) { + if (!caddy.includes(required)) throw new Error(`device_manager_route_contract_missing:${required}`); + } + await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\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" }); + 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: "platform", artifact: target, sha256, entries, services: ["reverse-proxy"] }, 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"); +} diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index 42d6067..a4eae80 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -38,6 +38,30 @@ DOCKER = Path("/usr/local/bin/docker") MAP_GATEWAY_SECRET_DIR = Path("/volume1/docker/nodedc-platform/secrets") MAP_GATEWAY_SECRET_FILE = MAP_GATEWAY_SECRET_DIR / "map-gateway-admin-secret" MAP_EGRESS_PROXY_SECRET_FILE = MAP_GATEWAY_SECRET_DIR / "map-egress-proxy-token" +PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE = ( + MAP_GATEWAY_SECRET_DIR / "device-core-internal-token" +) +PLATFORM_DEVICE_CORE_HUB_TRUST_REL = ( + "platform/deployment/device-core-hub-trust-v1.json" +) +PLATFORM_DEVICE_CORE_HUB_TRUST_ENTRIES = ( + "platform/docker-compose.platform-http.yml", + PLATFORM_DEVICE_CORE_HUB_TRUST_REL, +) +PLATFORM_DEVICE_MANAGER_PUBLIC_ROUTE_REL = ( + "platform/deployment/device-manager-public-route-v1.json" +) +PLATFORM_DEVICE_MANAGER_PUBLIC_ROUTE_ENTRIES = ( + "platform/Caddyfile.http", + PLATFORM_DEVICE_MANAGER_PUBLIC_ROUTE_REL, +) +LAUNCHER_DEVICE_CORE_SESSION_ENTRIES = ( + "server/control-plane-store.mjs", + "server/dev-server.mjs", + "server/device-core-session-access.mjs", + "server/internal-request-auth.mjs", + "src/shared/api/adminApi.ts", +) PROXY_CONTUR_ENV_FILE = Path("/volume1/docker/proxy-contur/.env") DC_AMD_PROXY_RUNTIME_DIR = Path("/volume1/docker/dc-amd-proxy/runtime") DEVICE_PLANE_ROOT = Path("/volume1/docker/nodedc-device-plane") @@ -45,8 +69,12 @@ DEVICE_PLANE_SECRET_DIR = DEVICE_PLANE_ROOT / "secrets" DEVICE_PLANE_POSTGRES_PASSWORD_FILE = DEVICE_PLANE_SECRET_DIR / "postgres-password" DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE = DEVICE_PLANE_SECRET_DIR / "gateway-core-token" DEVICE_PLANE_IDENTIFIER_PEPPER_FILE = DEVICE_PLANE_SECRET_DIR / "identifier-pepper" +DEVICE_PLANE_MANAGEMENT_CORE_TOKEN_FILE = ( + DEVICE_PLANE_SECRET_DIR / "management-core-token" +) DEVICE_PLANE_CONTROL_CORE_IMAGE = "nodedc/device-control-core:local" DEVICE_PLANE_GATEWAY_IMAGE = "nodedc/device-gateway:local" +DEVICE_PLANE_MANAGER_IMAGE = "nodedc/device-manager:local" DEVICE_PLANE_BACKHAUL_TARGET_IMAGE = "nodedc/device-backhaul-target:local" DEVICE_PLANE_BACKHAUL_FAILED_TARGET_REL = ( "deployment/device-plane-backhaul-target-v1.json" @@ -182,6 +210,26 @@ DEVICE_PLANE_FOUNDATION_ENTRIES = ( "services/device-control-core", "services/device-gateway", ) +DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL = ( + "deployment/device-manager-control-plane-v1.json" +) +DEVICE_PLANE_MANAGER_COMPOSE_REL = "docker-compose.device-manager.yml" +DEVICE_PLANE_MANAGER_COMPOSE_SHA256 = ( + "4954120aaddc999798b64c304d8cf692b79714feb727d873117bd1f3434e865e" +) +DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES = ( + ".dockerignore", + "package.json", + "package-lock.json", + DEVICE_PLANE_MANAGER_COMPOSE_REL, + "packages/device-protocol-contract", + "packages/arusnavi-b2-adapter", + "services/device-control-core", + "services/device-gateway/package.json", + "services/device-edge-relay/package.json", + "services/device-manager", + DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, +) DEVICE_PLANE_FOUNDATION_RECOVERY_REL = ( "deployment/device-plane-foundation-recovery-v1.json" ) @@ -1478,8 +1526,9 @@ COMPONENTS = { ), "bootstrap_root": True, "compose_no_deps": True, - # PostgreSQL is durable state infrastructure. Normal application - # overlays can rebuild/recreate only these two stateless services. + # PostgreSQL is durable state infrastructure. The default legacy + # selection remains Core + Gateway; the exact Device Manager slice + # separately registers the third stateless service. "services": ("device-control-core", "device-gateway"), }, "proxy-contur": { @@ -2641,9 +2690,11 @@ def denied_payload_path(component, rel): pass elif component == "device-plane" and rel in ( "docker-compose.device-plane.yml", + DEVICE_PLANE_MANAGER_COMPOSE_REL, DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL, "services/device-control-core/Dockerfile", "services/device-gateway/Dockerfile", + "services/device-manager/Dockerfile", "services/device-backhaul-target/Dockerfile", ): pass @@ -2791,8 +2842,10 @@ def denied_payload_path(component, rel): return "device-plane test path" if rel.startswith(( "docker-compose.device-plane.yml.bak", + "docker-compose.device-manager.yml.bak", "services/device-control-core/Dockerfile.bak", "services/device-gateway/Dockerfile.bak", + "services/device-manager/Dockerfile.bak", )): return "device-plane backup file" elif component == "n8n-private-extension": @@ -2916,6 +2969,8 @@ def allowed_payload_path(component, rel): "platform/Caddyfile.http", "platform/docker-compose.platform-http.yml", "platform/docker-compose.external-data-plane.yml", + PLATFORM_DEVICE_CORE_HUB_TRUST_REL, + PLATFORM_DEVICE_MANAGER_PUBLIC_ROUTE_REL, ): return True if rel == "platform/notification-core" or rel.startswith("platform/notification-core/"): @@ -3051,6 +3106,7 @@ def allowed_payload_path(component, rel): "package.json", "package-lock.json", "docker-compose.device-plane.yml", + DEVICE_PLANE_MANAGER_COMPOSE_REL, DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL, DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL, DEVICE_PLANE_FOUNDATION_RECOVERY_REL, @@ -3058,11 +3114,13 @@ def allowed_payload_path(component, rel): DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL, DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_REL, DEVICE_PLANE_BACKHAUL_TARGET_REL, + DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, "packages/device-protocol-contract", "packages/arusnavi-b2-adapter", "services/device-control-core", "services/device-gateway", "services/device-edge-relay/package.json", + "services/device-manager", "services/device-backhaul-target", ): return True @@ -3071,6 +3129,7 @@ def allowed_payload_path(component, rel): "packages/arusnavi-b2-adapter/", "services/device-control-core/", "services/device-gateway/", + "services/device-manager/", "services/device-backhaul-target/", )): return True @@ -8086,6 +8145,21 @@ def load_artifact(artifact, work_dir): die(f"files.txt entry missing in payload: {rel}") validate_payload_tree(manifest["component"], payload_dir, entries) + if is_platform_device_core_hub_trust_slice( + manifest["component"], + entries, + ): + validate_platform_device_core_hub_trust_payload(payload_dir) + if is_platform_device_manager_public_route_slice( + manifest["component"], + entries, + ): + validate_platform_device_manager_public_route_payload(payload_dir) + if is_device_plane_manager_control_plane_slice( + manifest["component"], + entries, + ): + validate_device_plane_manager_control_plane_payload(payload_dir) if is_device_plane_postgres_bootstrap_slice( manifest["component"], entries, @@ -8449,6 +8523,156 @@ def component_root(component): return COMPONENTS[component]["payload_root"] +def is_platform_device_core_hub_trust_slice(component, entries): + return ( + component == "platform" + and entries is not None + and tuple(entries) == PLATFORM_DEVICE_CORE_HUB_TRUST_ENTRIES + ) + + +def is_platform_device_manager_public_route_slice(component, entries): + return ( + component == "platform" + and entries is not None + and tuple(entries) == PLATFORM_DEVICE_MANAGER_PUBLIC_ROUTE_ENTRIES + ) + + +def is_launcher_device_core_session_slice(component, entries): + return ( + component == "launcher" + and entries is not None + and tuple(entries) == LAUNCHER_DEVICE_CORE_SESSION_ENTRIES + ) + + +def is_device_plane_manager_control_plane_slice(component, entries): + return ( + component == "device-plane" + and entries is not None + and tuple(entries) == DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES + ) + + +def expected_platform_device_core_hub_trust_descriptor(): + return { + "schemaVersion": "nodedc.platform.device-core-hub-trust.v1", + "action": "activate", + "serviceSlug": "device-core", + "launcherCredential": "runner-managed-file", + "credentialScope": ["handoff.consume", "session.validate"], + "publicRoute": "unchanged", + } + + +def expected_platform_device_manager_public_route_descriptor(): + return { + "schemaVersion": "nodedc.platform.device-manager-public-route.v1", + "action": "activate", + "hostname": "device.nodedc.ru", + "upstream": "device-manager:18122", + "transport": "reverse-proxy", + "rawTcpIngress": "forbidden", + } + + +def expected_device_plane_manager_control_plane_descriptor(): + return { + "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", + } + + +def validate_platform_device_core_hub_trust_payload(payload_dir): + descriptor = read_strict_json( + payload_dir / PLATFORM_DEVICE_CORE_HUB_TRUST_REL, + "Platform Device Core Hub trust descriptor", + max_bytes=16 * 1024, + ) + if descriptor != expected_platform_device_core_hub_trust_descriptor(): + die("Platform Device Core Hub trust descriptor mismatch") + compose = ( + payload_dir / "platform/docker-compose.platform-http.yml" + ).read_text(encoding="utf-8") + for required in ( + "NODEDC_DEVICE_CORE_INTERNAL_TOKEN_FILE: " + "/run/nodedc-secrets/device-core-internal-token", + "source: /volume1/docker/nodedc-platform/secrets/" + "device-core-internal-token", + "create_host_path: false", + ): + if required not in compose: + die(f"Platform Device Core Hub trust boundary missing: {required}") + return descriptor + + +def validate_platform_device_manager_public_route_payload(payload_dir): + descriptor = read_strict_json( + payload_dir / PLATFORM_DEVICE_MANAGER_PUBLIC_ROUTE_REL, + "Platform Device Manager public route descriptor", + max_bytes=16 * 1024, + ) + if descriptor != expected_platform_device_manager_public_route_descriptor(): + die("Platform Device Manager public route descriptor mismatch") + caddy = (payload_dir / "platform/Caddyfile.http").read_text( + encoding="utf-8" + ) + for required in ( + "http://device.nodedc.ru", + "reverse_proxy device-manager:18122", + "header_up X-Forwarded-Proto https", + ): + if required not in caddy: + die(f"Platform Device Manager route boundary missing: {required}") + return descriptor + + +def validate_device_plane_manager_control_plane_payload(payload_dir): + descriptor = read_strict_json( + payload_dir / DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, + "Device Manager control-plane descriptor", + max_bytes=16 * 1024, + ) + if descriptor != expected_device_plane_manager_control_plane_descriptor(): + die("Device Manager control-plane descriptor mismatch") + compose_path = payload_dir / DEVICE_PLANE_MANAGER_COMPOSE_REL + if sha256_file(compose_path) != DEVICE_PLANE_MANAGER_COMPOSE_SHA256: + die("Device Manager control-plane Compose mismatch") + compose = compose_path.read_text( + encoding="utf-8" + ) + for required in ( + "device-manager:", + 'DEVICE_MANAGEMENT_API_ENABLED: "true"', + "DEVICE_MANAGEMENT_CORE_TOKEN_FILE: " + "/run/nodedc-secrets/management-core-token", + "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 required not in compose: + die(f"Device Manager control-plane boundary missing: {required}") + for forbidden in ( + "NODEDC_INTERNAL_ACCESS_TOKEN:", + "NODEDC_PLATFORM_SERVICE_TOKEN:", + "0.0.0.0:18122", + ): + if forbidden in compose: + die(f"Device Manager control-plane boundary violation: {forbidden}") + return descriptor + + def is_device_plane_postgres_bootstrap_slice(component, entries): return ( component == "device-plane" @@ -9674,6 +9898,7 @@ def validate_device_plane_b2_discovery_rollback_recovery_evidence( def device_plane_service_container_ids(service): if service not in ( *DEVICE_PLANE_RUNTIME_SERVICES, + "device-manager", DEVICE_PLANE_BACKHAUL_TARGET_SERVICE, ): die(f"Device Plane runtime service is not registered: {service}") @@ -11044,7 +11269,7 @@ def validate_device_plane_backhaul_target_runtime( } -def validate_device_plane_runtime_secret_metadata(): +def validate_device_plane_runtime_secret_metadata(include_management=False): try: directory_stat = DEVICE_PLANE_SECRET_DIR.lstat() except FileNotFoundError: @@ -11057,11 +11282,17 @@ def validate_device_plane_runtime_secret_metadata(): or stat.S_IMODE(directory_stat.st_mode) != 0o710 ): die("Device Plane runtime secret directory boundary mismatch") - for path, label in ( + required_secrets = [ (DEVICE_PLANE_POSTGRES_PASSWORD_FILE, "PostgreSQL"), (DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE, "Gateway to Core"), (DEVICE_PLANE_IDENTIFIER_PEPPER_FILE, "identifier pepper"), - ): + ] + if include_management: + required_secrets.append(( + DEVICE_PLANE_MANAGEMENT_CORE_TOKEN_FILE, + "management to Core", + )) + for path, label in required_secrets: try: path_stat = path.lstat() except FileNotFoundError: @@ -11084,6 +11315,111 @@ def validate_device_plane_runtime_secret_metadata(): return "exact" +def validate_device_manager_control_plane_runtime(): + core_ids = device_plane_service_container_ids("device-control-core") + manager_ids = device_plane_service_container_ids("device-manager") + if len(core_ids) != 1 or len(manager_ids) != 1: + die("Device Manager control-plane runtime topology mismatch") + core = inspect_device_plane_container(core_ids[0]) + manager = inspect_device_plane_container(manager_ids[0]) + core_environment = container_environment(core, "Device Control Core") + manager_environment = container_environment(manager, "Device Manager") + if ( + core_environment.get("DEVICE_MANAGEMENT_API_ENABLED") != "true" + or core_environment.get("DEVICE_MANAGEMENT_CORE_TOKEN_FILE") + != "/run/nodedc-secrets/management-core-token" + ): + die("Device Control Core management runtime mismatch") + expected_manager_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" + ), + } + if any( + manager_environment.get(key) != value + for key, value in expected_manager_environment.items() + ): + die("Device Manager environment mismatch") + for environment in (core_environment, manager_environment): + for forbidden in ( + "NODEDC_INTERNAL_ACCESS_TOKEN", + "NODEDC_PLATFORM_SERVICE_TOKEN", + "DEVICE_MANAGEMENT_CORE_TOKEN", + ): + if forbidden in environment: + die("Device Manager plaintext secret boundary mismatch") + core_mounts = { + mount.get("Destination"): mount + for mount in core.get("Mounts") or [] + } + core_management_mount = core_mounts.get( + "/run/nodedc-secrets/management-core-token" + ) + if ( + core_management_mount is None + or core_management_mount.get("Type") != "bind" + or core_management_mount.get("Source") + != str(DEVICE_PLANE_MANAGEMENT_CORE_TOKEN_FILE) + or core_management_mount.get("RW") is not False + ): + die("Device Control Core management secret mount mismatch") + manager_mounts = { + mount.get("Destination"): mount + for mount in manager.get("Mounts") or [] + } + expected_manager_mounts = { + "/run/nodedc-secrets/device-core-internal-token": ( + PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE + ), + "/run/nodedc-secrets/management-core-token": ( + DEVICE_PLANE_MANAGEMENT_CORE_TOKEN_FILE + ), + } + if set(manager_mounts) != set(expected_manager_mounts): + die("Device Manager mount set mismatch") + for destination, source in expected_manager_mounts.items(): + mount = manager_mounts[destination] + if ( + mount.get("Type") != "bind" + or mount.get("Source") != str(source) + or mount.get("RW") is not False + ): + die("Device Manager secret mount mismatch") + ports = (manager.get("NetworkSettings") or {}).get("Ports") or {} + if any(bindings for bindings in ports.values()): + die("Device Manager host port publication is forbidden") + networks = set( + ((manager.get("NetworkSettings") or {}).get("Networks") or {}).keys() + ) + if networks != { + DEVICE_PLANE_PRIVATE_NETWORK, + "nodedc-platform_edge", + }: + die("Device Manager network boundary mismatch") + validate_device_plane_runtime_secret_metadata(include_management=True) + ensure_platform_runtime_secret( + PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE, + MAP_GATEWAY_SECRET_RE, + "Device Core Hub handoff", + ) + return "exact" + + def validate_device_plane_foundation_installed_source(): failed_artifact = FAILED_DIR / DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT with tempfile.TemporaryDirectory( @@ -12804,6 +13140,9 @@ def component_services(component, entries=None): # authorization normalization inside the already-active backend. return ("nodedc-backend",) + if is_device_plane_manager_control_plane_slice(component, entries): + return ("device-control-core", "device-manager") + if component == "device-plane" and entries is not None: selected = [] @@ -12882,6 +13221,12 @@ def component_services(component, entries=None): selected.append("proxy") return tuple(selected) + if is_platform_device_core_hub_trust_slice(component, entries): + return ("launcher",) + + if is_platform_device_manager_public_route_slice(component, entries): + return ("reverse-proxy",) + if component == "platform" and entries is not None: if is_platform_provider_catalog_only(entries): return () @@ -13045,6 +13390,16 @@ def component_compose_files( return tuple(files) files = COMPONENTS[component].get("compose_files", ()) if component == "device-plane": + manager_overlay = DEVICE_PLANE_ROOT / DEVICE_PLANE_MANAGER_COMPOSE_REL + if manager_overlay.exists() or manager_overlay.is_symlink(): + if ( + manager_overlay.is_symlink() + or not manager_overlay.is_file() + or sha256_file(manager_overlay) + != DEVICE_PLANE_MANAGER_COMPOSE_SHA256 + ): + die("installed Device Manager Compose drift detected") + files = (*files, manager_overlay) overlay = DEVICE_PLANE_ROOT / DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL if overlay.exists() or overlay.is_symlink(): if ( @@ -13088,6 +13443,39 @@ def component_build_args(component, entries=None): def component_builds(component, entries=None): + if is_platform_device_core_hub_trust_slice(component, entries): + return () + + if is_platform_device_manager_public_route_slice(component, entries): + return () + + if is_device_plane_manager_control_plane_slice(component, entries): + return ( + ( + DEVICE_PLANE_ROOT, + ( + "build", + "--no-cache", + "--network=host", + "-f", + "services/device-control-core/Dockerfile", + "-t", + DEVICE_PLANE_CONTROL_CORE_IMAGE, + ".", + ), + ), + ( + DEVICE_PLANE_ROOT / "services/device-manager", + ( + "build", + "--no-cache", + "-t", + DEVICE_PLANE_MANAGER_IMAGE, + ".", + ), + ), + ) + if is_device_plane_backhaul_vps_enrollment_slice(component, entries): return () @@ -13155,6 +13543,17 @@ def component_builds(component, entries=None): ".", ), )) + if "device-manager" in selected_services: + builds.append(( + DEVICE_PLANE_ROOT / "services/device-manager", + ( + "build", + "--no-cache", + "-t", + DEVICE_PLANE_MANAGER_IMAGE, + ".", + ), + )) return tuple(builds) if component == "platform" and entries is not None: @@ -16694,6 +17093,15 @@ def plan_artifact(artifact): print(f"runtime_secret=runner-managed:{DEVICE_PLANE_POSTGRES_PASSWORD_FILE}") print(f"runtime_secret=runner-managed:{DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE}") print(f"runtime_secret=runner-managed:{DEVICE_PLANE_IDENTIFIER_PEPPER_FILE}") + if is_device_plane_manager_control_plane_slice(component, entries): + print( + "runtime_secret=runner-managed:" + f"{DEVICE_PLANE_MANAGEMENT_CORE_TOKEN_FILE}" + ) + print( + "runtime_secret=runner-managed:" + f"{PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE}" + ) print( "device_postgres=" f"{device_plane_postgres_plan_selection(device_plane_postgres_preflight)}" @@ -16977,6 +17385,19 @@ def plan_artifact(artifact): ) print("device_postgres_bootstrap_mode=create-if-absent") print("device_postgres_rollback_volume=preserve") + if is_platform_device_core_hub_trust_slice(component, entries): + print( + "runtime_secret=runner-managed:" + f"{PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE}" + ) + print("device_core_hub_credential_scope=handoff+session-only") + print("device_manager_public_route=unchanged") + if is_platform_device_manager_public_route_slice(component, entries): + print("device_manager_public_route=https-via-reverse-proxy") + print("device_manager_raw_tcp_ingress=forbidden") + if is_launcher_device_core_session_slice(component, entries): + print("device_core_owner_scopes=hub-signed-runtime-claims") + print("device_core_company_scope=active-admin-grant-only") if touches_external_data_plane: print(f"runtime_secret=runner-managed:{EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE}") print(f"runtime_grants=runner-managed:{EXTERNAL_DATA_PLANE_READER_GRANTS_DIR}") @@ -18227,6 +18648,17 @@ def prepare_component_runtime(component, entries=None): MAP_GATEWAY_SECRET_RE, "device plane identifier pepper", ) + if is_device_plane_manager_control_plane_slice(component, entries): + ensure_platform_runtime_secret( + DEVICE_PLANE_MANAGEMENT_CORE_TOKEN_FILE, + MAP_GATEWAY_SECRET_RE, + "device plane management to Core", + ) + ensure_platform_runtime_secret( + PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE, + MAP_GATEWAY_SECRET_RE, + "Device Core Hub handoff", + ) if is_device_plane_backhaul_vps_enrollment_slice( component, entries, @@ -18272,6 +18704,12 @@ def prepare_component_runtime(component, entries=None): return if component == "platform" and entries is not None: + if is_platform_device_core_hub_trust_slice(component, entries): + ensure_platform_runtime_secret( + PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE, + MAP_GATEWAY_SECRET_RE, + "Device Core Hub handoff", + ) touches_map_gateway = any(rel == "platform/services/map-gateway" or rel.startswith("platform/services/map-gateway/") for rel in entries) touches_external_data_plane = touches_external_data_plane_files(entries) if touches_map_gateway: @@ -18483,6 +18921,44 @@ def module_foundry_healthcheck(): def component_healthchecks(component, entries=None, services=None): + if is_platform_device_core_hub_trust_slice(component, entries): + return ({ + "url": "http://127.0.0.1:18080/healthz", + "headers": {"Host": "hub.nodedc.ru"}, + },) + if is_platform_device_manager_public_route_slice(component, entries): + return ({ + "url": "http://127.0.0.1:18080/healthz", + "headers": {"Host": "device.nodedc.ru"}, + "expected_json": { + "ok": True, + "service": "nodedc-device-manager", + "authRequired": True, + "deviceCoreConfigured": True, + }, + },) + if is_launcher_device_core_session_slice(component, entries): + return ({ + "url": "http://127.0.0.1:18080/healthz", + "headers": {"Host": "hub.nodedc.ru"}, + "expected_json": { + "ok": True, + "service": "nodedc-launcher-bff", + "deviceCoreInternalAccessConfigured": True, + }, + },) + if is_device_plane_manager_control_plane_slice(component, entries): + return ({ + "url": "http://127.0.0.1:18120/healthz", + "expected_json": { + "ok": True, + "service": "nodedc-device-control-core", + "database": "ready", + "discoveryIngest": "enabled", + "managementApi": "enabled", + "commandTransport": "disabled", + }, + },) if is_device_plane_b2_discovery_rollback_recovery_slice( component, entries, @@ -19100,6 +19576,18 @@ def run_healthchecks(component, entries=None, services=None): healthcheck_url(check) assert_loopback_tcp_port_open(9921) return + if is_device_plane_manager_control_plane_slice(component, entries): + if tuple(services or ()) != ( + "device-control-core", + "device-manager", + ): + die("Device Manager control-plane service set mismatch") + for service in services: + healthcheck_compose_service("device-plane", service) + for check in component_healthchecks(component, entries, services): + healthcheck_url(check) + validate_device_manager_control_plane_runtime() + return if component == "platform" and entries is not None and is_platform_provider_catalog_only(entries): return if is_engine_l2_closed_loop_slice(component, entries): diff --git a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py new file mode 100644 index 0000000..6541c0a --- /dev/null +++ b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py @@ -0,0 +1,169 @@ +#!/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 import mock + + +SCRIPT_DIR = Path(__file__).resolve().parent +PLATFORM_ROOT = SCRIPT_DIR.parent.parent +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader( + "nodedc_device_manager_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 DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): + def build(self, script, patch_id, artifact_dir): + environment = os.environ.copy() + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + completed = subprocess.run( + ["node", str(SCRIPT_DIR / script), patch_id], + cwd=PLATFORM_ROOT, + env=environment, + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + def assert_deterministic_artifact(self, script, patch_id, expected_entries): + with tempfile.TemporaryDirectory(prefix="nodedc-device-manager-artifact-") as directory: + root = Path(directory) + first = self.build(script, patch_id, root / "first") + second = self.build(script, patch_id, root / "second") + first_artifact = Path(first["artifact"]) + second_artifact = Path(second["artifact"]) + self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes()) + self.assertEqual( + first["sha256"], + hashlib.sha256(first_artifact.read_bytes()).hexdigest(), + ) + self.assertEqual(tuple(first["entries"]), tuple(expected_entries)) + extracted = root / "extracted" + extracted.mkdir() + manifest, entries, payload = RUNNER.load_artifact(first_artifact, extracted) + self.assertEqual(tuple(entries), tuple(expected_entries)) + with tarfile.open(first_artifact, "r:gz") as archive: + members = archive.getmembers() + names = [member.name for member in members] + bytes_joined = b"\n".join( + archive.extractfile(member).read() + for member in members + if member.isfile() + ) + self.assertFalse(any(Path(name).name.startswith("._") for name in names)) + self.assertFalse(any("/node_modules/" in name or "/.git/" in name for name in names)) + self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", bytes_joined) + return manifest, entries, names, first + + def test_platform_hub_trust_artifact_is_exact_and_build_free(self): + manifest, entries, _names, result = self.assert_deterministic_artifact( + "build-platform-device-core-hub-trust-artifact.mjs", + "platform-device-core-hub-trust-unit-001", + RUNNER.PLATFORM_DEVICE_CORE_HUB_TRUST_ENTRIES, + ) + self.assertEqual(manifest["component"], "platform") + self.assertEqual(RUNNER.component_services("platform", entries), ("launcher",)) + self.assertEqual(RUNNER.component_builds("platform", entries), ()) + self.assertEqual(result["services"], ["launcher"]) + + def test_launcher_session_artifact_is_exact(self): + manifest, entries, _names, result = self.assert_deterministic_artifact( + "build-launcher-device-core-artifact.mjs", + "launcher-device-core-session-unit-001", + RUNNER.LAUNCHER_DEVICE_CORE_SESSION_ENTRIES, + ) + self.assertEqual(manifest["component"], "launcher") + self.assertEqual(RUNNER.component_services("launcher", entries), ("launcher",)) + self.assertEqual(len(RUNNER.component_builds("launcher", entries)), 1) + self.assertEqual(result["services"], ["launcher"]) + + def test_device_manager_artifact_selects_only_core_and_manager(self): + manifest, entries, names, result = self.assert_deterministic_artifact( + "build-device-manager-control-plane-artifact.mjs", + "device-manager-control-plane-unit-001", + RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES, + ) + self.assertEqual(manifest["component"], "device-plane") + self.assertEqual( + RUNNER.component_services("device-plane", entries), + ("device-control-core", "device-manager"), + ) + builds = RUNNER.component_builds("device-plane", entries) + self.assertEqual(len(builds), 2) + self.assertIn(RUNNER.DEVICE_PLANE_CONTROL_CORE_IMAGE, builds[0][1]) + self.assertIn(RUNNER.DEVICE_PLANE_MANAGER_IMAGE, builds[1][1]) + self.assertIn("payload/services/device-manager/dist/index.html", names) + self.assertIn( + "payload/services/device-manager/server/device-manager-server.mjs", + names, + ) + self.assertFalse(any(name.endswith(".test.mjs") for name in names)) + self.assertEqual(result["services"], ["device-control-core", "device-manager"]) + self.assertNotIn("device-postgres", result["services"]) + self.assertIn("docker-compose.device-manager.yml", entries) + self.assertNotIn("docker-compose.device-plane.yml", entries) + checks = RUNNER.component_healthchecks("device-plane", entries, tuple(result["services"])) + self.assertEqual(checks[0]["expected_json"]["managementApi"], "enabled") + self.assertEqual(checks[0]["expected_json"]["discoveryIngest"], "enabled") + + def test_public_route_artifact_is_last_and_proxy_only(self): + manifest, entries, _names, result = self.assert_deterministic_artifact( + "build-platform-device-manager-route-artifact.mjs", + "platform-device-manager-route-unit-001", + RUNNER.PLATFORM_DEVICE_MANAGER_PUBLIC_ROUTE_ENTRIES, + ) + self.assertEqual(manifest["component"], "platform") + self.assertEqual(RUNNER.component_services("platform", entries), ("reverse-proxy",)) + self.assertEqual(RUNNER.component_builds("platform", entries), ()) + self.assertEqual(result["services"], ["reverse-proxy"]) + + def test_runner_creates_only_file_backed_runtime_secrets(self): + with mock.patch.object(RUNNER, "ensure_platform_runtime_secret") as ensure: + RUNNER.prepare_component_runtime( + "platform", + RUNNER.PLATFORM_DEVICE_CORE_HUB_TRUST_ENTRIES, + ) + self.assertEqual( + [call.args[0] for call in ensure.call_args_list], + [RUNNER.PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE], + ) + + with mock.patch.object(RUNNER, "ensure_platform_runtime_secret") as ensure: + RUNNER.prepare_component_runtime( + "device-plane", + RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES, + ) + self.assertEqual( + [call.args[0] for call in ensure.call_args_list], + [ + RUNNER.DEVICE_PLANE_POSTGRES_PASSWORD_FILE, + RUNNER.DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE, + RUNNER.DEVICE_PLANE_IDENTIFIER_PEPPER_FILE, + RUNNER.DEVICE_PLANE_MANAGEMENT_CORE_TOKEN_FILE, + RUNNER.PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE, + ], + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/infra/deployment/device-core-hub-trust-v1.json b/infra/deployment/device-core-hub-trust-v1.json new file mode 100644 index 0000000..b67fab2 --- /dev/null +++ b/infra/deployment/device-core-hub-trust-v1.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": "nodedc.platform.device-core-hub-trust.v1", + "action": "activate", + "serviceSlug": "device-core", + "launcherCredential": "runner-managed-file", + "credentialScope": [ + "handoff.consume", + "session.validate" + ], + "publicRoute": "unchanged" +} diff --git a/infra/deployment/device-manager-public-route-v1.json b/infra/deployment/device-manager-public-route-v1.json new file mode 100644 index 0000000..cd581e2 --- /dev/null +++ b/infra/deployment/device-manager-public-route-v1.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": "nodedc.platform.device-manager-public-route.v1", + "action": "activate", + "hostname": "device.nodedc.ru", + "upstream": "device-manager:18122", + "transport": "reverse-proxy", + "rawTcpIngress": "forbidden" +} diff --git a/infra/synology/Caddyfile.http b/infra/synology/Caddyfile.http index 92e1a5a..60cc062 100644 --- a/infra/synology/Caddyfile.http +++ b/infra/synology/Caddyfile.http @@ -87,6 +87,16 @@ http://hub.nodedc.ru { } } +http://device.nodedc.ru { + reverse_proxy device-manager:18122 { + header_up Host device.nodedc.ru + header_up X-Forwarded-Host device.nodedc.ru + header_up X-Forwarded-Proto https + header_up X-Forwarded-Port 443 + header_up X-Forwarded-For {remote_host} + } +} + http://ops.nodedc.ru { reverse_proxy {$SYNOLOGY_TASK_MANAGER_UPSTREAM} { header_up Host ops.nodedc.ru diff --git a/infra/synology/docker-compose.platform-http.yml b/infra/synology/docker-compose.platform-http.yml index 1d26c0c..7f45d4a 100644 --- a/infra/synology/docker-compose.platform-http.yml +++ b/infra/synology/docker-compose.platform-http.yml @@ -46,11 +46,18 @@ services: AUTHENTIK_BASE_URL: http://nodedc-platform-authentik-server:9000 NODEDC_NOTIFICATION_CORE_URL: http://notification-core:5185 NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082 + NODEDC_DEVICE_CORE_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token expose: - "5173" volumes: - ../launcher/server-storage:/app/server/storage - ../launcher/uploads:/app/server/storage/uploads + - 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 extra_hosts: - "id.nodedc.ru:host-gateway" - "hub.nodedc.ru:host-gateway"