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:
|
||||
DEVICE_MANAGEMENT_API_ENABLED: "true"
|
||||
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:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/management-core-token
|
||||
@@ -16,26 +10,6 @@ services:
|
||||
read_only: true
|
||||
bind:
|
||||
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:
|
||||
image: nodedc/device-manager:local
|
||||
@@ -97,10 +71,6 @@ services:
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-plane-egress:
|
||||
name: nodedc-device-plane-egress
|
||||
driver: bridge
|
||||
internal: false
|
||||
platform-edge:
|
||||
external: true
|
||||
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 managerRoot = resolve(designRoot, "apps/device-manager");
|
||||
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]");
|
||||
|
||||
const entries = [
|
||||
@@ -27,7 +27,7 @@ const entries = [
|
||||
"services/device-gateway/package.json",
|
||||
"services/device-edge-relay/package.json",
|
||||
"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 payload = join(stage, "payload");
|
||||
@@ -41,7 +41,7 @@ try {
|
||||
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 === "deployment/device-manager-release-v2.json") {
|
||||
if (entry === "deployment/device-manager-release-v1.json") {
|
||||
const descriptor = JSON.parse(await readFile(resolve(devicePlaneRoot, entry), "utf8"));
|
||||
if (descriptor.releaseId !== "__PATCH_ID__") {
|
||||
throw new Error("device_manager_release_template_id_mismatch");
|
||||
@@ -100,20 +100,15 @@ try {
|
||||
"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",
|
||||
"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",
|
||||
]) 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:",
|
||||
"PRIVATE KEY",
|
||||
"DEVICE_EDGE_CHANNEL_",
|
||||
"device-edge-channel/",
|
||||
"nodedc-device-plane-egress",
|
||||
"0.0.0.0:18122",
|
||||
"0.0.0.0:9921:9921",
|
||||
"- \"9921:9921\"",
|
||||
@@ -121,12 +116,12 @@ try {
|
||||
if (compose.includes(forbidden)) throw new Error(`device_manager_compose_boundary_violation:${forbidden}`);
|
||||
}
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
join(payload, "deployment/device-manager-release-v2.json"),
|
||||
join(payload, "deployment/device-manager-release-v1.json"),
|
||||
"utf8",
|
||||
));
|
||||
const predecessor = descriptor.predecessor;
|
||||
if (
|
||||
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v2"
|
||||
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v1"
|
||||
|| descriptor.releaseId !== patchId
|
||||
|| !["activate", "upgrade"].includes(descriptor.action)
|
||||
|| !predecessor
|
||||
@@ -134,9 +129,6 @@ try {
|
||||
|| !/^[A-Za-z0-9._-]{1,96}$/.test(predecessor.patchId || "")
|
||||
|| !/^[a-f0-9]{64}$/.test(predecessor.artifactSha256 || "")
|
||||
|| (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.rollback !== "restore-preapply-snapshot"
|
||||
) 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(
|
||||
"build-device-manager-control-plane-artifact.mjs",
|
||||
"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(
|
||||
@@ -242,19 +242,53 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
compose = (
|
||||
PLATFORM_ROOT / "device-plane/docker-compose.device-manager.yml"
|
||||
).read_text(encoding="utf-8")
|
||||
for required in (
|
||||
'DEVICE_EDGE_CHANNEL_ENABLED: "true"',
|
||||
"DEVICE_EDGE_CHANNEL_CORE_KEY_FILE: ",
|
||||
"DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE: ",
|
||||
"DEVICE_EDGE_CHANNEL_TRUST_ROOT: ",
|
||||
for forbidden in (
|
||||
"DEVICE_EDGE_CHANNEL_",
|
||||
"device-edge-channel/",
|
||||
"name: nodedc-device-plane-egress",
|
||||
):
|
||||
self.assertIn(required, compose)
|
||||
self.assertNotIn(forbidden, compose)
|
||||
self.assertNotIn("PRIVATE KEY", compose)
|
||||
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_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):
|
||||
predecessor = device_manager_release_v1_descriptor(
|
||||
release_id="device-manager-release-20260811-010",
|
||||
@@ -417,7 +451,7 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
):
|
||||
RUNNER.prepare_component_runtime(
|
||||
"device-plane",
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES,
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES,
|
||||
)
|
||||
self.assertEqual(
|
||||
[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,
|
||||
],
|
||||
)
|
||||
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):
|
||||
entries = RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES
|
||||
entries = RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES
|
||||
services = ("device-control-core", "device-manager")
|
||||
with (
|
||||
mock.patch.object(
|
||||
|
||||
Reference in New Issue
Block a user