From d641744dca8170622e5cecab627e312244206b62 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 11 Aug 2026 23:27:16 +0300 Subject: [PATCH] fix(device-plane): split edge channel bootstrap --- ...device-edge-core-channel-bootstrap-v1.json | 33 + ...ocker-compose.device-edge-core-channel.yml | 36 + .../docker-compose.device-manager.yml | 30 - ...e-edge-core-channel-bootstrap-artifact.mjs | 197 ++++ ...-device-manager-control-plane-artifact.mjs | 24 +- infra/deploy-runner/nodedc-deploy | 865 ++++++++++++++++-- ...test_device_edge_core_channel_bootstrap.py | 145 +++ ..._device_manager_control_plane_artifacts.py | 81 +- 8 files changed, 1298 insertions(+), 113 deletions(-) create mode 100644 device-plane/deployment/device-edge-core-channel-bootstrap-v1.json create mode 100644 device-plane/docker-compose.device-edge-core-channel.yml create mode 100644 infra/deploy-runner/build-device-edge-core-channel-bootstrap-artifact.mjs create mode 100644 infra/deploy-runner/test_device_edge_core_channel_bootstrap.py diff --git a/device-plane/deployment/device-edge-core-channel-bootstrap-v1.json b/device-plane/deployment/device-edge-core-channel-bootstrap-v1.json new file mode 100644 index 0000000..16d5f78 --- /dev/null +++ b/device-plane/deployment/device-edge-core-channel-bootstrap-v1.json @@ -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" +} diff --git a/device-plane/docker-compose.device-edge-core-channel.yml b/device-plane/docker-compose.device-edge-core-channel.yml new file mode 100644 index 0000000..eb12eb4 --- /dev/null +++ b/device-plane/docker-compose.device-edge-core-channel.yml @@ -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 diff --git a/device-plane/docker-compose.device-manager.yml b/device-plane/docker-compose.device-manager.yml index 8597abd..f62052a 100644 --- a/device-plane/docker-compose.device-manager.yml +++ b/device-plane/docker-compose.device-manager.yml @@ -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 diff --git a/infra/deploy-runner/build-device-edge-core-channel-bootstrap-artifact.mjs b/infra/deploy-runner/build-device-edge-core-channel-bootstrap-artifact.mjs new file mode 100644 index 0000000..9b58028 --- /dev/null +++ b/infra/deploy-runner/build-device-edge-core-channel-bootstrap-artifact.mjs @@ -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"); +} diff --git a/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs b/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs index 0a1c62e..f1ff665 100644 --- a/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs +++ b/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs @@ -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"); diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index 4816cb6..c4c52ef 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -84,6 +84,9 @@ DEVICE_PLANE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE = ( DEVICE_PLANE_EDGE_CHANNEL_PEER_TRUST_DIR = ( DEVICE_PLANE_EDGE_CHANNEL_SECRET_DIR / "peers" ) +DEVICE_PLANE_EDGE_CHANNEL_RECOVERY_DIR = ( + DEVICE_PLANE_EDGE_CHANNEL_SECRET_DIR / "recovery" +) DEVICE_PLANE_EDGE_CHANNEL_EXPORT_DIR = ( DEVICE_PLANE_ROOT / "enrollment" / "device-edge-channel" ) @@ -244,6 +247,48 @@ DEVICE_PLANE_MANAGER_RELEASE_V2_REL = ( "deployment/device-manager-release-v2.json" ) DEVICE_PLANE_MANAGER_COMPOSE_REL = "docker-compose.device-manager.yml" +DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL = ( + "deployment/device-edge-core-channel-bootstrap-v1.json" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL = ( + "docker-compose.device-edge-core-channel.yml" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_SHA256 = ( + "cac1e06b21202d8d96f5694b1adb1e67c0a3cdd31fcfca56cb7e19839c8516d8" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_ENTRIES = ( + ".dockerignore", + "package.json", + "package-lock.json", + DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL, + "packages/device-protocol-contract", + "packages/device-edge-channel-contract", + "services/device-control-core", + DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL, +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_MANAGER_PREDECESSOR_PATCH_ID = ( + "device-manager-release-20260811-010" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_MANAGER_PREDECESSOR_ARTIFACT_SHA256 = ( + "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_FAILED_PATCH_ID = ( + "device-manager-release-20260811-016" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_FAILED_ARTIFACT_SHA256 = ( + "590405821b95b54088f926e0d3b2cdf9c704b339f6749da500c2bb64fe0e952d" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_FAILED_ARTIFACT = ( + "nodedc-device-plane-device-manager-release-20260811-016.tgz." + "20260811-215941" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_FAILED_BACKUP_ID = ( + "device-plane-device-manager-release-20260811-016-20260811-215941" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_INVALID_CERTIFICATE_FINGERPRINT = ( + "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" +) DEVICE_PLANE_MANAGER_RELEASE_V1_COMPOSE_SHA256 = ( "4954120aaddc999798b64c304d8cf692b79714feb727d873117bd1f3434e865e" ) @@ -292,6 +337,20 @@ DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES = ( "services/device-manager", DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, ) +DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES = ( + ".dockerignore", + "package.json", + "package-lock.json", + DEVICE_PLANE_MANAGER_COMPOSE_REL, + "packages/device-protocol-contract", + "packages/device-edge-channel-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_MANAGER_RELEASE_V2_ENTRIES = ( ".dockerignore", "package.json", @@ -2129,11 +2188,19 @@ def run_openssl(arguments, label): check=False, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, timeout=30, ) if result.returncode != 0: - die(f"openssl {label} failed") + detail = re.sub( + r"[^A-Za-z0-9 ._:/()\[\],+-]", + "?", + result.stderr.decode("utf-8", errors="replace").strip(), + )[:320] + die( + f"openssl {label} failed" + + (f": {detail}" if detail else "") + ) def capture_openssl(arguments, label): @@ -2142,11 +2209,19 @@ def capture_openssl(arguments, label): check=False, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, timeout=30, ) if result.returncode != 0: - die(f"openssl {label} failed") + detail = re.sub( + r"[^A-Za-z0-9 ._:/()\[\],+-]", + "?", + result.stderr.decode("utf-8", errors="replace").strip(), + )[:320] + die( + f"openssl {label} failed" + + (f": {detail}" if detail else "") + ) return result.stdout @@ -2215,7 +2290,151 @@ def install_public_runtime_export(source, destination, mode, label): temporary.unlink() -def ensure_device_edge_channel_core_identity(): +def generate_device_edge_channel_core_identity(private_key, certificate): + # Never inherit Synology's global /etc/ssl/openssl.cnf. DSM currently + # points req.x509_extensions at v3_ca, which otherwise adds CA:TRUE before + # our workload certificate extensions and produces a conflicting cert. + config = private_key.parent / ( + f".device-edge-core-openssl.{os.getpid()}.{time.time_ns()}.cnf" + ) + config_text = """[ req ] +prompt = no +distinguished_name = device_edge_core_dn +x509_extensions = device_edge_core_client + +[ device_edge_core_dn ] +CN = nodedc-device-control-core + +[ device_edge_core_client ] +basicConstraints = critical,CA:FALSE +keyUsage = critical,digitalSignature +extendedKeyUsage = clientAuth +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid,issuer +""" + try: + descriptor = os.open( + str(config), + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + ) + try: + os.write(descriptor, config_text.encode("ascii")) + os.fsync(descriptor) + finally: + os.close(descriptor) + run_openssl([ + "req", "-x509", "-newkey", "ed25519", "-nodes", + "-days", "3650", + "-config", str(config), + "-extensions", "device_edge_core_client", + "-keyout", str(private_key), + "-out", str(certificate), + ], "Device Edge channel Core identity generation") + finally: + if config.exists(): + config.unlink() + + +def device_edge_channel_certificate_text(path): + return capture_openssl( + ["x509", "-in", str(path), "-noout", "-text"], + "Device Edge channel certificate extension inspection", + ).decode("utf-8", errors="strict") + + +def validate_device_edge_channel_certificate_extensions(path): + text = device_edge_channel_certificate_text(path) + required = ( + ("X509v3 Basic Constraints: critical", "CA:FALSE"), + ("X509v3 Key Usage: critical", "Digital Signature"), + ("X509v3 Extended Key Usage:", "TLS Web Client Authentication"), + ) + for heading, value in required: + if text.count(heading) != 1 or text.count(value) != 1: + die("Device Edge channel Core certificate extension mismatch") + if "CA:TRUE" in text or "Certificate Sign" in text: + die("Device Edge channel Core certificate CA capability is forbidden") + return "exact-clientAuth" + + +def device_edge_channel_invalid_identity_is_exact_recoverable(): + private_key = DEVICE_PLANE_EDGE_CHANNEL_CORE_PRIVATE_KEY_FILE + certificate = DEVICE_PLANE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE + if not private_key.is_file() or not certificate.is_file(): + return False + if ( + device_edge_channel_certificate_fingerprint(certificate) + != DEVICE_PLANE_EDGE_CORE_CHANNEL_INVALID_CERTIFICATE_FINGERPRINT + ): + return False + text = device_edge_channel_certificate_text(certificate) + if ( + text.count("X509v3 Basic Constraints: critical") != 2 + or "CA:TRUE" not in text + or "CA:FALSE" not in text + ): + return False + if ( + DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_CERTIFICATE_FILE.exists() + or DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_CERTIFICATE_FILE.is_symlink() + or DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_FINGERPRINT_FILE.exists() + or DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_FINGERPRINT_FILE.is_symlink() + ): + return False + if any(DEVICE_PLANE_EDGE_CHANNEL_PEER_TRUST_DIR.iterdir()): + return False + certificate_public = capture_openssl( + ["x509", "-in", str(certificate), "-pubkey", "-noout"], + "invalid Device Edge certificate public key", + ) + private_public = capture_openssl( + ["pkey", "-in", str(private_key), "-pubout"], + "invalid Device Edge private key public derivation", + ) + return certificate_public == private_public + + +def recover_invalid_device_edge_channel_core_identity(): + if not device_edge_channel_invalid_identity_is_exact_recoverable(): + die( + "Device Edge channel Core identity is invalid but does not match " + "the exact unexported failed-016 recovery boundary" + ) + recovery = ( + DEVICE_PLANE_EDGE_CHANNEL_RECOVERY_DIR + / DEVICE_PLANE_EDGE_CORE_CHANNEL_FAILED_PATCH_ID + ) + if recovery.exists() or recovery.is_symlink(): + die("Device Edge channel Core identity recovery destination exists") + DEVICE_PLANE_EDGE_CHANNEL_RECOVERY_DIR.mkdir(mode=0o700, exist_ok=False) + os.chown(DEVICE_PLANE_EDGE_CHANNEL_RECOVERY_DIR, 0, 0) + recovery.mkdir(mode=0o700) + os.chown(recovery, 0, 0) + private_destination = recovery / "core-private-key.invalid.pem" + certificate_destination = recovery / "core-certificate.invalid.pem" + os.replace( + DEVICE_PLANE_EDGE_CHANNEL_CORE_PRIVATE_KEY_FILE, + private_destination, + ) + os.replace( + DEVICE_PLANE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE, + certificate_destination, + ) + os.chown(private_destination, 0, 0) + private_destination.chmod(0o600) + os.chown(certificate_destination, 0, 0) + certificate_destination.chmod(0o600) + fsync_directory(recovery) + fsync_directory(DEVICE_PLANE_EDGE_CHANNEL_RECOVERY_DIR) + fsync_directory(DEVICE_PLANE_EDGE_CHANNEL_SECRET_DIR) + return "failed-016-invalid-unexported-quarantined" + + +def ensure_device_edge_channel_core_identity( + *, + allow_invalid_unexported_recovery=False, +): # The Core private key is born on Synology and never enters a deployment # artifact, Compose environment, Ops, or runner output. Only the matching # public certificate and fingerprint are exported for the explicit VPS @@ -2237,6 +2456,14 @@ def ensure_device_edge_channel_core_identity(): if private_exists != certificate_exists: die("Device Edge channel Core identity is incomplete") + recovered = False + if private_exists and allow_invalid_unexported_recovery: + if device_edge_channel_invalid_identity_is_exact_recoverable(): + recover_invalid_device_edge_channel_core_identity() + private_exists = False + certificate_exists = False + recovered = True + created = False if not private_exists: private_tmp = private_key.with_name( @@ -2246,16 +2473,10 @@ def ensure_device_edge_channel_core_identity(): f".{certificate.name}.{os.getpid()}.{time.time_ns()}.tmp" ) try: - run_openssl([ - "req", "-x509", "-newkey", "ed25519", "-nodes", - "-days", "3650", - "-subj", "/CN=nodedc-device-control-core", - "-addext", "basicConstraints=critical,CA:FALSE", - "-addext", "keyUsage=critical,digitalSignature", - "-addext", "extendedKeyUsage=clientAuth", - "-keyout", str(private_tmp), - "-out", str(certificate_tmp), - ], "Device Edge channel Core identity generation") + generate_device_edge_channel_core_identity( + private_tmp, + certificate_tmp, + ) os.chown(private_tmp, 0, MAP_GATEWAY_RUNTIME_GID) private_tmp.chmod(0o640) os.chown(certificate_tmp, 0, MAP_GATEWAY_RUNTIME_GID) @@ -2298,6 +2519,7 @@ def ensure_device_edge_channel_core_identity(): "Device Edge channel Core private key validation") run_openssl(["x509", "-in", str(certificate), "-noout", "-checkend", "604800"], "Device Edge channel Core certificate lifetime validation") + validate_device_edge_channel_certificate_extensions(certificate) run_openssl([ "verify", "-purpose", "sslclient", "-CAfile", str(certificate), str(certificate), @@ -2341,9 +2563,51 @@ def ensure_device_edge_channel_core_identity(): finally: if fingerprint_tmp.exists(): fingerprint_tmp.unlink() + if recovered: + return "recovered+created" return "created" if created else "reused" +def validate_device_edge_channel_public_export(): + certificate = DEVICE_PLANE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE + exported_certificate = ( + DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_CERTIFICATE_FILE + ) + exported_fingerprint = ( + DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_FINGERPRINT_FILE + ) + for path, label in ( + (exported_certificate, "certificate"), + (exported_fingerprint, "fingerprint"), + ): + try: + path_stat = path.lstat() + except FileNotFoundError: + die(f"Device Edge channel public {label} export is missing") + if ( + stat.S_ISLNK(path_stat.st_mode) + or not stat.S_ISREG(path_stat.st_mode) + or path_stat.st_uid != 0 + or path_stat.st_gid != MAP_GATEWAY_RUNTIME_GID + or stat.S_IMODE(path_stat.st_mode) != 0o444 + or path_stat.st_size < 32 + or path_stat.st_size > 32 * 1024 + ): + die(f"Device Edge channel public {label} export is unsafe") + certificate_bytes = certificate.read_bytes() + exported_bytes = exported_certificate.read_bytes() + if b"PRIVATE KEY" in exported_bytes or exported_bytes != certificate_bytes: + die("Device Edge channel public certificate export mismatch") + fingerprint = device_edge_channel_certificate_fingerprint(certificate) + try: + fingerprint_text = exported_fingerprint.read_text(encoding="ascii") + except (OSError, UnicodeDecodeError): + die("Device Edge channel public fingerprint export is unreadable") + if fingerprint_text != f"SHA256={fingerprint}\n": + die("Device Edge channel public fingerprint export mismatch") + return "exact-public-only" + + def validate_ed25519_public_key_file(path): try: value = path.read_text(encoding="ascii") @@ -3519,6 +3783,7 @@ def allowed_payload_path(component, rel): "package-lock.json", "docker-compose.device-plane.yml", DEVICE_PLANE_MANAGER_COMPOSE_REL, + DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL, DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL, DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL, DEVICE_PLANE_FOUNDATION_RECOVERY_REL, @@ -3529,6 +3794,7 @@ def allowed_payload_path(component, rel): DEVICE_PLANE_MANAGER_FAILED_CONTROL_PLANE_REL, DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, DEVICE_PLANE_MANAGER_RELEASE_V2_REL, + DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL, DEVICE_PLANE_MANAGER_RECONCILIATION_REL, DEVICE_PLANE_MANAGER_V2_RECONCILIATION_REL, "packages/device-protocol-contract", @@ -8581,6 +8847,14 @@ def load_artifact(artifact, work_dir): payload_dir, expected_release_id=manifest["id"], ) + if is_device_plane_edge_core_channel_bootstrap_slice( + manifest["component"], + entries, + ): + validate_device_plane_edge_core_channel_bootstrap_payload( + payload_dir, + expected_transition_id=manifest["id"], + ) if is_device_plane_manager_failed_control_plane_slice( manifest["component"], entries, @@ -9051,6 +9325,7 @@ def is_device_plane_manager_control_plane_slice(component, entries): and entries is not None and tuple(entries) in ( DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES, + DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES, ) ) @@ -9064,6 +9339,15 @@ def is_device_plane_manager_release_v2_slice(component, entries): ) +def is_device_plane_edge_core_channel_bootstrap_slice(component, entries): + return ( + component == "device-plane" + and entries is not None + and tuple(entries) + == DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_ENTRIES + ) + + def is_device_plane_manager_v2_control_plane_slice(component, entries): return ( component == "device-plane" @@ -9155,6 +9439,59 @@ def expected_device_plane_manager_release_boundaries(): return expected_device_plane_manager_release_v2_boundaries() +def expected_device_plane_edge_core_channel_bootstrap_descriptor( + transition_id, +): + return { + "schemaVersion": ( + "nodedc.device-plane.device-edge-core-channel-bootstrap.v1" + ), + "transitionId": transition_id, + "action": "activate", + "managerPredecessor": { + "patchId": ( + DEVICE_PLANE_EDGE_CORE_CHANNEL_MANAGER_PREDECESSOR_PATCH_ID + ), + "artifactSha256": ( + DEVICE_PLANE_EDGE_CORE_CHANNEL_MANAGER_PREDECESSOR_ARTIFACT_SHA256 + ), + }, + "failedPredecessor": { + "patchId": DEVICE_PLANE_EDGE_CORE_CHANNEL_FAILED_PATCH_ID, + "artifactSha256": ( + DEVICE_PLANE_EDGE_CORE_CHANNEL_FAILED_ARTIFACT_SHA256 + ), + "backupId": DEVICE_PLANE_EDGE_CORE_CHANNEL_FAILED_BACKUP_ID, + "invalidCoreCertificateSha256Fingerprint": ( + DEVICE_PLANE_EDGE_CORE_CHANNEL_INVALID_CERTIFICATE_FINGERPRINT + ), + }, + "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", + } + + def expected_device_plane_manager_failed_control_plane_descriptor(): return { "schemaVersion": ( @@ -9458,6 +9795,67 @@ def validate_device_plane_manager_release_payload( ) +def validate_device_plane_edge_core_channel_bootstrap_payload( + payload_dir, + *, + expected_transition_id=None, +): + descriptor = read_strict_json( + payload_dir / DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL, + "Device Edge Core channel bootstrap descriptor", + max_bytes=16 * 1024, + ) + transition_id = descriptor.get("transitionId") + if ( + not isinstance(transition_id, str) + or not re.fullmatch(r"[A-Za-z0-9._-]{1,96}", transition_id) + or ( + expected_transition_id is not None + and transition_id != expected_transition_id + ) + or descriptor + != expected_device_plane_edge_core_channel_bootstrap_descriptor( + transition_id + ) + ): + die("Device Edge Core channel bootstrap descriptor mismatch") + compose = payload_dir / DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL + if sha256_file(compose) != DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_SHA256: + die("Device Edge Core channel Compose mismatch") + compose_text = compose.read_text(encoding="utf-8") + required = ( + "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 any(value not in compose_text for value in required): + die("Device Edge Core channel Compose boundary missing") + forbidden = ( + "device-manager:", + "device-gateway:", + "device-postgres:", + "ports:", + "PRIVATE KEY", + "0.0.0.0:8443", + "9921:9921", + ) + if any(value in compose_text for value in forbidden): + die("Device Edge Core channel Compose boundary violation") + return descriptor + + def installed_device_plane_manager_compose_sha256(): root = DEVICE_PLANE_ROOT v1 = root / DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL @@ -11074,6 +11472,7 @@ def validate_device_plane_manager_activation_predecessor(payload_dir): descriptor["action"] != "upgrade" or tuple(predecessor_entries) not in ( DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES, + DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES, ) ): @@ -11115,6 +11514,149 @@ def validate_device_plane_manager_activation_predecessor(payload_dir): } +def inspect_device_edge_channel_core_identity_state(): + private_key = DEVICE_PLANE_EDGE_CHANNEL_CORE_PRIVATE_KEY_FILE + certificate = DEVICE_PLANE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE + private_exists = private_key.exists() or private_key.is_symlink() + certificate_exists = certificate.exists() or certificate.is_symlink() + if private_exists != certificate_exists: + die("Device Edge channel Core identity is incomplete") + if not private_exists: + return "absent-create-at-apply" + if device_edge_channel_invalid_identity_is_exact_recoverable(): + return "failed-016-invalid-unexported-recover-at-apply" + validate_device_edge_channel_certificate_extensions(certificate) + run_openssl([ + "verify", "-purpose", "sslclient", "-CAfile", str(certificate), + str(certificate), + ], "Device Edge channel Core existing certificate purpose validation") + certificate_public = capture_openssl( + ["x509", "-in", str(certificate), "-pubkey", "-noout"], + "Device Edge channel existing certificate public key", + ) + private_public = capture_openssl( + ["pkey", "-in", str(private_key), "-pubout"], + "Device Edge channel existing private key public derivation", + ) + if certificate_public != private_public: + die("Device Edge channel Core certificate/private key mismatch") + return "valid-reuse-at-apply" + + +def validate_device_plane_edge_core_channel_bootstrap_predecessor( + payload_dir, +): + descriptor = validate_device_plane_edge_core_channel_bootstrap_payload( + payload_dir + ) + manager = descriptor["managerPredecessor"] + manager_artifact_name = ( + f"nodedc-device-plane-{manager['patchId']}.tgz" + ) + manager_artifact = APPLIED_DIR / manager_artifact_name + if ( + not manager_artifact.is_file() + or manager_artifact.is_symlink() + or sha256_file(manager_artifact) != manager["artifactSha256"] + ): + die("Device Edge Core channel manager predecessor mismatch") + manager_records = [ + row for row in load_state(STATE_FILE) + if row.get("id") == manager["patchId"] + and row.get("sha256") == manager["artifactSha256"] + ] + if ( + len(manager_records) != 1 + or manager_records[0].get("status") != "ok" + or manager_records[0].get("component") != "device-plane" + or manager_records[0].get("artifact") != manager_artifact_name + ): + die("Device Edge Core channel manager journal mismatch") + installed_manager = read_strict_json( + DEVICE_PLANE_ROOT / DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, + "installed Device Manager predecessor", + max_bytes=16 * 1024, + ) + with tempfile.TemporaryDirectory( + prefix="device-edge-manager-predecessor-", + dir=TMP_DIR, + ) as directory: + predecessor_manifest, predecessor_entries, predecessor_payload = ( + load_artifact(manager_artifact, Path(directory)) + ) + if ( + predecessor_manifest.get("id") != manager["patchId"] + or tuple(predecessor_entries) + != DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES + ): + die("Device Edge Core channel manager artifact type mismatch") + expected_manager = validate_device_plane_manager_control_plane_payload( + predecessor_payload, + expected_release_id=manager["patchId"], + ) + if installed_manager != expected_manager: + die("Device Edge Core channel manager predecessor is not current") + if ( + sha256_file(DEVICE_PLANE_ROOT / DEVICE_PLANE_MANAGER_COMPOSE_REL) + != DEVICE_PLANE_MANAGER_RELEASE_V1_COMPOSE_SHA256 + ): + die("Device Edge Core channel stable Manager Compose drift detected") + + failed = descriptor["failedPredecessor"] + failed_artifact = FAILED_DIR / DEVICE_PLANE_EDGE_CORE_CHANNEL_FAILED_ARTIFACT + failed_backup = BACKUPS_DIR / failed["backupId"] + if ( + not failed_artifact.is_file() + or failed_artifact.is_symlink() + or sha256_file(failed_artifact) != failed["artifactSha256"] + or not failed_backup.is_dir() + or failed_backup.is_symlink() + ): + die("Device Edge Core channel failed predecessor evidence mismatch") + failed_records = [ + row for row in load_state(FAILED_STATE_FILE) + if row.get("id") == failed["patchId"] + and row.get("sha256") == failed["artifactSha256"] + ] + if ( + len(failed_records) != 1 + or failed_records[0].get("status") != "failed" + or failed_records[0].get("component") != "device-plane" + or failed_records[0].get("backup_id") != failed["backupId"] + or failed_records[0].get("started_apply") is not True + ): + die("Device Edge Core channel failed journal mismatch") + installed_bootstrap = ( + DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL + ) + installed_override = ( + DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL + ) + if ( + installed_bootstrap.exists() + or installed_bootstrap.is_symlink() + or installed_override.exists() + or installed_override.is_symlink() + ): + die("Device Edge Core channel bootstrap is already installed") + identity_state = inspect_device_edge_channel_core_identity_state() + for service in ( + "device-control-core", + "device-manager", + "device-gateway", + "device-postgres", + ): + healthcheck_compose_service("device-plane", service) + return { + "mode": "separate-edge-core-channel-bootstrap", + "descriptor": descriptor, + "identityState": identity_state, + "managerArtifact": manager_artifact, + "failedArtifact": failed_artifact, + "failedBackup": failed_backup, + } + + def validate_device_plane_manager_v2_reconciliation_backup(): backup_dir = ( BACKUPS_DIR / DEVICE_PLANE_MANAGER_V2_RECONCILIATION_BACKUP_ID @@ -12738,7 +13280,7 @@ def validate_device_plane_runtime_secret_metadata(include_management=False): return "exact" -def validate_device_manager_control_plane_runtime(): +def validate_device_manager_control_plane_runtime(*, require_edge_channel=None): 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: @@ -12747,29 +13289,49 @@ def validate_device_manager_control_plane_runtime(): manager = inspect_device_plane_container(manager_ids[0]) core_environment = container_environment(core, "Device Control Core") manager_environment = container_environment(manager, "Device Manager") + if require_edge_channel is None: + edge_descriptor = ( + DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL + ) + require_edge_channel = edge_descriptor.is_file() and not edge_descriptor.is_symlink() expected_core_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", } + if require_edge_channel: + expected_core_environment.update({ + "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", + }) if any( core_environment.get(key) != value for key, value in expected_core_environment.items() ): die("Device Control Core management runtime mismatch") + if not require_edge_channel and any( + key in core_environment + for key in ( + "DEVICE_EDGE_CHANNEL_ENABLED", + "DEVICE_EDGE_CHANNEL_CORE_KEY_FILE", + "DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE", + "DEVICE_EDGE_CHANNEL_TRUST_ROOT", + "DEVICE_EDGE_CHANNEL_MAX_EDGES", + "DEVICE_EDGE_CHANNEL_RECONCILE_INTERVAL_MS", + ) + ): + die("Device Manager runtime unexpectedly owns Edge channel state") expected_manager_environment = { "NODE_ENV": "production", "HOST": "0.0.0.0", @@ -12829,23 +13391,28 @@ def validate_device_manager_control_plane_runtime(): DEVICE_PLANE_EDGE_CHANNEL_PEER_TRUST_DIR ), } - for destination, source in expected_core_channel_mounts.items(): - mount = core_mounts.get(destination) - if ( - mount is None - or mount.get("Type") != "bind" - or mount.get("Source") != str(source) - or mount.get("RW") is not False - ): - die("Device Control Core Edge channel mount mismatch") + if require_edge_channel: + for destination, source in expected_core_channel_mounts.items(): + mount = core_mounts.get(destination) + if ( + mount is None + or mount.get("Type") != "bind" + or mount.get("Source") != str(source) + or mount.get("RW") is not False + ): + die("Device Control Core Edge channel mount mismatch") + elif any(destination in core_mounts for destination in expected_core_channel_mounts): + die("Device Manager runtime unexpectedly mounts Edge channel state") core_networks = set( ((core.get("NetworkSettings") or {}).get("Networks") or {}).keys() ) - if core_networks != { + expected_core_networks = { DEVICE_PLANE_PRIVATE_NETWORK, DEVICE_PLANE_CONTROL_NETWORK, - DEVICE_PLANE_EGRESS_NETWORK, - }: + } + if require_edge_channel: + expected_core_networks.add(DEVICE_PLANE_EGRESS_NETWORK) + if core_networks != expected_core_networks: die("Device Control Core Edge channel network boundary mismatch") manager_mounts = { mount.get("Destination"): mount @@ -12881,7 +13448,10 @@ def validate_device_manager_control_plane_runtime(): }: die("Device Manager network boundary mismatch") validate_device_plane_runtime_secret_metadata(include_management=True) - ensure_device_edge_channel_core_identity() + if require_edge_channel: + if inspect_device_edge_channel_core_identity_state() != "valid-reuse-at-apply": + die("Device Edge channel Core identity is not active") + validate_device_edge_channel_public_export() ensure_platform_runtime_secret( PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE, MAP_GATEWAY_SECRET_RE, @@ -14517,6 +15087,9 @@ def is_platform_provider_catalog_only(entries): def component_services(component, entries=None): + if is_device_plane_edge_core_channel_bootstrap_slice(component, entries): + return ("device-control-core",) + if is_device_plane_backhaul_vps_enrollment_slice(component, entries): return (DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,) @@ -14883,6 +15456,45 @@ def component_compose_files( ): die("installed Device Manager Compose drift detected") files = (*files, manager_overlay) + edge_descriptor = ( + DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL + ) + edge_override = ( + DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL + ) + edge_descriptor_exists = ( + edge_descriptor.exists() or edge_descriptor.is_symlink() + ) + edge_override_exists = edge_override.exists() or edge_override.is_symlink() + if edge_descriptor_exists != edge_override_exists: + die("installed Device Edge Core channel source is incomplete") + if edge_descriptor_exists: + if ( + edge_descriptor.is_symlink() + or not edge_descriptor.is_file() + or edge_override.is_symlink() + or not edge_override.is_file() + or sha256_file(edge_override) + != DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_SHA256 + ): + die("installed Device Edge Core channel source drift detected") + descriptor = read_strict_json( + edge_descriptor, + "installed Device Edge Core channel descriptor", + max_bytes=16 * 1024, + ) + transition_id = descriptor.get("transitionId") + if ( + not isinstance(transition_id, str) + or not re.fullmatch(r"[A-Za-z0-9._-]{1,96}", transition_id) + or descriptor != ( + expected_device_plane_edge_core_channel_bootstrap_descriptor( + transition_id + ) + ) + ): + die("installed Device Edge Core channel descriptor drift detected") + files = (*files, edge_override) overlay = DEVICE_PLANE_ROOT / DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL if overlay.exists() or overlay.is_symlink(): if ( @@ -14932,6 +15544,21 @@ def component_builds(component, entries=None): if is_platform_device_manager_public_route_slice(component, entries): return () + if is_device_plane_edge_core_channel_bootstrap_slice(component, entries): + return (( + DEVICE_PLANE_ROOT, + ( + "build", + "--no-cache", + "--network=host", + "-f", + "services/device-control-core/Dockerfile", + "-t", + DEVICE_PLANE_CONTROL_CORE_IMAGE, + ".", + ), + ),) + if is_device_plane_manager_control_plane_slice(component, entries): return ( ( @@ -16988,6 +17615,7 @@ def plan_artifact(artifact): device_plane_b2_ingress_preflight = None device_plane_b2_recovery_preflight = None device_plane_manager_activation_preflight = None + device_plane_edge_core_channel_preflight = None device_plane_manager_reconciliation_preflight = None device_plane_manager_v2_reconciliation_preflight = None device_plane_backhaul_preflight = None @@ -17209,6 +17837,15 @@ def plan_artifact(artifact): payload_dir ) ) + if is_device_plane_edge_core_channel_bootstrap_slice( + manifest["component"], + entries, + ): + device_plane_edge_core_channel_preflight = ( + validate_device_plane_edge_core_channel_bootstrap_predecessor( + payload_dir + ) + ) if is_device_plane_manager_reconciliation_slice( manifest["component"], entries, @@ -18627,6 +19264,14 @@ def plan_artifact(artifact): "runtime_secret=runner-managed:" f"{PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE}" ) + if is_device_plane_edge_core_channel_bootstrap_slice( + component, + entries, + ): + print( + "runtime_secret=runner-managed:" + f"{DEVICE_PLANE_MANAGEMENT_CORE_TOKEN_FILE}" + ) print( "runtime_private_key=runner-managed-host-local:" f"{DEVICE_PLANE_EDGE_CHANNEL_CORE_PRIVATE_KEY_FILE}" @@ -18815,26 +19460,58 @@ def plan_artifact(artifact): "device-backhaul-target" ) print("device_manager_health_gate=bounded-grace+contract") - print( - "device_edge_channel=" - "enabled:core-initiated:pinned-mtls:registered-edges-only" - ) - print( - "device_edge_channel_egress=" - "dedicated-core-only-bridge:no-host-ingress" - ) - print( - "device_edge_channel_identity=" - "host-local-private-key:public-certificate-export-only" - ) - print("device_edge_channel_registrations=preserved") - print("device_edge_channel_commands=disabled") print("device_manager_public_route=unchanged:absent") print("device_gateway_tcp_9921=preserved:loopback-only") print( "device_plane_rollback=" "source+reconciled-baseline-runtime" ) + if device_plane_edge_core_channel_preflight is not None: + print( + "device_plane_transition=" + f"{device_plane_edge_core_channel_preflight['mode']}" + ) + print( + "device_plane_predecessor_patch=" + f"{DEVICE_PLANE_EDGE_CORE_CHANNEL_MANAGER_PREDECESSOR_PATCH_ID}" + ) + print( + "device_plane_predecessor_artifact_sha256=" + f"{DEVICE_PLANE_EDGE_CORE_CHANNEL_MANAGER_PREDECESSOR_ARTIFACT_SHA256}" + ) + print( + "failed_patch=" + f"{DEVICE_PLANE_EDGE_CORE_CHANNEL_FAILED_PATCH_ID}" + ) + print( + "failed_artifact_sha256=" + f"{DEVICE_PLANE_EDGE_CORE_CHANNEL_FAILED_ARTIFACT_SHA256}" + ) + print( + "recovery_backup=" + f"{DEVICE_PLANE_EDGE_CORE_CHANNEL_FAILED_BACKUP_ID}" + ) + print( + "device_edge_channel_identity_preflight=" + f"{device_plane_edge_core_channel_preflight['identityState']}" + ) + print("device_plane_runtime_mutation=build+recreate:device-control-core") + print( + "device_plane_runtime_services=" + "preserved:device-manager,device-gateway,device-postgres," + "device-backhaul-target" + ) + print("device_edge_channel=enabled:core-initiated:pinned-mtls:registered-edges-only") + print("device_edge_channel_egress=dedicated-core-only-bridge:no-host-ingress") + print("device_edge_channel_identity=host-local-private-key:public-certificate-export-only") + print("device_edge_channel_invalid_failed_016=recover-exact-unexported-only") + print("device_edge_channel_registrations=preserved") + print("device_edge_channel_commands=disabled") + print("device_manager=preserved:active") + print("device_manager_public_route=unchanged:active") + print("device_gateway_tcp_9921=preserved:loopback-only") + print("gelios=untouched") + print("device_plane_rollback=source+predecessor-core-runtime") if device_plane_manager_reconciliation_preflight is not None: print( "device_plane_transition=" @@ -19515,7 +20192,24 @@ def rollback_device_plane_apply( baseline_entries, baseline_services, ) - if is_device_plane_manager_control_plane_slice("device-plane", entries): + if is_device_plane_edge_core_channel_bootstrap_slice( + "device-plane", + entries, + ): + for service in ( + "device-control-core", + "device-manager", + "device-gateway", + "device-postgres", + ): + healthcheck_compose_service_with_grace( + "device-plane", + service, + ) + validate_device_manager_control_plane_runtime( + require_edge_channel=False + ) + elif is_device_plane_manager_control_plane_slice("device-plane", entries): for service in baseline_services: healthcheck_compose_service_with_grace( "device-plane", @@ -20320,7 +21014,18 @@ def prepare_component_runtime(component, entries=None): MAP_GATEWAY_SECRET_RE, "Device Core Hub handoff", ) - ensure_device_edge_channel_core_identity() + if is_device_plane_edge_core_channel_bootstrap_slice( + component, + entries, + ): + ensure_platform_runtime_secret( + DEVICE_PLANE_MANAGEMENT_CORE_TOKEN_FILE, + MAP_GATEWAY_SECRET_RE, + "device plane management to Core", + ) + ensure_device_edge_channel_core_identity( + allow_invalid_unexported_recovery=True + ) if is_device_plane_backhaul_vps_enrollment_slice( component, entries, @@ -20628,6 +21333,18 @@ def component_healthchecks(component, entries=None, services=None): "commandTransport": "disabled", }, },) + if is_device_plane_edge_core_channel_bootstrap_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, @@ -21340,6 +22057,25 @@ def run_healthchecks(component, entries=None, services=None): healthcheck_url(check) validate_device_manager_control_plane_runtime() return + if is_device_plane_edge_core_channel_bootstrap_slice(component, entries): + if tuple(services or ()) != ("device-control-core",): + die("Device Edge Core channel bootstrap service set mismatch") + for service in ( + "device-control-core", + "device-manager", + "device-gateway", + "device-postgres", + ): + healthcheck_compose_service_with_grace( + "device-plane", + service, + ) + for check in component_healthchecks(component, entries, services): + healthcheck_url(check) + validate_device_manager_control_plane_runtime( + require_edge_channel=True + ) + 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): @@ -22190,6 +22926,13 @@ def apply_artifact(artifact): validate_device_plane_manager_activation_predecessor( payload_dir ) + if is_device_plane_edge_core_channel_bootstrap_slice( + component, + entries, + ): + validate_device_plane_edge_core_channel_bootstrap_predecessor( + payload_dir + ) if is_device_plane_manager_reconciliation_slice( component, entries, @@ -22510,6 +23253,14 @@ def apply_artifact(artifact): *inventory_services, "device-manager", ) + if is_device_plane_edge_core_channel_bootstrap_slice( + component, + entries, + ): + inventory_services = ( + *inventory_services, + "device-manager", + ) device_plane_runtime_before = ( device_plane_runtime_inventory( inventory_services diff --git a/infra/deploy-runner/test_device_edge_core_channel_bootstrap.py b/infra/deploy-runner/test_device_edge_core_channel_bootstrap.py new file mode 100644 index 0000000..40ea60d --- /dev/null +++ b/infra/deploy-runner/test_device_edge_core_channel_bootstrap.py @@ -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() diff --git a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py index b47b5f3..e7b79e4 100644 --- a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py +++ b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py @@ -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(