feat(device-plane): add canonical Device Manager runtime
This commit is contained in:
@@ -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
|
||||||
+13
-5
@@ -5,7 +5,7 @@ import test from "node:test";
|
|||||||
const appUrl = new URL("../src/app.mjs", import.meta.url);
|
const appUrl = new URL("../src/app.mjs", import.meta.url);
|
||||||
const serverUrl = new URL("../src/server.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 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 () => {
|
test("management surface is internal, POST-only and disabled by default", async () => {
|
||||||
const source = await readFile(appUrl, "utf8");
|
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)/);
|
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 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_API_ENABLED/);
|
||||||
assert.match(server, /DEVICE_MANAGEMENT_CORE_TOKEN_FILE/);
|
assert.match(server, /DEVICE_MANAGEMENT_CORE_TOKEN_FILE/);
|
||||||
assert.doesNotMatch(compose, /DEVICE_MANAGEMENT_API_ENABLED/);
|
assert.match(compose, /DEVICE_MANAGEMENT_API_ENABLED: "true"/);
|
||||||
assert.doesNotMatch(compose, /DEVICE_MANAGEMENT_CORE_TOKEN_FILE/);
|
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 () => {
|
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");
|
||||||
|
}
|
||||||
@@ -38,6 +38,30 @@ DOCKER = Path("/usr/local/bin/docker")
|
|||||||
MAP_GATEWAY_SECRET_DIR = Path("/volume1/docker/nodedc-platform/secrets")
|
MAP_GATEWAY_SECRET_DIR = Path("/volume1/docker/nodedc-platform/secrets")
|
||||||
MAP_GATEWAY_SECRET_FILE = MAP_GATEWAY_SECRET_DIR / "map-gateway-admin-secret"
|
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"
|
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")
|
PROXY_CONTUR_ENV_FILE = Path("/volume1/docker/proxy-contur/.env")
|
||||||
DC_AMD_PROXY_RUNTIME_DIR = Path("/volume1/docker/dc-amd-proxy/runtime")
|
DC_AMD_PROXY_RUNTIME_DIR = Path("/volume1/docker/dc-amd-proxy/runtime")
|
||||||
DEVICE_PLANE_ROOT = Path("/volume1/docker/nodedc-device-plane")
|
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_POSTGRES_PASSWORD_FILE = DEVICE_PLANE_SECRET_DIR / "postgres-password"
|
||||||
DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE = DEVICE_PLANE_SECRET_DIR / "gateway-core-token"
|
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_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_CONTROL_CORE_IMAGE = "nodedc/device-control-core:local"
|
||||||
DEVICE_PLANE_GATEWAY_IMAGE = "nodedc/device-gateway: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_TARGET_IMAGE = "nodedc/device-backhaul-target:local"
|
||||||
DEVICE_PLANE_BACKHAUL_FAILED_TARGET_REL = (
|
DEVICE_PLANE_BACKHAUL_FAILED_TARGET_REL = (
|
||||||
"deployment/device-plane-backhaul-target-v1.json"
|
"deployment/device-plane-backhaul-target-v1.json"
|
||||||
@@ -182,6 +210,26 @@ DEVICE_PLANE_FOUNDATION_ENTRIES = (
|
|||||||
"services/device-control-core",
|
"services/device-control-core",
|
||||||
"services/device-gateway",
|
"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 = (
|
DEVICE_PLANE_FOUNDATION_RECOVERY_REL = (
|
||||||
"deployment/device-plane-foundation-recovery-v1.json"
|
"deployment/device-plane-foundation-recovery-v1.json"
|
||||||
)
|
)
|
||||||
@@ -1478,8 +1526,9 @@ COMPONENTS = {
|
|||||||
),
|
),
|
||||||
"bootstrap_root": True,
|
"bootstrap_root": True,
|
||||||
"compose_no_deps": True,
|
"compose_no_deps": True,
|
||||||
# PostgreSQL is durable state infrastructure. Normal application
|
# PostgreSQL is durable state infrastructure. The default legacy
|
||||||
# overlays can rebuild/recreate only these two stateless services.
|
# selection remains Core + Gateway; the exact Device Manager slice
|
||||||
|
# separately registers the third stateless service.
|
||||||
"services": ("device-control-core", "device-gateway"),
|
"services": ("device-control-core", "device-gateway"),
|
||||||
},
|
},
|
||||||
"proxy-contur": {
|
"proxy-contur": {
|
||||||
@@ -2641,9 +2690,11 @@ def denied_payload_path(component, rel):
|
|||||||
pass
|
pass
|
||||||
elif component == "device-plane" and rel in (
|
elif component == "device-plane" and rel in (
|
||||||
"docker-compose.device-plane.yml",
|
"docker-compose.device-plane.yml",
|
||||||
|
DEVICE_PLANE_MANAGER_COMPOSE_REL,
|
||||||
DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL,
|
DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL,
|
||||||
"services/device-control-core/Dockerfile",
|
"services/device-control-core/Dockerfile",
|
||||||
"services/device-gateway/Dockerfile",
|
"services/device-gateway/Dockerfile",
|
||||||
|
"services/device-manager/Dockerfile",
|
||||||
"services/device-backhaul-target/Dockerfile",
|
"services/device-backhaul-target/Dockerfile",
|
||||||
):
|
):
|
||||||
pass
|
pass
|
||||||
@@ -2791,8 +2842,10 @@ def denied_payload_path(component, rel):
|
|||||||
return "device-plane test path"
|
return "device-plane test path"
|
||||||
if rel.startswith((
|
if rel.startswith((
|
||||||
"docker-compose.device-plane.yml.bak",
|
"docker-compose.device-plane.yml.bak",
|
||||||
|
"docker-compose.device-manager.yml.bak",
|
||||||
"services/device-control-core/Dockerfile.bak",
|
"services/device-control-core/Dockerfile.bak",
|
||||||
"services/device-gateway/Dockerfile.bak",
|
"services/device-gateway/Dockerfile.bak",
|
||||||
|
"services/device-manager/Dockerfile.bak",
|
||||||
)):
|
)):
|
||||||
return "device-plane backup file"
|
return "device-plane backup file"
|
||||||
elif component == "n8n-private-extension":
|
elif component == "n8n-private-extension":
|
||||||
@@ -2916,6 +2969,8 @@ def allowed_payload_path(component, rel):
|
|||||||
"platform/Caddyfile.http",
|
"platform/Caddyfile.http",
|
||||||
"platform/docker-compose.platform-http.yml",
|
"platform/docker-compose.platform-http.yml",
|
||||||
"platform/docker-compose.external-data-plane.yml",
|
"platform/docker-compose.external-data-plane.yml",
|
||||||
|
PLATFORM_DEVICE_CORE_HUB_TRUST_REL,
|
||||||
|
PLATFORM_DEVICE_MANAGER_PUBLIC_ROUTE_REL,
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
if rel == "platform/notification-core" or rel.startswith("platform/notification-core/"):
|
if rel == "platform/notification-core" or rel.startswith("platform/notification-core/"):
|
||||||
@@ -3051,6 +3106,7 @@ def allowed_payload_path(component, rel):
|
|||||||
"package.json",
|
"package.json",
|
||||||
"package-lock.json",
|
"package-lock.json",
|
||||||
"docker-compose.device-plane.yml",
|
"docker-compose.device-plane.yml",
|
||||||
|
DEVICE_PLANE_MANAGER_COMPOSE_REL,
|
||||||
DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL,
|
DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL,
|
||||||
DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL,
|
DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL,
|
||||||
DEVICE_PLANE_FOUNDATION_RECOVERY_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_INGRESS_REL,
|
||||||
DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_REL,
|
DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_REL,
|
||||||
DEVICE_PLANE_BACKHAUL_TARGET_REL,
|
DEVICE_PLANE_BACKHAUL_TARGET_REL,
|
||||||
|
DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL,
|
||||||
"packages/device-protocol-contract",
|
"packages/device-protocol-contract",
|
||||||
"packages/arusnavi-b2-adapter",
|
"packages/arusnavi-b2-adapter",
|
||||||
"services/device-control-core",
|
"services/device-control-core",
|
||||||
"services/device-gateway",
|
"services/device-gateway",
|
||||||
"services/device-edge-relay/package.json",
|
"services/device-edge-relay/package.json",
|
||||||
|
"services/device-manager",
|
||||||
"services/device-backhaul-target",
|
"services/device-backhaul-target",
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
@@ -3071,6 +3129,7 @@ def allowed_payload_path(component, rel):
|
|||||||
"packages/arusnavi-b2-adapter/",
|
"packages/arusnavi-b2-adapter/",
|
||||||
"services/device-control-core/",
|
"services/device-control-core/",
|
||||||
"services/device-gateway/",
|
"services/device-gateway/",
|
||||||
|
"services/device-manager/",
|
||||||
"services/device-backhaul-target/",
|
"services/device-backhaul-target/",
|
||||||
)):
|
)):
|
||||||
return True
|
return True
|
||||||
@@ -8086,6 +8145,21 @@ def load_artifact(artifact, work_dir):
|
|||||||
die(f"files.txt entry missing in payload: {rel}")
|
die(f"files.txt entry missing in payload: {rel}")
|
||||||
|
|
||||||
validate_payload_tree(manifest["component"], payload_dir, entries)
|
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(
|
if is_device_plane_postgres_bootstrap_slice(
|
||||||
manifest["component"],
|
manifest["component"],
|
||||||
entries,
|
entries,
|
||||||
@@ -8449,6 +8523,156 @@ def component_root(component):
|
|||||||
return COMPONENTS[component]["payload_root"]
|
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):
|
def is_device_plane_postgres_bootstrap_slice(component, entries):
|
||||||
return (
|
return (
|
||||||
component == "device-plane"
|
component == "device-plane"
|
||||||
@@ -9674,6 +9898,7 @@ def validate_device_plane_b2_discovery_rollback_recovery_evidence(
|
|||||||
def device_plane_service_container_ids(service):
|
def device_plane_service_container_ids(service):
|
||||||
if service not in (
|
if service not in (
|
||||||
*DEVICE_PLANE_RUNTIME_SERVICES,
|
*DEVICE_PLANE_RUNTIME_SERVICES,
|
||||||
|
"device-manager",
|
||||||
DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,
|
DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,
|
||||||
):
|
):
|
||||||
die(f"Device Plane runtime service is not registered: {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:
|
try:
|
||||||
directory_stat = DEVICE_PLANE_SECRET_DIR.lstat()
|
directory_stat = DEVICE_PLANE_SECRET_DIR.lstat()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -11057,11 +11282,17 @@ def validate_device_plane_runtime_secret_metadata():
|
|||||||
or stat.S_IMODE(directory_stat.st_mode) != 0o710
|
or stat.S_IMODE(directory_stat.st_mode) != 0o710
|
||||||
):
|
):
|
||||||
die("Device Plane runtime secret directory boundary mismatch")
|
die("Device Plane runtime secret directory boundary mismatch")
|
||||||
for path, label in (
|
required_secrets = [
|
||||||
(DEVICE_PLANE_POSTGRES_PASSWORD_FILE, "PostgreSQL"),
|
(DEVICE_PLANE_POSTGRES_PASSWORD_FILE, "PostgreSQL"),
|
||||||
(DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE, "Gateway to Core"),
|
(DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE, "Gateway to Core"),
|
||||||
(DEVICE_PLANE_IDENTIFIER_PEPPER_FILE, "identifier pepper"),
|
(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:
|
try:
|
||||||
path_stat = path.lstat()
|
path_stat = path.lstat()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -11084,6 +11315,111 @@ def validate_device_plane_runtime_secret_metadata():
|
|||||||
return "exact"
|
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():
|
def validate_device_plane_foundation_installed_source():
|
||||||
failed_artifact = FAILED_DIR / DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT
|
failed_artifact = FAILED_DIR / DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT
|
||||||
with tempfile.TemporaryDirectory(
|
with tempfile.TemporaryDirectory(
|
||||||
@@ -12804,6 +13140,9 @@ def component_services(component, entries=None):
|
|||||||
# authorization normalization inside the already-active backend.
|
# authorization normalization inside the already-active backend.
|
||||||
return ("nodedc-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:
|
if component == "device-plane" and entries is not None:
|
||||||
selected = []
|
selected = []
|
||||||
|
|
||||||
@@ -12882,6 +13221,12 @@ def component_services(component, entries=None):
|
|||||||
selected.append("proxy")
|
selected.append("proxy")
|
||||||
return tuple(selected)
|
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 component == "platform" and entries is not None:
|
||||||
if is_platform_provider_catalog_only(entries):
|
if is_platform_provider_catalog_only(entries):
|
||||||
return ()
|
return ()
|
||||||
@@ -13045,6 +13390,16 @@ def component_compose_files(
|
|||||||
return tuple(files)
|
return tuple(files)
|
||||||
files = COMPONENTS[component].get("compose_files", ())
|
files = COMPONENTS[component].get("compose_files", ())
|
||||||
if component == "device-plane":
|
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
|
overlay = DEVICE_PLANE_ROOT / DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL
|
||||||
if overlay.exists() or overlay.is_symlink():
|
if overlay.exists() or overlay.is_symlink():
|
||||||
if (
|
if (
|
||||||
@@ -13088,6 +13443,39 @@ def component_build_args(component, entries=None):
|
|||||||
|
|
||||||
|
|
||||||
def component_builds(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):
|
if is_device_plane_backhaul_vps_enrollment_slice(component, entries):
|
||||||
return ()
|
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)
|
return tuple(builds)
|
||||||
|
|
||||||
if component == "platform" and entries is not None:
|
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_POSTGRES_PASSWORD_FILE}")
|
||||||
print(f"runtime_secret=runner-managed:{DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE}")
|
print(f"runtime_secret=runner-managed:{DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE}")
|
||||||
print(f"runtime_secret=runner-managed:{DEVICE_PLANE_IDENTIFIER_PEPPER_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(
|
print(
|
||||||
"device_postgres="
|
"device_postgres="
|
||||||
f"{device_plane_postgres_plan_selection(device_plane_postgres_preflight)}"
|
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_bootstrap_mode=create-if-absent")
|
||||||
print("device_postgres_rollback_volume=preserve")
|
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:
|
if touches_external_data_plane:
|
||||||
print(f"runtime_secret=runner-managed:{EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE}")
|
print(f"runtime_secret=runner-managed:{EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE}")
|
||||||
print(f"runtime_grants=runner-managed:{EXTERNAL_DATA_PLANE_READER_GRANTS_DIR}")
|
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,
|
MAP_GATEWAY_SECRET_RE,
|
||||||
"device plane identifier pepper",
|
"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(
|
if is_device_plane_backhaul_vps_enrollment_slice(
|
||||||
component,
|
component,
|
||||||
entries,
|
entries,
|
||||||
@@ -18272,6 +18704,12 @@ def prepare_component_runtime(component, entries=None):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if component == "platform" and entries is not None:
|
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_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)
|
touches_external_data_plane = touches_external_data_plane_files(entries)
|
||||||
if touches_map_gateway:
|
if touches_map_gateway:
|
||||||
@@ -18483,6 +18921,44 @@ def module_foundry_healthcheck():
|
|||||||
|
|
||||||
|
|
||||||
def component_healthchecks(component, entries=None, services=None):
|
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(
|
if is_device_plane_b2_discovery_rollback_recovery_slice(
|
||||||
component,
|
component,
|
||||||
entries,
|
entries,
|
||||||
@@ -19100,6 +19576,18 @@ def run_healthchecks(component, entries=None, services=None):
|
|||||||
healthcheck_url(check)
|
healthcheck_url(check)
|
||||||
assert_loopback_tcp_port_open(9921)
|
assert_loopback_tcp_port_open(9921)
|
||||||
return
|
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):
|
if component == "platform" and entries is not None and is_platform_provider_catalog_only(entries):
|
||||||
return
|
return
|
||||||
if is_engine_l2_closed_loop_slice(component, entries):
|
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"
|
||||||
|
}
|
||||||
@@ -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 {
|
http://ops.nodedc.ru {
|
||||||
reverse_proxy {$SYNOLOGY_TASK_MANAGER_UPSTREAM} {
|
reverse_proxy {$SYNOLOGY_TASK_MANAGER_UPSTREAM} {
|
||||||
header_up Host ops.nodedc.ru
|
header_up Host ops.nodedc.ru
|
||||||
|
|||||||
@@ -46,11 +46,18 @@ services:
|
|||||||
AUTHENTIK_BASE_URL: http://nodedc-platform-authentik-server:9000
|
AUTHENTIK_BASE_URL: http://nodedc-platform-authentik-server:9000
|
||||||
NODEDC_NOTIFICATION_CORE_URL: http://notification-core:5185
|
NODEDC_NOTIFICATION_CORE_URL: http://notification-core:5185
|
||||||
NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082
|
NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082
|
||||||
|
NODEDC_DEVICE_CORE_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token
|
||||||
expose:
|
expose:
|
||||||
- "5173"
|
- "5173"
|
||||||
volumes:
|
volumes:
|
||||||
- ../launcher/server-storage:/app/server/storage
|
- ../launcher/server-storage:/app/server/storage
|
||||||
- ../launcher/uploads:/app/server/storage/uploads
|
- ../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:
|
extra_hosts:
|
||||||
- "id.nodedc.ru:host-gateway"
|
- "id.nodedc.ru:host-gateway"
|
||||||
- "hub.nodedc.ru:host-gateway"
|
- "hub.nodedc.ru:host-gateway"
|
||||||
|
|||||||
Reference in New Issue
Block a user