feat(device-plane): add canonical Device Manager runtime

This commit is contained in:
Codex
2026-08-10 22:00:59 +03:00
parent 1d1e9a96b3
commit 29ba5de92e
14 changed files with 1122 additions and 10 deletions
@@ -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,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
@@ -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 () => {
@@ -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"]
@@ -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");
}
@@ -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");
}
@@ -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");
}
@@ -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");
}
+493 -5
View File
@@ -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):
@@ -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)
@@ -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"
}
@@ -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"
}
+10
View File
@@ -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
@@ -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"