fix(device-plane): split edge channel bootstrap
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "nodedc.device-plane.device-edge-core-channel-bootstrap.v1",
|
||||||
|
"transitionId": "__PATCH_ID__",
|
||||||
|
"action": "activate",
|
||||||
|
"managerPredecessor": {
|
||||||
|
"patchId": "device-manager-release-20260811-010",
|
||||||
|
"artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e"
|
||||||
|
},
|
||||||
|
"failedPredecessor": {
|
||||||
|
"patchId": "device-manager-release-20260811-016",
|
||||||
|
"artifactSha256": "590405821b95b54088f926e0d3b2cdf9c704b339f6749da500c2bb64fe0e952d",
|
||||||
|
"backupId": "device-plane-device-manager-release-20260811-016-20260811-215941",
|
||||||
|
"invalidCoreCertificateSha256Fingerprint": "56:16:E0:3A:F4:03:85:FD:42:86:85:AF:2A:AF:1E:90:16:C8:F7:91:7C:AD:02:7D:B7:C2:ED:07:06:56:81:F6"
|
||||||
|
},
|
||||||
|
"service": "device-control-core",
|
||||||
|
"composeActivation": "dedicated-additive-override",
|
||||||
|
"identity": "runner-managed-host-local-private-key-public-certificate-export",
|
||||||
|
"identityRecovery": "exact-invalid-unexported-failed-predecessor-only",
|
||||||
|
"tlsPurpose": "clientAuth",
|
||||||
|
"direction": "core-initiated",
|
||||||
|
"publicIngress": "none-on-synology",
|
||||||
|
"edgeRegistrations": "preserved",
|
||||||
|
"commandTransport": "disabled",
|
||||||
|
"gelios": "untouched",
|
||||||
|
"preservedServices": [
|
||||||
|
"device-manager",
|
||||||
|
"device-gateway",
|
||||||
|
"device-postgres",
|
||||||
|
"device-backhaul-target"
|
||||||
|
],
|
||||||
|
"healthGate": "bounded-container-grace+core-edge-contract",
|
||||||
|
"rollback": "restore-source-and-preapply-core-runtime"
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
device-control-core:
|
||||||
|
environment:
|
||||||
|
DEVICE_EDGE_CHANNEL_ENABLED: "true"
|
||||||
|
DEVICE_EDGE_CHANNEL_CORE_KEY_FILE: /run/nodedc-secrets/device-edge-channel/core-private-key.pem
|
||||||
|
DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE: /run/nodedc-secrets/device-edge-channel/core-certificate.pem
|
||||||
|
DEVICE_EDGE_CHANNEL_TRUST_ROOT: /run/nodedc-secrets/device-edge-channel/peers
|
||||||
|
DEVICE_EDGE_CHANNEL_MAX_EDGES: "32"
|
||||||
|
DEVICE_EDGE_CHANNEL_RECONCILE_INTERVAL_MS: "15000"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-private-key.pem
|
||||||
|
target: /run/nodedc-secrets/device-edge-channel/core-private-key.pem
|
||||||
|
read_only: true
|
||||||
|
bind:
|
||||||
|
create_host_path: false
|
||||||
|
- type: bind
|
||||||
|
source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-certificate.pem
|
||||||
|
target: /run/nodedc-secrets/device-edge-channel/core-certificate.pem
|
||||||
|
read_only: true
|
||||||
|
bind:
|
||||||
|
create_host_path: false
|
||||||
|
- type: bind
|
||||||
|
source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/peers
|
||||||
|
target: /run/nodedc-secrets/device-edge-channel/peers
|
||||||
|
read_only: true
|
||||||
|
bind:
|
||||||
|
create_host_path: false
|
||||||
|
networks:
|
||||||
|
- device-plane-egress
|
||||||
|
|
||||||
|
networks:
|
||||||
|
device-plane-egress:
|
||||||
|
name: nodedc-device-plane-egress
|
||||||
|
driver: bridge
|
||||||
|
internal: false
|
||||||
@@ -3,12 +3,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DEVICE_MANAGEMENT_API_ENABLED: "true"
|
DEVICE_MANAGEMENT_API_ENABLED: "true"
|
||||||
DEVICE_MANAGEMENT_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token
|
DEVICE_MANAGEMENT_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token
|
||||||
DEVICE_EDGE_CHANNEL_ENABLED: "true"
|
|
||||||
DEVICE_EDGE_CHANNEL_CORE_KEY_FILE: /run/nodedc-secrets/device-edge-channel/core-private-key.pem
|
|
||||||
DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE: /run/nodedc-secrets/device-edge-channel/core-certificate.pem
|
|
||||||
DEVICE_EDGE_CHANNEL_TRUST_ROOT: /run/nodedc-secrets/device-edge-channel/peers
|
|
||||||
DEVICE_EDGE_CHANNEL_MAX_EDGES: "32"
|
|
||||||
DEVICE_EDGE_CHANNEL_RECONCILE_INTERVAL_MS: "15000"
|
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /volume1/docker/nodedc-device-plane/secrets/management-core-token
|
source: /volume1/docker/nodedc-device-plane/secrets/management-core-token
|
||||||
@@ -16,26 +10,6 @@ services:
|
|||||||
read_only: true
|
read_only: true
|
||||||
bind:
|
bind:
|
||||||
create_host_path: false
|
create_host_path: false
|
||||||
- type: bind
|
|
||||||
source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-private-key.pem
|
|
||||||
target: /run/nodedc-secrets/device-edge-channel/core-private-key.pem
|
|
||||||
read_only: true
|
|
||||||
bind:
|
|
||||||
create_host_path: false
|
|
||||||
- type: bind
|
|
||||||
source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-certificate.pem
|
|
||||||
target: /run/nodedc-secrets/device-edge-channel/core-certificate.pem
|
|
||||||
read_only: true
|
|
||||||
bind:
|
|
||||||
create_host_path: false
|
|
||||||
- type: bind
|
|
||||||
source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/peers
|
|
||||||
target: /run/nodedc-secrets/device-edge-channel/peers
|
|
||||||
read_only: true
|
|
||||||
bind:
|
|
||||||
create_host_path: false
|
|
||||||
networks:
|
|
||||||
- device-plane-egress
|
|
||||||
|
|
||||||
device-manager:
|
device-manager:
|
||||||
image: nodedc/device-manager:local
|
image: nodedc/device-manager:local
|
||||||
@@ -97,10 +71,6 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
device-plane-egress:
|
|
||||||
name: nodedc-device-plane-egress
|
|
||||||
driver: bridge
|
|
||||||
internal: false
|
|
||||||
platform-edge:
|
platform-edge:
|
||||||
external: true
|
external: true
|
||||||
name: nodedc-platform_edge
|
name: nodedc-platform_edge
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, relative, resolve } from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(scriptDir, "../..");
|
||||||
|
const devicePlaneRoot = resolve(platformRoot, "device-plane");
|
||||||
|
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||||
|
const [patchId = "device-edge-core-channel-bootstrap-20260811-017", ...extra] = process.argv.slice(2);
|
||||||
|
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||||
|
throw new Error("usage: build-device-edge-core-channel-bootstrap-artifact.mjs [patch-id]");
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptorPath = "deployment/device-edge-core-channel-bootstrap-v1.json";
|
||||||
|
const entries = [
|
||||||
|
".dockerignore",
|
||||||
|
"package.json",
|
||||||
|
"package-lock.json",
|
||||||
|
"docker-compose.device-edge-core-channel.yml",
|
||||||
|
"packages/device-protocol-contract",
|
||||||
|
"packages/device-edge-channel-contract",
|
||||||
|
"services/device-control-core",
|
||||||
|
descriptorPath,
|
||||||
|
];
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-edge-core-channel-"));
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
const target = join(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(payload, { recursive: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry === descriptorPath) {
|
||||||
|
const descriptor = JSON.parse(await readFile(resolve(devicePlaneRoot, entry), "utf8"));
|
||||||
|
if (descriptor.transitionId !== "__PATCH_ID__") {
|
||||||
|
throw new Error("device_edge_core_channel_template_id_mismatch");
|
||||||
|
}
|
||||||
|
descriptor.transitionId = patchId;
|
||||||
|
const destination = join(payload, entry);
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await writeFile(destination, `${JSON.stringify(descriptor, null, 2)}\n`, "utf8");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await copySafe(resolve(devicePlaneRoot, entry), join(payload, entry), devicePlaneRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
await validateDockerCopySources(
|
||||||
|
join(payload, "services/device-control-core/Dockerfile"),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
for (const modulePath of [
|
||||||
|
"services/device-control-core/src/sensitive-reference-management.mjs",
|
||||||
|
"services/device-control-core/src/device-gateway-core-runtime.mjs",
|
||||||
|
"packages/device-edge-channel-contract/src/index.mjs",
|
||||||
|
]) {
|
||||||
|
const imported = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
["--input-type=module", "--eval", `import(${JSON.stringify(pathToFileURL(join(payload, modulePath)).href)})`],
|
||||||
|
{ cwd: payload, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
|
||||||
|
);
|
||||||
|
if (imported.status !== 0) {
|
||||||
|
throw new Error(`device_edge_core_channel_staged_module_import_failed:${modulePath}:${imported.stderr || imported.stdout}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compose = await readFile(join(payload, "docker-compose.device-edge-core-channel.yml"), "utf8");
|
||||||
|
for (const required of [
|
||||||
|
"device-control-core:",
|
||||||
|
"DEVICE_EDGE_CHANNEL_ENABLED: \"true\"",
|
||||||
|
"DEVICE_EDGE_CHANNEL_CORE_KEY_FILE: /run/nodedc-secrets/device-edge-channel/core-private-key.pem",
|
||||||
|
"DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE: /run/nodedc-secrets/device-edge-channel/core-certificate.pem",
|
||||||
|
"DEVICE_EDGE_CHANNEL_TRUST_ROOT: /run/nodedc-secrets/device-edge-channel/peers",
|
||||||
|
"source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-private-key.pem",
|
||||||
|
"source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-certificate.pem",
|
||||||
|
"source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/peers",
|
||||||
|
"name: nodedc-device-plane-egress",
|
||||||
|
]) {
|
||||||
|
if (!compose.includes(required)) {
|
||||||
|
throw new Error(`device_edge_core_channel_compose_contract_missing:${required}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const forbidden of [
|
||||||
|
"device-manager:",
|
||||||
|
"device-gateway:",
|
||||||
|
"device-postgres:",
|
||||||
|
"PRIVATE KEY",
|
||||||
|
"ports:",
|
||||||
|
"network_mode:",
|
||||||
|
"privileged:",
|
||||||
|
]) {
|
||||||
|
if (compose.includes(forbidden)) {
|
||||||
|
throw new Error(`device_edge_core_channel_compose_boundary_violation:${forbidden}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8"));
|
||||||
|
if (
|
||||||
|
descriptor.schemaVersion !== "nodedc.device-plane.device-edge-core-channel-bootstrap.v1"
|
||||||
|
|| descriptor.transitionId !== patchId
|
||||||
|
|| descriptor.action !== "activate"
|
||||||
|
|| descriptor.service !== "device-control-core"
|
||||||
|
|| descriptor.composeActivation !== "dedicated-additive-override"
|
||||||
|
|| descriptor.identityRecovery !== "exact-invalid-unexported-failed-predecessor-only"
|
||||||
|
|| descriptor.tlsPurpose !== "clientAuth"
|
||||||
|
|| descriptor.direction !== "core-initiated"
|
||||||
|
|| descriptor.publicIngress !== "none-on-synology"
|
||||||
|
|| descriptor.commandTransport !== "disabled"
|
||||||
|
|| descriptor.gelios !== "untouched"
|
||||||
|
) {
|
||||||
|
throw new Error("device_edge_core_channel_bootstrap_contract_mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
|
||||||
|
await mkdir(artifactDir, { recursive: true });
|
||||||
|
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 128 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||||
|
const sha256 = createHash("sha256").update(await readFile(target)).digest("hex");
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
patchId,
|
||||||
|
component: "device-plane",
|
||||||
|
artifact: target,
|
||||||
|
sha256,
|
||||||
|
entries,
|
||||||
|
services: ["device-control-core"],
|
||||||
|
preserved: ["device-manager", "device-gateway", "device-postgres", "device-backhaul-target", "Gelios"],
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySafe(source, destination, sourceBoundary) {
|
||||||
|
const sourceStat = await lstat(source);
|
||||||
|
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(sourceBoundary, source)}`);
|
||||||
|
if (sourceStat.isFile()) {
|
||||||
|
if (source.endsWith(".test.mjs") || source.endsWith(".map")) return;
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${source}`);
|
||||||
|
await mkdir(destination, { recursive: true });
|
||||||
|
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||||
|
if ([".DS_Store", ".git", "node_modules", "test"].includes(entry.name) || entry.name.startsWith(".env")) continue;
|
||||||
|
await copySafe(join(source, entry.name), join(destination, entry.name), sourceBoundary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateDockerCopySources(dockerfilePath, buildContext) {
|
||||||
|
const dockerfile = await readFile(dockerfilePath, "utf8");
|
||||||
|
for (const [index, rawLine] of dockerfile.split("\n").entries()) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!/^COPY\s+/i.test(line)) continue;
|
||||||
|
if (line.endsWith("\\") || /^COPY\s+\[/i.test(line)) {
|
||||||
|
throw new Error(`unsupported_docker_copy_syntax:${dockerfilePath}:${index + 1}`);
|
||||||
|
}
|
||||||
|
const tokens = line.split(/\s+/).slice(1);
|
||||||
|
while (tokens[0]?.startsWith("--")) tokens.shift();
|
||||||
|
if (tokens.length < 2) throw new Error(`invalid_docker_copy:${dockerfilePath}:${index + 1}`);
|
||||||
|
for (const source of tokens.slice(0, -1)) {
|
||||||
|
if (/[*?[\]{}]/.test(source)) throw new Error(`docker_copy_glob_rejected:${dockerfilePath}:${index + 1}:${source}`);
|
||||||
|
const resolvedSource = resolve(buildContext, source);
|
||||||
|
const relativeSource = relative(buildContext, resolvedSource);
|
||||||
|
if (!relativeSource || relativeSource.startsWith("..") || resolve(buildContext, relativeSource) !== resolvedSource) {
|
||||||
|
throw new Error(`docker_copy_source_outside_context:${dockerfilePath}:${index + 1}:${source}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await lstat(resolvedSource);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ENOENT") throw new Error(`docker_copy_source_missing:${dockerfilePath}:${index + 1}:${source}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTarScript() {
|
||||||
|
return [
|
||||||
|
"import gzip,io,pathlib,sys,tarfile",
|
||||||
|
"root=pathlib.Path(sys.argv[2])",
|
||||||
|
"with open(sys.argv[1],'wb') as out:",
|
||||||
|
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||||
|
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||||
|
" for top in ('manifest.env','files.txt','payload'):",
|
||||||
|
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||||
|
" for x in paths:",
|
||||||
|
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||||
|
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ const devicePlaneRoot = resolve(platformRoot, "device-plane");
|
|||||||
const designRoot = resolve(process.env.NODEDC_DEVICE_MANAGER_SOURCE_ROOT || resolve(platformRoot, "../NODEDC_DESIGN_GUIDELINE"));
|
const designRoot = resolve(process.env.NODEDC_DEVICE_MANAGER_SOURCE_ROOT || resolve(platformRoot, "../NODEDC_DESIGN_GUIDELINE"));
|
||||||
const managerRoot = resolve(designRoot, "apps/device-manager");
|
const managerRoot = resolve(designRoot, "apps/device-manager");
|
||||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||||
const [patchId = "device-manager-release-20260811-016", ...extra] = process.argv.slice(2);
|
const [patchId = "device-manager-release-20260811-018", ...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]");
|
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 = [
|
const entries = [
|
||||||
@@ -27,7 +27,7 @@ const entries = [
|
|||||||
"services/device-gateway/package.json",
|
"services/device-gateway/package.json",
|
||||||
"services/device-edge-relay/package.json",
|
"services/device-edge-relay/package.json",
|
||||||
"services/device-manager",
|
"services/device-manager",
|
||||||
"deployment/device-manager-release-v2.json",
|
"deployment/device-manager-release-v1.json",
|
||||||
];
|
];
|
||||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-control-plane-"));
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-control-plane-"));
|
||||||
const payload = join(stage, "payload");
|
const payload = join(stage, "payload");
|
||||||
@@ -41,7 +41,7 @@ try {
|
|||||||
if (build.status !== 0) throw new Error(`device_manager_build_failed:${build.stderr || build.stdout}`);
|
if (build.status !== 0) throw new Error(`device_manager_build_failed:${build.stderr || build.stdout}`);
|
||||||
await mkdir(payload, { recursive: true });
|
await mkdir(payload, { recursive: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry === "deployment/device-manager-release-v2.json") {
|
if (entry === "deployment/device-manager-release-v1.json") {
|
||||||
const descriptor = JSON.parse(await readFile(resolve(devicePlaneRoot, entry), "utf8"));
|
const descriptor = JSON.parse(await readFile(resolve(devicePlaneRoot, entry), "utf8"));
|
||||||
if (descriptor.releaseId !== "__PATCH_ID__") {
|
if (descriptor.releaseId !== "__PATCH_ID__") {
|
||||||
throw new Error("device_manager_release_template_id_mismatch");
|
throw new Error("device_manager_release_template_id_mismatch");
|
||||||
@@ -100,20 +100,15 @@ try {
|
|||||||
"DEVICE_MANAGEMENT_API_ENABLED: \"true\"",
|
"DEVICE_MANAGEMENT_API_ENABLED: \"true\"",
|
||||||
"NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token",
|
"NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token",
|
||||||
"NODEDC_DEVICE_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token",
|
"NODEDC_DEVICE_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token",
|
||||||
"DEVICE_EDGE_CHANNEL_ENABLED: \"true\"",
|
|
||||||
"DEVICE_EDGE_CHANNEL_CORE_KEY_FILE: /run/nodedc-secrets/device-edge-channel/core-private-key.pem",
|
|
||||||
"DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE: /run/nodedc-secrets/device-edge-channel/core-certificate.pem",
|
|
||||||
"DEVICE_EDGE_CHANNEL_TRUST_ROOT: /run/nodedc-secrets/device-edge-channel/peers",
|
|
||||||
"source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-private-key.pem",
|
|
||||||
"source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-certificate.pem",
|
|
||||||
"source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/peers",
|
|
||||||
"name: nodedc-device-plane-egress",
|
|
||||||
"name: nodedc-platform_edge",
|
"name: nodedc-platform_edge",
|
||||||
]) if (!compose.includes(required)) throw new Error(`device_manager_compose_contract_missing:${required}`);
|
]) if (!compose.includes(required)) throw new Error(`device_manager_compose_contract_missing:${required}`);
|
||||||
for (const forbidden of [
|
for (const forbidden of [
|
||||||
"NODEDC_INTERNAL_ACCESS_TOKEN:",
|
"NODEDC_INTERNAL_ACCESS_TOKEN:",
|
||||||
"NODEDC_PLATFORM_SERVICE_TOKEN:",
|
"NODEDC_PLATFORM_SERVICE_TOKEN:",
|
||||||
"PRIVATE KEY",
|
"PRIVATE KEY",
|
||||||
|
"DEVICE_EDGE_CHANNEL_",
|
||||||
|
"device-edge-channel/",
|
||||||
|
"nodedc-device-plane-egress",
|
||||||
"0.0.0.0:18122",
|
"0.0.0.0:18122",
|
||||||
"0.0.0.0:9921:9921",
|
"0.0.0.0:9921:9921",
|
||||||
"- \"9921:9921\"",
|
"- \"9921:9921\"",
|
||||||
@@ -121,12 +116,12 @@ try {
|
|||||||
if (compose.includes(forbidden)) throw new Error(`device_manager_compose_boundary_violation:${forbidden}`);
|
if (compose.includes(forbidden)) throw new Error(`device_manager_compose_boundary_violation:${forbidden}`);
|
||||||
}
|
}
|
||||||
const descriptor = JSON.parse(await readFile(
|
const descriptor = JSON.parse(await readFile(
|
||||||
join(payload, "deployment/device-manager-release-v2.json"),
|
join(payload, "deployment/device-manager-release-v1.json"),
|
||||||
"utf8",
|
"utf8",
|
||||||
));
|
));
|
||||||
const predecessor = descriptor.predecessor;
|
const predecessor = descriptor.predecessor;
|
||||||
if (
|
if (
|
||||||
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v2"
|
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v1"
|
||||||
|| descriptor.releaseId !== patchId
|
|| descriptor.releaseId !== patchId
|
||||||
|| !["activate", "upgrade"].includes(descriptor.action)
|
|| !["activate", "upgrade"].includes(descriptor.action)
|
||||||
|| !predecessor
|
|| !predecessor
|
||||||
@@ -134,9 +129,6 @@ try {
|
|||||||
|| !/^[A-Za-z0-9._-]{1,96}$/.test(predecessor.patchId || "")
|
|| !/^[A-Za-z0-9._-]{1,96}$/.test(predecessor.patchId || "")
|
||||||
|| !/^[a-f0-9]{64}$/.test(predecessor.artifactSha256 || "")
|
|| !/^[a-f0-9]{64}$/.test(predecessor.artifactSha256 || "")
|
||||||
|| (descriptor.action === "activate") !== (predecessor.kind === "reconciliation")
|
|| (descriptor.action === "activate") !== (predecessor.kind === "reconciliation")
|
||||||
|| descriptor.edgeChannel !== "core-initiated-pinned-mtls-enabled-zero-or-more-registered-edges"
|
|
||||||
|| descriptor.edgeChannelIdentity !== "runner-managed-host-local-private-key-public-certificate-export"
|
|
||||||
|| descriptor.edgeChannelEgress !== "dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-8443-registration-policy"
|
|
||||||
|| descriptor.healthGate !== "bounded-container-grace+core-contract"
|
|| descriptor.healthGate !== "bounded-container-grace+core-contract"
|
||||||
|| descriptor.rollback !== "restore-preapply-snapshot"
|
|| descriptor.rollback !== "restore-preapply-snapshot"
|
||||||
) throw new Error("device_manager_activation_successor_contract_mismatch");
|
) throw new Error("device_manager_activation_successor_contract_mismatch");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||||
|
|
||||||
|
|
||||||
|
def load_runner():
|
||||||
|
loader = importlib.machinery.SourceFileLoader(
|
||||||
|
"nodedc_device_edge_core_channel_deploy_under_test",
|
||||||
|
str(RUNNER_PATH),
|
||||||
|
)
|
||||||
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
RUNNER = load_runner()
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceEdgeCoreChannelBootstrapTest(unittest.TestCase):
|
||||||
|
def test_identity_generation_ignores_synology_global_ca_extensions(self):
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix="nodedc-device-edge-openssl-",
|
||||||
|
) as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
malicious = root / "synology-openssl.cnf"
|
||||||
|
malicious.write_text(
|
||||||
|
"""[ req ]
|
||||||
|
prompt = no
|
||||||
|
distinguished_name = dn
|
||||||
|
x509_extensions = v3_ca
|
||||||
|
|
||||||
|
[ dn ]
|
||||||
|
CN = synology-global-default
|
||||||
|
|
||||||
|
[ v3_ca ]
|
||||||
|
basicConstraints = critical,CA:TRUE
|
||||||
|
keyUsage = critical,keyCertSign,cRLSign
|
||||||
|
""",
|
||||||
|
encoding="ascii",
|
||||||
|
)
|
||||||
|
private_key = root / "core-private-key.pem"
|
||||||
|
certificate = root / "core-certificate.pem"
|
||||||
|
with (
|
||||||
|
mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"OPENSSL_CONF": str(malicious)},
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"resolve_openssl_binary",
|
||||||
|
return_value=Path(shutil.which("openssl")),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
RUNNER.generate_device_edge_channel_core_identity(
|
||||||
|
private_key,
|
||||||
|
certificate,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
RUNNER.validate_device_edge_channel_certificate_extensions(
|
||||||
|
certificate
|
||||||
|
),
|
||||||
|
"exact-clientAuth",
|
||||||
|
)
|
||||||
|
text = RUNNER.device_edge_channel_certificate_text(certificate)
|
||||||
|
self.assertEqual(
|
||||||
|
text.count("X509v3 Basic Constraints: critical"),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
self.assertIn("CA:FALSE", text)
|
||||||
|
self.assertNotIn("CA:TRUE", text)
|
||||||
|
RUNNER.run_openssl(
|
||||||
|
[
|
||||||
|
"verify",
|
||||||
|
"-purpose",
|
||||||
|
"sslclient",
|
||||||
|
"-CAfile",
|
||||||
|
str(certificate),
|
||||||
|
str(certificate),
|
||||||
|
],
|
||||||
|
"unit Device Edge client certificate",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bootstrap_acceptance_is_core_only_and_preserves_manager(self):
|
||||||
|
entries = RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_ENTRIES
|
||||||
|
services = ("device-control-core",)
|
||||||
|
with (
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"healthcheck_compose_service_with_grace",
|
||||||
|
) as service_health,
|
||||||
|
mock.patch.object(RUNNER, "healthcheck_url") as url_health,
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"validate_device_manager_control_plane_runtime",
|
||||||
|
) as runtime_acceptance,
|
||||||
|
):
|
||||||
|
RUNNER.run_healthchecks("device-plane", entries, services)
|
||||||
|
self.assertEqual(
|
||||||
|
[call.args for call in service_health.call_args_list],
|
||||||
|
[
|
||||||
|
("device-plane", "device-control-core"),
|
||||||
|
("device-plane", "device-manager"),
|
||||||
|
("device-plane", "device-gateway"),
|
||||||
|
("device-plane", "device-postgres"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
url_health.assert_called_once_with(
|
||||||
|
RUNNER.component_healthchecks(
|
||||||
|
"device-plane",
|
||||||
|
entries,
|
||||||
|
services,
|
||||||
|
)[0]
|
||||||
|
)
|
||||||
|
runtime_acceptance.assert_called_once_with(
|
||||||
|
require_edge_channel=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_exact_failed_016_recovery_is_required_for_replacement(self):
|
||||||
|
with (
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"device_edge_channel_invalid_identity_is_exact_recoverable",
|
||||||
|
return_value=False,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
RUNNER.DeployError,
|
||||||
|
"does not match the exact unexported failed-016",
|
||||||
|
):
|
||||||
|
RUNNER.recover_invalid_device_edge_channel_core_identity()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -185,7 +185,7 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
manifest, entries, names, result = self.assert_deterministic_artifact(
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
"build-device-manager-control-plane-artifact.mjs",
|
"build-device-manager-control-plane-artifact.mjs",
|
||||||
"device-manager-control-plane-unit-001",
|
"device-manager-control-plane-unit-001",
|
||||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES,
|
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES,
|
||||||
)
|
)
|
||||||
self.assertEqual(manifest["component"], "device-plane")
|
self.assertEqual(manifest["component"], "device-plane")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -242,19 +242,53 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
compose = (
|
compose = (
|
||||||
PLATFORM_ROOT / "device-plane/docker-compose.device-manager.yml"
|
PLATFORM_ROOT / "device-plane/docker-compose.device-manager.yml"
|
||||||
).read_text(encoding="utf-8")
|
).read_text(encoding="utf-8")
|
||||||
for required in (
|
for forbidden in (
|
||||||
'DEVICE_EDGE_CHANNEL_ENABLED: "true"',
|
"DEVICE_EDGE_CHANNEL_",
|
||||||
"DEVICE_EDGE_CHANNEL_CORE_KEY_FILE: ",
|
"device-edge-channel/",
|
||||||
"DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE: ",
|
|
||||||
"DEVICE_EDGE_CHANNEL_TRUST_ROOT: ",
|
|
||||||
"name: nodedc-device-plane-egress",
|
"name: nodedc-device-plane-egress",
|
||||||
):
|
):
|
||||||
self.assertIn(required, compose)
|
self.assertNotIn(forbidden, compose)
|
||||||
self.assertNotIn("PRIVATE KEY", compose)
|
self.assertNotIn("PRIVATE KEY", compose)
|
||||||
checks = RUNNER.component_healthchecks("device-plane", entries, tuple(result["services"]))
|
checks = RUNNER.component_healthchecks("device-plane", entries, tuple(result["services"]))
|
||||||
self.assertEqual(checks[0]["expected_json"]["managementApi"], "enabled")
|
self.assertEqual(checks[0]["expected_json"]["managementApi"], "enabled")
|
||||||
self.assertEqual(checks[0]["expected_json"]["discoveryIngest"], "enabled")
|
self.assertEqual(checks[0]["expected_json"]["discoveryIngest"], "enabled")
|
||||||
|
|
||||||
|
def test_edge_core_channel_bootstrap_is_core_only_and_secret_free(self):
|
||||||
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
|
"build-device-edge-core-channel-bootstrap-artifact.mjs",
|
||||||
|
"device-edge-core-channel-bootstrap-unit-001",
|
||||||
|
RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_ENTRIES,
|
||||||
|
)
|
||||||
|
self.assertEqual(manifest["component"], "device-plane")
|
||||||
|
self.assertEqual(
|
||||||
|
RUNNER.component_services("device-plane", entries),
|
||||||
|
("device-control-core",),
|
||||||
|
)
|
||||||
|
builds = RUNNER.component_builds("device-plane", entries)
|
||||||
|
self.assertEqual(len(builds), 1)
|
||||||
|
self.assertIn(RUNNER.DEVICE_PLANE_CONTROL_CORE_IMAGE, builds[0][1])
|
||||||
|
self.assertEqual(result["services"], ["device-control-core"])
|
||||||
|
self.assertFalse(any(
|
||||||
|
name.startswith("payload/services/device-manager/")
|
||||||
|
or name.startswith("payload/services/device-gateway/")
|
||||||
|
for name in names
|
||||||
|
))
|
||||||
|
self.assertIn(
|
||||||
|
"payload/packages/device-edge-channel-contract/src/index.mjs",
|
||||||
|
names,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"payload/docker-compose.device-edge-core-channel.yml",
|
||||||
|
names,
|
||||||
|
)
|
||||||
|
checks = RUNNER.component_healthchecks(
|
||||||
|
"device-plane",
|
||||||
|
entries,
|
||||||
|
tuple(result["services"]),
|
||||||
|
)
|
||||||
|
self.assertEqual(checks[0]["expected_json"]["managementApi"], "enabled")
|
||||||
|
self.assertEqual(checks[0]["expected_json"]["commandTransport"], "disabled")
|
||||||
|
|
||||||
def test_release_v2_keeps_release_v1_predecessor_contract_immutable(self):
|
def test_release_v2_keeps_release_v1_predecessor_contract_immutable(self):
|
||||||
predecessor = device_manager_release_v1_descriptor(
|
predecessor = device_manager_release_v1_descriptor(
|
||||||
release_id="device-manager-release-20260811-010",
|
release_id="device-manager-release-20260811-010",
|
||||||
@@ -417,7 +451,7 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
RUNNER.prepare_component_runtime(
|
RUNNER.prepare_component_runtime(
|
||||||
"device-plane",
|
"device-plane",
|
||||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES,
|
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES,
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[call.args[0] for call in ensure.call_args_list],
|
[call.args[0] for call in ensure.call_args_list],
|
||||||
@@ -429,10 +463,37 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
RUNNER.PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE,
|
RUNNER.PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
ensure_edge_identity.assert_called_once_with()
|
ensure_edge_identity.assert_not_called()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"ensure_platform_runtime_secret",
|
||||||
|
) as ensure,
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"ensure_device_edge_channel_core_identity",
|
||||||
|
) as ensure_edge_identity,
|
||||||
|
):
|
||||||
|
RUNNER.prepare_component_runtime(
|
||||||
|
"device-plane",
|
||||||
|
RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_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,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
ensure_edge_identity.assert_called_once_with(
|
||||||
|
allow_invalid_unexported_recovery=True
|
||||||
|
)
|
||||||
|
|
||||||
def test_apply_gate_checks_exact_services_core_contract_and_runtime_boundary(self):
|
def test_apply_gate_checks_exact_services_core_contract_and_runtime_boundary(self):
|
||||||
entries = RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES
|
entries = RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES
|
||||||
services = ("device-control-core", "device-manager")
|
services = ("device-control-core", "device-manager")
|
||||||
with (
|
with (
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
|
|||||||
Reference in New Issue
Block a user