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 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");
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user