diff --git a/device-plane/deployment/device-manager-release-v1.json b/device-plane/deployment/device-manager-release-v1.json index 75379bc..559d898 100644 --- a/device-plane/deployment/device-manager-release-v1.json +++ b/device-plane/deployment/device-manager-release-v1.json @@ -4,13 +4,16 @@ "action": "upgrade", "predecessor": { "kind": "release", - "patchId": "device-manager-release-20260811-009", - "artifactSha256": "e0cc696e9387d26f005d342529f0e7c08be54113b9725271aecd9baf58b3726d" + "patchId": "device-manager-release-20260811-010", + "artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e" }, "service": "device-manager", "publicIngress": "reverse-proxy-only", "deviceCoreManagementApi": "file-token-authenticated", "launcherTrust": "file-token-scoped-to-device-core-handoff", + "edgeChannel": "core-initiated-pinned-mtls-enabled-zero-or-more-registered-edges", + "edgeChannelIdentity": "runner-managed-host-local-private-key-public-certificate-export", + "edgeChannelEgress": "dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-8443-registration-policy", "healthGate": "bounded-container-grace+core-contract", "commandTransport": "disabled", "gelios": "untouched", diff --git a/device-plane/docker-compose.device-manager.yml b/device-plane/docker-compose.device-manager.yml index f62052a..8597abd 100644 --- a/device-plane/docker-compose.device-manager.yml +++ b/device-plane/docker-compose.device-manager.yml @@ -3,6 +3,12 @@ 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 @@ -10,6 +16,26 @@ 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 @@ -71,6 +97,10 @@ 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-manager-control-plane-artifact.mjs b/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs index d5f2cab..208ed9a 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-010", ...extra] = process.argv.slice(2); +const [patchId = "device-manager-release-20260811-011", ...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 = [ @@ -88,11 +88,20 @@ 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", "0.0.0.0:18122", "0.0.0.0:9921:9921", "- \"9921:9921\"", @@ -113,6 +122,9 @@ 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 2772d3d..36becec 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -72,6 +72,27 @@ DEVICE_PLANE_IDENTIFIER_PEPPER_FILE = DEVICE_PLANE_SECRET_DIR / "identifier-pepp DEVICE_PLANE_MANAGEMENT_CORE_TOKEN_FILE = ( DEVICE_PLANE_SECRET_DIR / "management-core-token" ) +DEVICE_PLANE_EDGE_CHANNEL_SECRET_DIR = ( + DEVICE_PLANE_SECRET_DIR / "device-edge-channel" +) +DEVICE_PLANE_EDGE_CHANNEL_CORE_PRIVATE_KEY_FILE = ( + DEVICE_PLANE_EDGE_CHANNEL_SECRET_DIR / "core-private-key.pem" +) +DEVICE_PLANE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE = ( + DEVICE_PLANE_EDGE_CHANNEL_SECRET_DIR / "core-certificate.pem" +) +DEVICE_PLANE_EDGE_CHANNEL_PEER_TRUST_DIR = ( + DEVICE_PLANE_EDGE_CHANNEL_SECRET_DIR / "peers" +) +DEVICE_PLANE_EDGE_CHANNEL_EXPORT_DIR = ( + DEVICE_PLANE_ROOT / "enrollment" / "device-edge-channel" +) +DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_CERTIFICATE_FILE = ( + DEVICE_PLANE_EDGE_CHANNEL_EXPORT_DIR / "core-certificate.pem" +) +DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_FINGERPRINT_FILE = ( + DEVICE_PLANE_EDGE_CHANNEL_EXPORT_DIR / "core-certificate.sha256" +) DEVICE_PLANE_CONTROL_CORE_IMAGE = "nodedc/device-control-core:local" DEVICE_PLANE_GATEWAY_IMAGE = "nodedc/device-gateway:local" DEVICE_PLANE_MANAGER_IMAGE = "nodedc/device-manager:local" @@ -221,7 +242,7 @@ DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL = ( ) DEVICE_PLANE_MANAGER_COMPOSE_REL = "docker-compose.device-manager.yml" DEVICE_PLANE_MANAGER_COMPOSE_SHA256 = ( - "4954120aaddc999798b64c304d8cf692b79714feb727d873117bd1f3434e865e" + "369a2acf9c1a1030b9e1c6c366144b1eaf8900aef0ee59bb6bf23250b7b371b9" ) DEVICE_PLANE_MANAGER_FAILED_CONTROL_PLANE_ENTRIES = ( ".dockerignore", @@ -556,6 +577,7 @@ DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_COMPOSE_SHA256 = ( ) DEVICE_PLANE_PRIVATE_NETWORK = "nodedc-device-plane-private" DEVICE_PLANE_CONTROL_NETWORK = "nodedc-device-plane-control" +DEVICE_PLANE_EGRESS_NETWORK = "nodedc-device-plane-egress" DEVICE_PLANE_FOUNDATION_PREDECESSOR_CONTAINER_IDS = { "device-control-core": ( "ccdaeee71472a557ccecb485e81b31f5a4ee37b1b3f3cb4d5edbcf12d02bb08b" @@ -2065,7 +2087,7 @@ def ensure_external_data_plane_provisioner_secret(): def resolve_openssl_binary(): candidate = shutil.which("openssl") if not candidate: - die("openssl is required to provision the Engine credential issuer") + die("openssl is required to provision runtime identities") path = Path(candidate).resolve() try: path_stat = path.lstat() @@ -2091,6 +2113,214 @@ def run_openssl(arguments, label): die(f"openssl {label} failed") +def capture_openssl(arguments, label): + result = subprocess.run( + [str(resolve_openssl_binary()), *arguments], + check=False, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=30, + ) + if result.returncode != 0: + die(f"openssl {label} failed") + return result.stdout + + +def ensure_safe_runtime_directory(path, mode, label): + try: + path_stat = path.lstat() + except FileNotFoundError: + path.mkdir(parents=True, exist_ok=False) + path_stat = path.lstat() + if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISDIR(path_stat.st_mode): + die(f"{label} directory is unsafe: {path}") + os.chown(path, 0, MAP_GATEWAY_RUNTIME_GID) + path.chmod(mode) + + +def device_edge_channel_certificate_fingerprint(path): + raw = capture_openssl( + ["x509", "-in", str(path), "-noout", "-fingerprint", "-sha256"], + "Device Edge channel certificate fingerprint", + ).decode("ascii", errors="strict").strip() + match = re.fullmatch( + r"SHA256 Fingerprint=((?:[A-F0-9]{2}:){31}[A-F0-9]{2})", + raw, + flags=re.IGNORECASE, + ) + if not match: + die("Device Edge channel certificate fingerprint is invalid") + return match.group(1).upper() + + +def install_public_runtime_export(source, destination, mode, label): + source_bytes = source.read_bytes() + if b"PRIVATE KEY" in source_bytes: + die(f"{label} contains private key material") + try: + destination_stat = destination.lstat() + except FileNotFoundError: + destination_stat = None + if destination_stat is not None and ( + stat.S_ISLNK(destination_stat.st_mode) + or not stat.S_ISREG(destination_stat.st_mode) + ): + die(f"{label} destination is unsafe: {destination}") + temporary = destination.parent / ( + f".{destination.name}.{os.getpid()}.{time.time_ns()}.tmp" + ) + descriptor = None + try: + descriptor = os.open( + str(temporary), + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + mode, + ) + os.write(descriptor, source_bytes) + os.fsync(descriptor) + os.fchown(descriptor, 0, MAP_GATEWAY_RUNTIME_GID) + os.fchmod(descriptor, mode) + os.close(descriptor) + descriptor = None + os.replace(temporary, destination) + fsync_directory(destination.parent) + finally: + if descriptor is not None: + os.close(descriptor) + if temporary.exists(): + temporary.unlink() + + +def ensure_device_edge_channel_core_identity(): + # 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 + # trust handoff. + ensure_safe_runtime_directory( + DEVICE_PLANE_EDGE_CHANNEL_SECRET_DIR, + 0o710, + "Device Edge channel identity", + ) + ensure_safe_runtime_directory( + DEVICE_PLANE_EDGE_CHANNEL_PEER_TRUST_DIR, + 0o710, + "Device Edge channel peer trust", + ) + 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") + + created = False + if not private_exists: + private_tmp = private_key.with_name( + f".{private_key.name}.{os.getpid()}.{time.time_ns()}.tmp" + ) + certificate_tmp = certificate.with_name( + 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") + os.chown(private_tmp, 0, MAP_GATEWAY_RUNTIME_GID) + private_tmp.chmod(0o640) + os.chown(certificate_tmp, 0, MAP_GATEWAY_RUNTIME_GID) + certificate_tmp.chmod(0o640) + fsync_file(private_tmp) + fsync_file(certificate_tmp) + os.replace(private_tmp, private_key) + os.replace(certificate_tmp, certificate) + fsync_directory(DEVICE_PLANE_EDGE_CHANNEL_SECRET_DIR) + created = True + finally: + if private_tmp.exists(): + private_tmp.unlink() + if certificate_tmp.exists(): + certificate_tmp.unlink() + + for path, label in ( + (private_key, "private key"), + (certificate, "certificate"), + ): + path_stat = path.lstat() + 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) != 0o640 + or path_stat.st_size < 64 + or path_stat.st_size > 32 * 1024 + ): + die(f"Device Edge channel Core {label} boundary mismatch") + + private_text = private_key.read_text(encoding="ascii") + certificate_text = certificate.read_text(encoding="ascii") + if "PRIVATE KEY" not in private_text or "PRIVATE KEY" in certificate_text: + die("Device Edge channel Core private/public boundary mismatch") + if certificate_text.count("-----BEGIN CERTIFICATE-----") != 1: + die("Device Edge channel Core certificate cardinality mismatch") + run_openssl(["pkey", "-in", str(private_key), "-check", "-noout"], + "Device Edge channel Core private key validation") + run_openssl(["x509", "-in", str(certificate), "-noout", "-checkend", "604800"], + "Device Edge channel Core certificate lifetime validation") + run_openssl([ + "verify", "-purpose", "sslclient", "-CAfile", str(certificate), + str(certificate), + ], "Device Edge channel Core certificate purpose validation") + certificate_public = capture_openssl( + ["x509", "-in", str(certificate), "-pubkey", "-noout"], + "Device Edge channel Core certificate public key", + ) + private_public = capture_openssl( + ["pkey", "-in", str(private_key), "-pubout"], + "Device Edge channel Core private key public derivation", + ) + if certificate_public != private_public: + die("Device Edge channel Core certificate/private key mismatch") + + ensure_safe_runtime_directory( + DEVICE_PLANE_EDGE_CHANNEL_EXPORT_DIR, + 0o755, + "Device Edge channel public export", + ) + install_public_runtime_export( + certificate, + DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_CERTIFICATE_FILE, + 0o444, + "Device Edge channel Core certificate export", + ) + fingerprint = device_edge_channel_certificate_fingerprint(certificate) + fingerprint_tmp = DEVICE_PLANE_EDGE_CHANNEL_EXPORT_DIR / ( + f".core-certificate.sha256.{os.getpid()}.{time.time_ns()}.tmp" + ) + try: + fingerprint_tmp.write_text(f"SHA256={fingerprint}\n", encoding="ascii") + os.chown(fingerprint_tmp, 0, MAP_GATEWAY_RUNTIME_GID) + fingerprint_tmp.chmod(0o444) + fsync_file(fingerprint_tmp) + os.replace( + fingerprint_tmp, + DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_FINGERPRINT_FILE, + ) + fsync_directory(DEVICE_PLANE_EDGE_CHANNEL_EXPORT_DIR) + finally: + if fingerprint_tmp.exists(): + fingerprint_tmp.unlink() + return "created" if created else "reused" + + def validate_ed25519_public_key_file(path): try: value = path.read_text(encoding="ascii") @@ -8859,6 +9089,16 @@ def expected_device_plane_manager_release_boundaries(): "publicIngress": "reverse-proxy-only", "deviceCoreManagementApi": "file-token-authenticated", "launcherTrust": "file-token-scoped-to-device-core-handoff", + "edgeChannel": ( + "core-initiated-pinned-mtls-enabled-zero-or-more-registered-edges" + ), + "edgeChannelIdentity": ( + "runner-managed-host-local-private-key-public-certificate-export" + ), + "edgeChannelEgress": ( + "dedicated-core-only-bridge-no-host-ingress-" + "public-ipv4-tcp-8443-registration-policy" + ), "healthGate": "bounded-container-grace+core-contract", "commandTransport": "disabled", "gelios": "untouched", @@ -9062,6 +9302,20 @@ def validate_device_plane_manager_control_plane_payload( "/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 required not in compose: @@ -12317,10 +12571,27 @@ 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 ( - core_environment.get("DEVICE_MANAGEMENT_API_ENABLED") != "true" - or core_environment.get("DEVICE_MANAGEMENT_CORE_TOKEN_FILE") - != "/run/nodedc-secrets/management-core-token" + 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 any( + core_environment.get(key) != value + for key, value in expected_core_environment.items() ): die("Device Control Core management runtime mismatch") expected_manager_environment = { @@ -12371,6 +12642,35 @@ def validate_device_manager_control_plane_runtime(): or core_management_mount.get("RW") is not False ): die("Device Control Core management secret mount mismatch") + expected_core_channel_mounts = { + "/run/nodedc-secrets/device-edge-channel/core-private-key.pem": ( + DEVICE_PLANE_EDGE_CHANNEL_CORE_PRIVATE_KEY_FILE + ), + "/run/nodedc-secrets/device-edge-channel/core-certificate.pem": ( + DEVICE_PLANE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE + ), + "/run/nodedc-secrets/device-edge-channel/peers": ( + 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") + core_networks = set( + ((core.get("NetworkSettings") or {}).get("Networks") or {}).keys() + ) + if core_networks != { + DEVICE_PLANE_PRIVATE_NETWORK, + DEVICE_PLANE_CONTROL_NETWORK, + DEVICE_PLANE_EGRESS_NETWORK, + }: + die("Device Control Core Edge channel network boundary mismatch") manager_mounts = { mount.get("Destination"): mount for mount in manager.get("Mounts") or [] @@ -12405,6 +12705,7 @@ 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() ensure_platform_runtime_secret( PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE, MAP_GATEWAY_SECRET_RE, @@ -18147,6 +18448,14 @@ def plan_artifact(artifact): "runtime_secret=runner-managed:" f"{PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE}" ) + print( + "runtime_private_key=runner-managed-host-local:" + f"{DEVICE_PLANE_EDGE_CHANNEL_CORE_PRIVATE_KEY_FILE}" + ) + print( + "runtime_public_certificate_export=runner-managed:" + f"{DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_CERTIFICATE_FILE}" + ) print( "device_postgres=" f"{device_plane_postgres_plan_selection(device_plane_postgres_preflight)}" @@ -18327,6 +18636,20 @@ 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( @@ -19818,6 +20141,7 @@ 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_backhaul_vps_enrollment_slice( component, entries, 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 6fc348e..d4288b5 100644 --- a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py +++ b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py @@ -189,6 +189,18 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): self.assertNotIn("device-postgres", result["services"]) self.assertIn("docker-compose.device-manager.yml", entries) self.assertNotIn("docker-compose.device-plane.yml", entries) + 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: ", + "name: nodedc-device-plane-egress", + ): + self.assertIn(required, 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") @@ -215,7 +227,16 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): [RUNNER.PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE], ) - with mock.patch.object(RUNNER, "ensure_platform_runtime_secret") as ensure: + 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_MANAGER_CONTROL_PLANE_ENTRIES, @@ -230,6 +251,7 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): RUNNER.PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE, ], ) + ensure_edge_identity.assert_called_once_with() def test_apply_gate_checks_exact_services_core_contract_and_runtime_boundary(self): entries = RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES