From 3ea2133bcde429ceb14bc8b87d1f82b95037c611 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 22:57:20 +0300 Subject: [PATCH] feat(deploy): add typed command transport releases --- .../device-control-core-release-v2.json | 35 ++ .../device-edge-vps-command-transport-v1.json | 78 +++ .../deployment/device-manager-release-v3.json | 31 ++ ...d-device-control-core-release-artifact.mjs | 22 +- .../build-device-edge-vps-artifact.mjs | 64 ++- ...-device-manager-control-plane-artifact.mjs | 65 ++- infra/deploy-runner/nodedc-b2-vps-deploy | 190 +++++++- infra/deploy-runner/nodedc-deploy | 444 ++++++++++++++++-- .../test_device_edge_vps_artifact.py | 68 ++- ..._device_manager_control_plane_artifacts.py | 106 +++++ 10 files changed, 1028 insertions(+), 75 deletions(-) create mode 100644 device-plane/deployment/device-control-core-release-v2.json create mode 100644 device-plane/deployment/device-edge-vps-command-transport-v1.json create mode 100644 device-plane/deployment/device-manager-release-v3.json diff --git a/device-plane/deployment/device-control-core-release-v2.json b/device-plane/deployment/device-control-core-release-v2.json new file mode 100644 index 0000000..5d25430 --- /dev/null +++ b/device-plane/deployment/device-control-core-release-v2.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": "nodedc.device-plane.device-control-core-release.v2", + "releaseId": "__PATCH_ID__", + "action": "upgrade", + "predecessor": { + "kind": "release", + "patchId": "device-control-core-release-20260812-024", + "artifactSha256": "a289e909283109642e6bba3d9822a31f63423cfe0bbcd52705979681bd2bc793" + }, + "service": "device-control-core", + "composeActivation": "preserve-active-v4-topology", + "identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export", + "identityRecovery": "forbidden-valid-existing-identity-required", + "tlsPurpose": "clientAuth", + "direction": "core-initiated", + "endpointPolicy": "public-ipv4-standard-https-tcp-443-only", + "coreNetworks": [ + "device-plane-private", + "device-plane-egress" + ], + "publicIngress": "none-on-synology", + "edgeRegistrations": "preserved", + "commandTransport": "typed-service-ping-v1", + "commandCatalog": "allowlisted-adapter-typed-commands-only", + "credentialBoundary": "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned", + "gelios": "untouched-legacy-only", + "preservedServices": [ + "device-manager", + "device-gateway", + "device-postgres", + "device-backhaul-target" + ], + "healthGate": "bounded-container-grace+core-edge-contract+exact-private-egress-network-boundary", + "rollback": "restore-preapply-source-and-core-runtime" +} diff --git a/device-plane/deployment/device-edge-vps-command-transport-v1.json b/device-plane/deployment/device-edge-vps-command-transport-v1.json new file mode 100644 index 0000000..aa9a658 --- /dev/null +++ b/device-plane/deployment/device-edge-vps-command-transport-v1.json @@ -0,0 +1,78 @@ +{ + "schemaVersion": "nodedc.device-edge-vps.command-transport.v1", + "mode": "provider-neutral-typed-command-transport-over-accepted-core-channel", + "status": "active-typed-command-transport", + "authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001", + "component": "device-edge-vps", + "phase": "command-transport", + "runtimeHost": "koffyvngij", + "predecessorPatch": "device-edge-vps-tracker-ingress-20260812-012", + "predecessorArtifactSha256": "290acef118839c6b0c31aac864c47da1832a289537366af9322d4624a1dd81ec", + "runtimeUser": "nodedc-channel", + "runtimeService": "nodedc-device-edge-channel.service", + "runtimeComposition": "single-process-core-channel-plus-universal-device-gateway", + "publicIngress": "tcp/443-mtls-core-channel+tcp/9921-bidirectional-tracker-session", + "trackerIngress": "preserved:allowlisted-adapters-only", + "initialAdapterProfile": "arusnavi.b2.internal.v1", + "commandTransport": "typed-service-ping-v1", + "commandCatalog": "allowlisted-adapter-typed-commands-only", + "allowedCommands": [ + "service.ping" + ], + "credentialBoundary": "transient-over-pinned-mtls-in-memory-until-single-tracker-write-never-stored-never-logged-never-returned", + "responseBoundary": "exact-adapter-parser-serv-ok-only", + "health": "127.0.0.1:18222", + "adapterHealth": "127.0.0.1:18221", + "rawDeviceTcp9921": "public-bidirectional-tracker-session-no-generic-forwarding", + "gelios": "untouched-legacy-only", + "tailscale": "absent", + "dataBoundary": "no-vps-database-no-business-logic-no-synology-route", + "resourceCeilings": { + "memory": "192M", + "swap": "0", + "cpu": "75%", + "tasks": 128, + "openFiles": 1024, + "sessions": 128, + "sessionsPerAddress": 16, + "connectionsPerMinutePerAddress": 60, + "sessionBufferBytes": 65536, + "aggregateBufferBytes": 33554432 + }, + "preserved": [ + "management-ssh-key", + "accepted-node-runtime", + "accepted-core-channel-trust-and-registration", + "accepted-tracker-ingress", + "retired-tailnet-boundary", + "gelios-production-path" + ], + "forbidden": [ + "vps-initiated-synology-connection", + "generic-tcp-forwarding", + "tailscale-runtime", + "docker", + "public-health", + "vps-database", + "vps-business-logic", + "unregistered-adapter", + "raw-command", + "firmware-command", + "reboot-command", + "persistent-command-credential" + ], + "acceptance": [ + "exact-tracker-ingress-012-predecessor", + "single-non-root-edge-process", + "core-channel-remains-accepted", + "public-tracker-tcp-9921-listening", + "adapter-profile-allowlisted", + "typed-service-ping-only", + "exact-serv-ok-response-parser", + "bounded-session-and-buffer-limits", + "tailscale-remains-absent", + "no-vps-to-synology-route", + "gelios-untouched" + ], + "rollback": "restore-exact-tracker-ingress-012-source-and-existing-runtime" +} diff --git a/device-plane/deployment/device-manager-release-v3.json b/device-plane/deployment/device-manager-release-v3.json new file mode 100644 index 0000000..afc896c --- /dev/null +++ b/device-plane/deployment/device-manager-release-v3.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": "nodedc.device-plane.device-manager-release.v3", + "releaseId": "__PATCH_ID__", + "action": "upgrade", + "predecessor": { + "kind": "release", + "patchId": "device-manager-release-20260811-010", + "artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e" + }, + "controlCorePredecessor": { + "patchId": "device-control-core-release-v2-20260812-025", + "artifactSha256": "c61b1f0de1bae23de0caa7289036865ea419ff5705611416f736ca929d1592db" + }, + "edgeChannelPredecessor": { + "patchId": "device-edge-core-channel-upgrade-v4-20260812-023", + "artifactSha256": "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc" + }, + "service": "device-manager", + "publicIngress": "reverse-proxy-only", + "deviceCoreManagementApi": "file-token-authenticated", + "launcherTrust": "file-token-scoped-to-device-core-handoff", + "edgeChannel": "preserve-active-v4-core-initiated-pinned-mtls", + "edgeChannelIdentity": "reuse-runner-managed-host-local-private-key-public-certificate-export", + "edgeChannelEgress": "preserve-dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-443-only", + "healthGate": "bounded-container-grace+core-contract", + "commandTransport": "typed-service-ping-v1", + "commandCatalog": "allowlisted-adapter-typed-commands-only", + "credentialBoundary": "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned", + "gelios": "untouched-legacy-only", + "rollback": "restore-preapply-snapshot" +} diff --git a/infra/deploy-runner/build-device-control-core-release-artifact.mjs b/infra/deploy-runner/build-device-control-core-release-artifact.mjs index c39cb89..de0615e 100644 --- a/infra/deploy-runner/build-device-control-core-release-artifact.mjs +++ b/infra/deploy-runner/build-device-control-core-release-artifact.mjs @@ -11,7 +11,7 @@ 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-control-core-release-20260812-024", + patchId = "device-control-core-release-v2-20260812-025", predecessorPatchId, predecessorSha256, ...extra @@ -35,7 +35,10 @@ if ( ); } -const descriptorPath = "deployment/device-control-core-release-v1.json"; +const isV2 = patchId.startsWith("device-control-core-release-v2-"); +const descriptorPath = isV2 + ? "deployment/device-control-core-release-v2.json" + : "deployment/device-control-core-release-v1.json"; const entries = [ ".dockerignore", "package.json", @@ -94,7 +97,7 @@ try { const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8")); if ( - descriptor.schemaVersion !== "nodedc.device-plane.device-control-core-release.v1" + descriptor.schemaVersion !== `nodedc.device-plane.device-control-core-release.${isV2 ? "v2" : "v1"}` || descriptor.releaseId !== patchId || descriptor.action !== "upgrade" || descriptor.service !== "device-control-core" @@ -106,9 +109,18 @@ try { || JSON.stringify(descriptor.coreNetworks) !== JSON.stringify(["device-plane-private", "device-plane-egress"]) || descriptor.publicIngress !== "none-on-synology" || descriptor.edgeRegistrations !== "preserved" - || descriptor.commandTransport !== "disabled" - || descriptor.gelios !== "untouched" + || descriptor.commandTransport !== (isV2 ? "typed-service-ping-v1" : "disabled") + || descriptor.gelios !== (isV2 ? "untouched-legacy-only" : "untouched") || descriptor.rollback !== "restore-preapply-source-and-core-runtime" + || ( + isV2 + && ( + descriptor.commandCatalog !== "allowlisted-adapter-typed-commands-only" + || descriptor.credentialBoundary !== "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned" + || descriptor.predecessor?.patchId !== "device-control-core-release-20260812-024" + || descriptor.predecessor?.artifactSha256 !== "a289e909283109642e6bba3d9822a31f63423cfe0bbcd52705979681bd2bc793" + ) + ) ) { throw new Error("device_control_core_release_contract_mismatch"); } diff --git a/infra/deploy-runner/build-device-edge-vps-artifact.mjs b/infra/deploy-runner/build-device-edge-vps-artifact.mjs index 6d452f4..f06a93b 100644 --- a/infra/deploy-runner/build-device-edge-vps-artifact.mjs +++ b/infra/deploy-runner/build-device-edge-vps-artifact.mjs @@ -37,11 +37,12 @@ if ( "core-channel", "tailscale-retirement", "tracker-ingress", + "command-transport", ].includes(phase) || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId || "") ) { throw new Error( - "usage: build-device-edge-vps-artifact.mjs ", + "usage: build-device-edge-vps-artifact.mjs ", ); } @@ -52,6 +53,10 @@ if ( ) { throw new Error("vps_initiated_transport_frozen:ADR-0001"); } +const acceptedSharedSourcePhases = new Set(["core-channel", "tracker-ingress"]); +if (acceptedSharedSourcePhases.has(phase)) { + throw new Error(`accepted_vps_phase_rebuild_frozen:${phase}:ADR-0001`); +} const nodeArchive = "node-v22.23.2-linux-x64.tar.xz"; const tailscaleArchive = "tailscale_1.102.2_amd64.tgz"; @@ -110,6 +115,21 @@ const entriesByPhase = { "vps/systemd/nodedc-device-edge-runtime.service", "deployment/device-edge-vps-tracker-ingress-v1.json", ], + "command-transport": [ + "packages/device-edge-channel-contract/package.json", + "packages/device-edge-channel-contract/src", + "services/device-edge-channel/package.json", + "services/device-edge-channel/src", + "packages/device-adapter-runtime/package.json", + "packages/device-adapter-runtime/src", + "packages/device-adapter-catalog/package.json", + "packages/device-adapter-catalog/src", + "packages/arusnavi-b2-adapter/package.json", + "packages/arusnavi-b2-adapter/src", + "services/device-gateway/src/runtime.mjs", + "vps/edge-process/device-edge-runtime.mjs", + "deployment/device-edge-vps-command-transport-v1.json", + ], }; const entries = entriesByPhase[phase]; const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]); @@ -171,10 +191,12 @@ try { ? "tcp/9921" : ["core-channel", "tailscale-retirement"].includes(phase) ? "tcp/443-mtls-only" - : phase === "tracker-ingress" + : ["tracker-ingress", "command-transport"].includes(phase) ? "tcp/443-mtls+tcp/9921-telemetry" : "disabled", - commandTransport: "disabled", + commandTransport: phase === "command-transport" + ? "typed-service-ping-v1" + : "disabled", gelios: "untouched", }, null, 2)); } finally { @@ -190,8 +212,10 @@ async function assertBoundary() { if ( descriptor.component !== "device-edge-vps" || descriptor.runtimeHost !== "koffyvngij" - || descriptor.commandTransport !== "disabled" - || descriptor.gelios !== "untouched" + || descriptor.commandTransport !== (phase === "command-transport" + ? "typed-service-ping-v1" + : "disabled") + || !String(descriptor.gelios || "").startsWith("untouched") || !String(descriptor.rollback || "").length ) { throw new Error(`descriptor_boundary_mismatch:${phase}`); @@ -365,6 +389,36 @@ async function assertBoundary() { } } } + if (phase === "command-transport") { + for (const required of [ + '"predecessorPatch": "device-edge-vps-tracker-ingress-20260812-012"', + '"runtimeService": "nodedc-device-edge-channel.service"', + '"runtimeComposition": "single-process-core-channel-plus-universal-device-gateway"', + '"commandTransport": "typed-service-ping-v1"', + '"commandCatalog": "allowlisted-adapter-typed-commands-only"', + '"responseBoundary": "exact-adapter-parser-serv-ok-only"', + "buildTypedCommand", + "parseTypedCommandResponse", + "submitCommandStatus", + '"service.ping"', + ]) { + if (!combined.includes(required)) { + throw new Error(`command_transport_boundary_missing:${required}`); + } + } + for (const forbidden of [ + "LocalForward", + "tailscale-userspace", + "DEVICE_EDGE_RELAY_UPSTREAM", + "device.dc.ru", + "PRIVATE KEY", + "TS_AUTHKEY", + ]) { + if (combined.includes(forbidden)) { + throw new Error(`command_transport_boundary_violation:${forbidden}`); + } + } + } } function canonicalTarScript() { 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 f1ff665..383186c 100644 --- a/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs +++ b/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs @@ -12,10 +12,19 @@ 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-018", ...extra] = process.argv.slice(2); +const [patchId = "device-manager-release-v3-20260812-026", ...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 = [ +const descriptorPath = patchId.startsWith("device-manager-release-v3-") + ? "deployment/device-manager-release-v3.json" + : "deployment/device-manager-release-v1.json"; + +const isV3 = descriptorPath.endsWith("release-v3.json"); +const entries = isV3 ? [ + "docker-compose.device-manager.yml", + "services/device-manager", + descriptorPath, +] : [ ".dockerignore", "package.json", "package-lock.json", @@ -27,7 +36,7 @@ const entries = [ "services/device-gateway/package.json", "services/device-edge-relay/package.json", "services/device-manager", - "deployment/device-manager-release-v1.json", + descriptorPath, ]; const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-control-plane-")); const payload = join(stage, "payload"); @@ -41,7 +50,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-v1.json") { + if (entry === descriptorPath) { const descriptor = JSON.parse(await readFile(resolve(devicePlaneRoot, entry), "utf8")); if (descriptor.releaseId !== "__PATCH_ID__") { throw new Error("device_manager_release_template_id_mismatch"); @@ -62,15 +71,17 @@ try { } await copySafe(resolve(devicePlaneRoot, entry), join(payload, entry), devicePlaneRoot); } - await validateDockerCopySources( - join(payload, "services/device-control-core/Dockerfile"), - payload, - ); + if (!isV3) { + await validateDockerCopySources( + join(payload, "services/device-control-core/Dockerfile"), + payload, + ); + } await validateDockerCopySources( join(payload, "services/device-manager/Dockerfile"), join(payload, "services/device-manager"), ); - for (const modulePath of [ + for (const modulePath of isV3 ? [] : [ "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", @@ -115,14 +126,10 @@ 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-v1.json"), - "utf8", - )); + const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8")); const predecessor = descriptor.predecessor; - if ( - descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v1" - || descriptor.releaseId !== patchId + const commonContractInvalid = ( + descriptor.releaseId !== patchId || !["activate", "upgrade"].includes(descriptor.action) || !predecessor || !["reconciliation", "release"].includes(predecessor.kind) @@ -131,7 +138,29 @@ try { || (descriptor.action === "activate") !== (predecessor.kind === "reconciliation") || descriptor.healthGate !== "bounded-container-grace+core-contract" || descriptor.rollback !== "restore-preapply-snapshot" - ) throw new Error("device_manager_activation_successor_contract_mismatch"); + ); + if (commonContractInvalid) throw new Error("device_manager_activation_successor_contract_mismatch"); + if (descriptorPath.endsWith("release-v3.json")) { + if ( + descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v3" + || descriptor.commandTransport !== "typed-service-ping-v1" + || descriptor.commandCatalog !== "allowlisted-adapter-typed-commands-only" + || descriptor.credentialBoundary !== "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned" + || descriptor.controlCorePredecessor?.patchId !== "device-control-core-release-v2-20260812-025" + || descriptor.controlCorePredecessor?.artifactSha256 !== "c61b1f0de1bae23de0caa7289036865ea419ff5705611416f736ca929d1592db" + || descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023" + || descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc" + || descriptor.edgeChannel !== "preserve-active-v4-core-initiated-pinned-mtls" + || descriptor.edgeChannelEgress !== "preserve-dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-443-only" + || descriptor.gelios !== "untouched-legacy-only" + ) throw new Error("device_manager_v3_typed_command_contract_mismatch"); + } else if ( + descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v1" + || descriptor.commandTransport !== "disabled" + || descriptor.gelios !== "untouched" + ) { + throw new Error("device_manager_v1_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 }); @@ -145,7 +174,7 @@ try { artifact: target, sha256, entries, - services: ["device-control-core", "device-manager"], + services: isV3 ? ["device-manager"] : ["device-control-core", "device-manager"], preserved: ["device-postgres", "device-gateway", "device-backhaul-target", "Gelios"], }, null, 2)); } finally { diff --git a/infra/deploy-runner/nodedc-b2-vps-deploy b/infra/deploy-runner/nodedc-b2-vps-deploy index 52faeae..d7eb0ed 100755 --- a/infra/deploy-runner/nodedc-b2-vps-deploy +++ b/infra/deploy-runner/nodedc-b2-vps-deploy @@ -112,6 +112,10 @@ TAILSCALE_RETIREMENT_ACCEPTED_PATCH = ( TAILSCALE_RETIREMENT_ACCEPTED_SHA256 = ( "e7b61ec9c83122fa5631467010eff871b98935746df6a1326b6ff6bb9713d877" ) +TRACKER_INGRESS_ACCEPTED_PATCH = "device-edge-vps-tracker-ingress-20260812-012" +TRACKER_INGRESS_ACCEPTED_SHA256 = ( + "290acef118839c6b0c31aac864c47da1832a289537366af9322d4624a1dd81ec" +) FOUNDATION_ENTRIES = ( "vps/config/00-nodedc-b2-vps.conf", @@ -162,6 +166,21 @@ TRACKER_INGRESS_ENTRIES = ( "vps/systemd/nodedc-device-edge-runtime.service", "deployment/device-edge-vps-tracker-ingress-v1.json", ) +COMMAND_TRANSPORT_ENTRIES = ( + "packages/device-edge-channel-contract/package.json", + "packages/device-edge-channel-contract/src", + "services/device-edge-channel/package.json", + "services/device-edge-channel/src", + "packages/device-adapter-runtime/package.json", + "packages/device-adapter-runtime/src", + "packages/device-adapter-catalog/package.json", + "packages/device-adapter-catalog/src", + "packages/arusnavi-b2-adapter/package.json", + "packages/arusnavi-b2-adapter/src", + "services/device-gateway/src/runtime.mjs", + "vps/edge-process/device-edge-runtime.mjs", + "deployment/device-edge-vps-command-transport-v1.json", +) PHASE_ENTRIES = { "foundation": FOUNDATION_ENTRIES, @@ -171,6 +190,7 @@ PHASE_ENTRIES = { "core-channel": CORE_CHANNEL_ENTRIES, "tailscale-retirement": TAILSCALE_RETIREMENT_ENTRIES, "tracker-ingress": TRACKER_INGRESS_ENTRIES, + "command-transport": COMMAND_TRANSPORT_ENTRIES, } SUPERSEDED_TRANSPORT_PHASES = frozenset({"backhaul", "relay"}) @@ -262,6 +282,36 @@ PHASE_FILE_SHA256 = { "deployment/device-edge-vps-tracker-ingress-v1.json": "faecfa0317ad0e3d102c3636c0547b65257b930e9b48f34d8679c00ceb3d323b", }, + "command-transport": { + "packages/device-edge-channel-contract/package.json": + "57d5349b5dcef2cacd4f3e4fad010359a65d59f5f903eff07d89f67c497f97c0", + "packages/device-edge-channel-contract/src/index.mjs": + "a49bfe31476a08f8a912c10c82b982fae28a29e2c0eb5828ced390b01f3a10bc", + "services/device-edge-channel/package.json": + "bdf502be43b62bdd6db05b022a532d93ba954277ac5143d6058d2f27f6a2e9d2", + "services/device-edge-channel/src/runtime.mjs": + "39579d85edf50be07573873a1f59045f62a74469c85588db1fd4e5151b3b778d", + "services/device-edge-channel/src/server.mjs": + "a82057218bb368ab926404f90a19cc17c0359b57dc890f38a1324ab8c497c17b", + "packages/device-adapter-runtime/package.json": + "293ee6010f255c511df40b34cf8019fcd0d1b06babe30761c2a842c7da7af778", + "packages/device-adapter-runtime/src/index.mjs": + "199fcdd775d1ea15ddc9f85ab24fc2c165bb7757008de26749f1b7e84481dfeb", + "packages/device-adapter-catalog/package.json": + "60dbfc551ca49719e0851755058c159968411c490621eac0bf96ca76bf8b78d1", + "packages/device-adapter-catalog/src/index.mjs": + "614749e763c0624bf0de7905343632d3edb4e026316f427a2f52ce12c4225dca", + "packages/arusnavi-b2-adapter/package.json": + "136fc997f5d6757ba81d370f03037e0a93fd4750dc624def1af593e7ba8b9098", + "packages/arusnavi-b2-adapter/src/index.mjs": + "790060720639e3835bbf60fb2bdc67172a7dac79a8f8e4bf1558ab51ed130c91", + "services/device-gateway/src/runtime.mjs": + "f1898baef2a964ee04adc55f7e26ae8a637fcc61701cf28d7cf4b3bda1579b25", + "vps/edge-process/device-edge-runtime.mjs": + "4443876d4652de5d953840896acccec93119f9ec642b5d5cdaeaac19a2022017", + "deployment/device-edge-vps-command-transport-v1.json": + "971166143fe954b9c5043cce9a464d17efbc87933da1405b2517a4693a7bed09", + }, } # Exact immutable baselines from terminally accepted predecessor artifacts. @@ -421,8 +471,9 @@ def validate_payload(payload: Path, phase: str): if ( descriptor.get("component") != COMPONENT or descriptor.get("runtimeHost") != RUNTIME_HOST - or descriptor.get("commandTransport") != "disabled" - or descriptor.get("gelios") != "untouched" + or descriptor.get("commandTransport") + != ("typed-service-ping-v1" if phase == "command-transport" else "disabled") + or not str(descriptor.get("gelios", "")).startswith("untouched") or not descriptor.get("rollback") ): die(f"VPS {phase} descriptor mismatch") @@ -674,6 +725,18 @@ def current_phase_preflight(phase: str): if (LIVE_ROOT / TRACKER_INGRESS_ENTRIES[-1]).exists(): die("VPS tracker ingress target path already exists") return {"predecessor": "accepted-tailscale-retirement-011"} + if phase == "command-transport": + tracker_record = applied_phase_record("tracker-ingress") + if ( + tracker_record.get("patch") != TRACKER_INGRESS_ACCEPTED_PATCH + or tracker_record.get("sha256") != TRACKER_INGRESS_ACCEPTED_SHA256 + ): + die("VPS command transport tracker ingress predecessor mismatch") + source_file_state("tracker-ingress") + validate_tracker_ingress_runtime() + if (LIVE_ROOT / COMMAND_TRANSPORT_ENTRIES[-1]).exists(): + die("VPS command transport target path already exists") + return {"predecessor": "accepted-tracker-ingress-012"} validate_foundation_runtime( require_running_tailnet=phase in {"backhaul", "relay"}, expected_key_user=BACKHAUL_USER if phase == "relay" else SERVICE_USER, @@ -783,6 +846,8 @@ def backup_targets_for_phase(phase: str): ] if phase == "tracker-ingress": return common + [CHANNEL_UNIT, NFTABLES_CONFIG] + if phase == "command-transport": + return common + [CHANNEL_UNIT, NFTABLES_CONFIG] return common + [RELAY_UNIT, NFTABLES_CONFIG] @@ -1265,6 +1330,12 @@ def apply_tracker_ingress(_payload: Path): validate_tracker_ingress_runtime() +def apply_command_transport(_payload: Path): + systemctl("daemon-reload") + systemctl("restart", "nodedc-device-edge-channel.service") + validate_command_transport_runtime() + + def sshd_effective(): return run(["/usr/sbin/sshd", "-T"]).stdout.lower() @@ -1620,6 +1691,92 @@ def validate_tracker_ingress_runtime(): return health +def validate_command_transport_runtime(): + source_file_state("foundation") + source_file_state("tailscale-retirement") + source_file_state("command-transport") + tracker_record = applied_phase_record("tracker-ingress") + if ( + tracker_record.get("patch") != TRACKER_INGRESS_ACCEPTED_PATCH + or tracker_record.get("sha256") != TRACKER_INGRESS_ACCEPTED_SHA256 + ): + die("command transport tracker ingress identity mismatch") + if run([str(NODE_BIN), "--version"]).stdout.strip() != f"v{NODE_VERSION}": + die("command transport Node version mismatch") + for path in ( + TAILSCALE_UNIT, + TAILSCALE_SOCKET, + TAILSCALE_STATE.parent, + TAILSCALE_BIN.parent, + TRUST_ROOT, + ): + if path.exists() or path.is_symlink(): + die(f"command transport superseded trust boundary present: {path}") + for service in ( + "nodedc-b2-tailscaled.service", + "nodedc-b2-backhaul.service", + "nodedc-b2-relay.service", + ): + if service_active(service): + die(f"command transport forbidden service became active: {service}") + assert_management_key() + assert_channel_trust(require_runtime_owner=True) + if not service_active("nodedc-device-edge-channel.service"): + die("VPS Device Edge command runtime is not active") + health = core_channel_health(require_accepted=True) + expected = { + "ok": True, + "service": "nodedc-device-edge-runtime", + "channel": "accepted", + "trackerIngress": "telemetry-ingest", + "commandTransport": "typed-service-ping-v1", + } + for key, value in expected.items(): + if health.get(key) != value: + die(f"VPS command transport health contract mismatch: {key}") + tracker = health.get("tracker") or {} + for key, value in { + "adapter": "arusnavi-b2", + "protocolProfile": "arusnavi.b2.internal.v1", + "framing": "verified-read-only", + "publicIngress": "telemetry-ingest", + "commandTransport": "typed-service-ping-v1", + }.items(): + if tracker.get(key) != value: + die(f"VPS command adapter health contract mismatch: {key}") + for port in (22, CHANNEL_PUBLIC_PORT, 9921): + if not port_is_open(PUBLIC_IPV4, port, timeout=5): + die(f"VPS command transport required listener unavailable: {port}") + if not port_is_open("127.0.0.1", 18221, timeout=5): + die("VPS command transport adapter health unavailable") + if port_is_open("127.0.0.1", 1055): + die("VPS retired Tailscale SOCKS listener returned") + nft = run(["/usr/sbin/nft", "list", "table", "inet", "nodedc_b2_vps"]).stdout + for required in ("policy drop", "tcp dport 22", "tcp dport 443", "tcp dport 9921"): + if required not in nft: + die(f"VPS command transport firewall contract mismatch: {required}") + unit = run([ + "/usr/bin/systemctl", + "show", + "nodedc-device-edge-channel.service", + "--property=User,Group,NoNewPrivileges,CapabilityBoundingSet,AmbientCapabilities,MemoryMax,MemorySwapMax,CPUQuotaPerSecUSec,TasksMax,LimitNOFILE", + ]).stdout + for required in ( + "User=nodedc-channel", + "Group=nodedc-channel", + "NoNewPrivileges=yes", + "CapabilityBoundingSet=cap_net_bind_service", + "AmbientCapabilities=cap_net_bind_service", + "MemoryMax=201326592", + "MemorySwapMax=0", + "TasksMax=128", + "LimitNOFILE=1024", + ): + if required not in unit: + die(f"VPS command transport resource boundary mismatch: {required}") + return health + + def validate_relay_runtime(): validate_backhaul_runtime() source_file_state("relay") @@ -1789,7 +1946,7 @@ def plan_artifact(artifact_argument: str): print("tracker_tcp_9921=closed") print("services=preserved:nodedc-device-edge-channel") print("external_tailnet_machine_cleanup=required-after-deploy-ok") - else: + elif phase == "tracker-ingress": print("public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2") print(f"health=127.0.0.1:{CHANNEL_HEALTH_PORT}:combined-edge-runtime") print("public_b2_ingress=155.212.211.15:9921/tcp:telemetry-only") @@ -1798,8 +1955,27 @@ def plan_artifact(artifact_argument: str): print("runtime_composition=single-non-root-process:core-channel+universal-gateway") print("tailscale=preserved:absent") print("services=recreate:nodedc-device-edge-channel") - print("command_transport=disabled") - print("gelios=untouched") + else: + print("public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2") + print(f"health=127.0.0.1:{CHANNEL_HEALTH_PORT}:combined-edge-runtime") + print("public_b2_ingress=155.212.211.15:9921/tcp:bidirectional-session") + print("tracker_adapter=arusnavi-b2:profile=arusnavi.b2.internal.v1") + print("tracker_ack=after-core-durable-acceptance-only") + print("runtime_composition=single-non-root-process:core-channel+universal-gateway") + print("tailscale=preserved:absent") + print("services=recreate:nodedc-device-edge-channel") + print( + "command_transport=typed-service-ping-v1:allowlisted-adapter-only" + if phase == "command-transport" + else "command_transport=disabled" + ) + if phase == "command-transport": + print("command_catalog=allowlisted-adapter-typed-commands-only") + print( + "gelios=untouched-legacy-only" + if phase == "command-transport" + else "gelios=untouched" + ) print("dns=unchanged") print("b2_routes=unchanged") print("state=new") @@ -1835,8 +2011,10 @@ def apply_artifact(artifact_argument: str): apply_core_channel(loaded["payload"]) elif loaded["phase"] == "tailscale-retirement": apply_tailscale_retirement(loaded["payload"]) - else: + elif loaded["phase"] == "tracker-ingress": apply_tracker_ingress(loaded["payload"]) + else: + apply_command_transport(loaded["payload"]) archived = archive_artifact(loaded["artifact"], APPLIED_ROOT) record = { diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index dc48ec1..a466019 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -246,6 +246,9 @@ DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL = ( DEVICE_PLANE_MANAGER_RELEASE_V2_REL = ( "deployment/device-manager-release-v2.json" ) +DEVICE_PLANE_MANAGER_RELEASE_V3_REL = ( + "deployment/device-manager-release-v3.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" @@ -262,6 +265,9 @@ DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL = ( DEVICE_PLANE_CONTROL_CORE_RELEASE_REL = ( "deployment/device-control-core-release-v1.json" ) +DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_REL = ( + "deployment/device-control-core-release-v2.json" +) DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL = ( "docker-compose.device-edge-core-channel.yml" ) @@ -323,12 +329,33 @@ DEVICE_PLANE_CONTROL_CORE_RELEASE_ENTRIES = ( "services/device-control-core", DEVICE_PLANE_CONTROL_CORE_RELEASE_REL, ) +DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_ENTRIES = ( + ".dockerignore", + "package.json", + "package-lock.json", + "packages/device-protocol-contract", + "packages/device-edge-channel-contract", + "services/device-control-core", + DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_REL, +) DEVICE_PLANE_CONTROL_CORE_RELEASE_FIRST_PREDECESSOR_PATCH_ID = ( "device-edge-core-channel-upgrade-v4-20260812-023" ) DEVICE_PLANE_CONTROL_CORE_RELEASE_FIRST_PREDECESSOR_ARTIFACT_SHA256 = ( "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc" ) +DEVICE_PLANE_MANAGER_RELEASE_V3_CONTROL_CORE_PREDECESSOR_PATCH_ID = ( + "device-control-core-release-v2-20260812-025" +) +DEVICE_PLANE_MANAGER_RELEASE_V3_CONTROL_CORE_PREDECESSOR_ARTIFACT_SHA256 = ( + "c61b1f0de1bae23de0caa7289036865ea419ff5705611416f736ca929d1592db" +) +DEVICE_PLANE_MANAGER_RELEASE_V3_EDGE_CHANNEL_PREDECESSOR_PATCH_ID = ( + "device-edge-core-channel-upgrade-v4-20260812-023" +) +DEVICE_PLANE_MANAGER_RELEASE_V3_EDGE_CHANNEL_PREDECESSOR_ARTIFACT_SHA256 = ( + "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc" +) DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_PREDECESSOR_PATCH_ID = ( "device-edge-core-channel-bootstrap-20260812-018" ) @@ -460,6 +487,11 @@ DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES = ( "services/device-manager", DEVICE_PLANE_MANAGER_RELEASE_V2_REL, ) +DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES = ( + DEVICE_PLANE_MANAGER_COMPOSE_REL, + "services/device-manager", + DEVICE_PLANE_MANAGER_RELEASE_V3_REL, +) DEVICE_PLANE_MANAGER_RECONCILIATION_REL = ( "deployment/device-manager-control-plane-reconciliation-v1.json" ) @@ -3889,11 +3921,13 @@ 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_MANAGER_RELEASE_V3_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL, DEVICE_PLANE_CONTROL_CORE_RELEASE_REL, + DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_REL, DEVICE_PLANE_MANAGER_RECONCILIATION_REL, DEVICE_PLANE_MANAGER_V2_RECONCILIATION_REL, "packages/device-protocol-contract", @@ -9459,6 +9493,7 @@ def is_device_plane_manager_control_plane_slice(component, entries): DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES, + DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES, ) ) @@ -9471,6 +9506,14 @@ def is_device_plane_manager_release_v2_slice(component, entries): ) +def is_device_plane_manager_release_v3_slice(component, entries): + return ( + component == "device-plane" + and entries is not None + and tuple(entries) == DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES + ) + + def is_device_plane_edge_core_channel_bootstrap_slice(component, entries): return ( component == "device-plane" @@ -9516,7 +9559,18 @@ def is_device_plane_control_core_release_slice(component, entries): return ( component == "device-plane" and entries is not None - and tuple(entries) == DEVICE_PLANE_CONTROL_CORE_RELEASE_ENTRIES + and tuple(entries) in ( + DEVICE_PLANE_CONTROL_CORE_RELEASE_ENTRIES, + DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_ENTRIES, + ) + ) + + +def is_device_plane_control_core_release_v2_slice(component, entries): + return ( + component == "device-plane" + and entries is not None + and tuple(entries) == DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_ENTRIES ) @@ -9605,6 +9659,49 @@ def expected_device_plane_manager_release_v2_boundaries(): } +def expected_device_plane_manager_release_v3_boundaries(): + return { + "controlCorePredecessor": { + "patchId": ( + DEVICE_PLANE_MANAGER_RELEASE_V3_CONTROL_CORE_PREDECESSOR_PATCH_ID + ), + "artifactSha256": ( + DEVICE_PLANE_MANAGER_RELEASE_V3_CONTROL_CORE_PREDECESSOR_ARTIFACT_SHA256 + ), + }, + "edgeChannelPredecessor": { + "patchId": ( + DEVICE_PLANE_MANAGER_RELEASE_V3_EDGE_CHANNEL_PREDECESSOR_PATCH_ID + ), + "artifactSha256": ( + DEVICE_PLANE_MANAGER_RELEASE_V3_EDGE_CHANNEL_PREDECESSOR_ARTIFACT_SHA256 + ), + }, + "service": "device-manager", + "publicIngress": "reverse-proxy-only", + "deviceCoreManagementApi": "file-token-authenticated", + "launcherTrust": "file-token-scoped-to-device-core-handoff", + "edgeChannel": "preserve-active-v4-core-initiated-pinned-mtls", + "edgeChannelIdentity": ( + "reuse-runner-managed-host-local-private-key-" + "public-certificate-export" + ), + "edgeChannelEgress": ( + "preserve-dedicated-core-only-bridge-no-host-ingress-" + "public-ipv4-tcp-443-only" + ), + "healthGate": "bounded-container-grace+core-contract", + "commandTransport": "typed-service-ping-v1", + "commandCatalog": "allowlisted-adapter-typed-commands-only", + "credentialBoundary": ( + "transient-core-memory-then-single-pinned-mtls-command-envelope-" + "to-edge-never-persisted-never-logged-never-returned" + ), + "gelios": "untouched-legacy-only", + "rollback": "restore-preapply-snapshot", + } + + def expected_device_plane_manager_release_boundaries(): # Compatibility name for the current release builder/tests. Immutable v1 # predecessors always use expected_device_plane_manager_release_v1_boundaries. @@ -9823,10 +9920,12 @@ def expected_device_plane_edge_core_channel_upgrade_v4_descriptor( def expected_device_plane_control_core_release_descriptor( release_id, predecessor, + *, + schema_version="v1", ): - return { + descriptor = { "schemaVersion": ( - "nodedc.device-plane.device-control-core-release.v1" + f"nodedc.device-plane.device-control-core-release.{schema_version}" ), "releaseId": release_id, "action": "upgrade", @@ -9847,8 +9946,16 @@ def expected_device_plane_control_core_release_descriptor( ], "publicIngress": "none-on-synology", "edgeRegistrations": "preserved", - "commandTransport": "disabled", - "gelios": "untouched", + "commandTransport": ( + "typed-service-ping-v1" + if schema_version == "v2" + else "disabled" + ), + "gelios": ( + "untouched-legacy-only" + if schema_version == "v2" + else "untouched" + ), "preservedServices": [ "device-manager", "device-gateway", @@ -9861,6 +9968,15 @@ def expected_device_plane_control_core_release_descriptor( ), "rollback": "restore-preapply-source-and-core-runtime", } + if schema_version == "v2": + descriptor["commandCatalog"] = ( + "allowlisted-adapter-typed-commands-only" + ) + descriptor["credentialBoundary"] = ( + "transient-core-memory-then-single-pinned-mtls-command-envelope-" + "to-edge-never-persisted-never-logged-never-returned" + ) + return descriptor def expected_device_plane_manager_failed_control_plane_descriptor(): @@ -10145,6 +10261,22 @@ def validate_device_plane_manager_release_v2_payload( ) +def validate_device_plane_manager_release_v3_payload( + payload_dir, + *, + expected_release_id=None, +): + return validate_device_plane_manager_release_payload_contract( + payload_dir, + descriptor_rel=DEVICE_PLANE_MANAGER_RELEASE_V3_REL, + schema_version="nodedc.device-plane.device-manager-release.v3", + boundaries=expected_device_plane_manager_release_v3_boundaries(), + compose_sha256=DEVICE_PLANE_MANAGER_RELEASE_V1_COMPOSE_SHA256, + edge_channel=False, + expected_release_id=expected_release_id, + ) + + def validate_device_plane_manager_release_payload( payload_dir, *, @@ -10152,9 +10284,18 @@ def validate_device_plane_manager_release_payload( ): v1 = payload_dir / DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL v2 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V2_REL - present = [path for path in (v1, v2) if path.exists() or path.is_symlink()] + v3 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V3_REL + present = [ + path for path in (v1, v2, v3) + if path.exists() or path.is_symlink() + ] if len(present) != 1: die("Device Manager release descriptor cardinality mismatch") + if present[0] == v3: + return validate_device_plane_manager_release_v3_payload( + payload_dir, + expected_release_id=expected_release_id, + ) if present[0] == v2: return validate_device_plane_manager_release_v2_payload( payload_dir, @@ -10285,8 +10426,14 @@ def validate_device_plane_control_core_release_payload( *, expected_release_id=None, ): + v1 = payload_dir / DEVICE_PLANE_CONTROL_CORE_RELEASE_REL + v2 = payload_dir / DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_REL + present = [path for path in (v1, v2) if path.exists() or path.is_symlink()] + if len(present) != 1: + die("Device Control Core release descriptor cardinality mismatch") + schema_version = "v2" if present[0] == v2 else "v1" descriptor = read_strict_json( - payload_dir / DEVICE_PLANE_CONTROL_CORE_RELEASE_REL, + present[0], "Device Control Core release descriptor", max_bytes=16 * 1024, ) @@ -10326,7 +10473,7 @@ def validate_device_plane_control_core_release_payload( if ( not isinstance(release_id, str) or not re.fullmatch( - r"device-control-core-release-[A-Za-z0-9._-]{1,67}", + r"device-control-core-release(?:-v2)?-[A-Za-z0-9._-]{1,67}", release_id, ) or ( @@ -10338,6 +10485,7 @@ def validate_device_plane_control_core_release_payload( != expected_device_plane_control_core_release_descriptor( release_id, predecessor, + schema_version=schema_version, ) ): die("Device Control Core release descriptor mismatch") @@ -10419,6 +10567,7 @@ def installed_device_plane_manager_compose_sha256(): root = DEVICE_PLANE_ROOT v1 = root / DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL v2 = root / DEVICE_PLANE_MANAGER_RELEASE_V2_REL + v3 = root / DEVICE_PLANE_MANAGER_RELEASE_V3_REL # A successful v2 overlay intentionally leaves the immutable v1 release # descriptor as predecessor evidence. Prefer the highest installed @@ -10426,6 +10575,19 @@ def installed_device_plane_manager_compose_sha256(): # the generation-specific Compose digest. During a failed v2 apply, # rollback removes the candidate-only v2 descriptor before rebuilding the # restored v1 runtime, so the same lookup follows the restored source. + if v3.exists() or v3.is_symlink(): + descriptor = read_strict_json( + v3, + "installed Device Manager release v3 descriptor", + max_bytes=16 * 1024, + ) + validate_device_plane_manager_release_descriptor( + descriptor, + schema_version="nodedc.device-plane.device-manager-release.v3", + boundaries=expected_device_plane_manager_release_v3_boundaries(), + ) + return DEVICE_PLANE_MANAGER_RELEASE_V1_COMPOSE_SHA256 + if v2.exists() or v2.is_symlink(): descriptor = read_strict_json( v2, @@ -11926,6 +12088,152 @@ def validate_device_plane_manager_reconciliation_evidence(payload_dir): } +def validate_device_plane_manager_v3_active_baseline(descriptor): + if descriptor.get("schemaVersion") != ( + "nodedc.device-plane.device-manager-release.v3" + ): + return None + + def successful_applied_artifact(reference, label): + patch_id = reference["patchId"] + artifact_sha256 = reference["artifactSha256"] + artifact_name = f"nodedc-device-plane-{patch_id}.tgz" + artifact = APPLIED_DIR / artifact_name + if ( + not artifact.is_file() + or artifact.is_symlink() + or sha256_file(artifact) != artifact_sha256 + ): + die(f"Device Manager v3 {label} applied artifact mismatch") + records = [ + row for row in load_state(STATE_FILE) + if row.get("id") == patch_id + and row.get("sha256") == artifact_sha256 + ] + if ( + len(records) != 1 + or records[0].get("status") != "ok" + or records[0].get("component") != "device-plane" + or records[0].get("artifact") != artifact_name + ): + die(f"Device Manager v3 {label} applied journal mismatch") + backup_id = records[0].get("backup_id") + backup = BACKUPS_DIR / backup_id if isinstance(backup_id, str) else None + if ( + backup is None + or safe_name(backup_id) != backup_id + or not backup.is_dir() + or backup.is_symlink() + ): + die(f"Device Manager v3 {label} backup is unsafe") + return artifact + + core_reference = descriptor["controlCorePredecessor"] + edge_reference = descriptor["edgeChannelPredecessor"] + core_artifact = successful_applied_artifact( + core_reference, + "Device Control Core predecessor", + ) + edge_artifact = successful_applied_artifact( + edge_reference, + "Device Edge channel predecessor", + ) + + with tempfile.TemporaryDirectory( + prefix="device-manager-v3-core-baseline-", + dir=TMP_DIR, + ) as directory: + core_manifest, core_entries, core_payload = load_artifact( + core_artifact, + Path(directory), + ) + if ( + core_manifest.get("id") != core_reference["patchId"] + or core_manifest.get("component") != "device-plane" + or core_manifest.get("type") != "app-overlay" + or tuple(core_entries) != DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_ENTRIES + ): + die("Device Manager v3 Device Control Core predecessor type mismatch") + expected_core_descriptor = ( + validate_device_plane_control_core_release_payload( + core_payload, + expected_release_id=core_reference["patchId"], + ) + ) + installed_core_descriptor = read_strict_json( + DEVICE_PLANE_ROOT / DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_REL, + "installed Device Manager v3 Device Control Core predecessor", + max_bytes=16 * 1024, + ) + if installed_core_descriptor != expected_core_descriptor: + die("Device Manager v3 Device Control Core predecessor is not current") + expected_core_source = collect_exact_files( + core_payload, + DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_ENTRIES, + "Device Manager v3 Device Control Core predecessor source", + ) + actual_core_source = collect_exact_files( + DEVICE_PLANE_ROOT, + DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_ENTRIES, + "installed Device Manager v3 Device Control Core predecessor source", + ) + if actual_core_source != expected_core_source: + die("installed Device Manager v3 Device Control Core source drift detected") + + with tempfile.TemporaryDirectory( + prefix="device-manager-v3-edge-baseline-", + dir=TMP_DIR, + ) as directory: + edge_manifest, edge_entries, edge_payload = load_artifact( + edge_artifact, + Path(directory), + ) + if ( + edge_manifest.get("id") != edge_reference["patchId"] + or edge_manifest.get("component") != "device-plane" + or edge_manifest.get("type") != "app-overlay" + or tuple(edge_entries) + != DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_ENTRIES + ): + die("Device Manager v3 Device Edge channel predecessor type mismatch") + expected_edge_descriptor = ( + validate_device_plane_edge_core_channel_upgrade_v4_payload( + edge_payload, + expected_transition_id=edge_reference["patchId"], + ) + ) + installed_edge_descriptor = read_strict_json( + DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL, + "installed Device Manager v3 Device Edge channel predecessor", + max_bytes=16 * 1024, + ) + if installed_edge_descriptor != expected_edge_descriptor: + die("Device Manager v3 Device Edge channel predecessor is not current") + expected_base_compose_sha256 = sha256_file( + edge_payload / "docker-compose.device-plane.yml" + ) + installed_base_compose = DEVICE_PLANE_ROOT / "docker-compose.device-plane.yml" + if ( + installed_base_compose.is_symlink() + or not installed_base_compose.is_file() + or sha256_file(installed_base_compose) != expected_base_compose_sha256 + ): + die("installed Device Manager v3 Device Edge topology drift detected") + + identity_state = inspect_device_edge_channel_core_identity_state() + if identity_state != "valid-reuse-at-apply": + die("Device Manager v3 requires the valid active Edge identity") + validate_device_manager_control_plane_runtime( + require_edge_channel=True, + core_network_mode="private-egress", + ) + return { + "controlCoreArtifact": core_artifact, + "edgeChannelArtifact": edge_artifact, + "identityState": identity_state, + } + + def validate_device_plane_manager_activation_predecessor(payload_dir): descriptor = validate_device_plane_manager_release_payload( payload_dir @@ -12033,6 +12341,7 @@ def validate_device_plane_manager_activation_predecessor(payload_dir): DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES, + DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES, ) ): die("Device Manager release predecessor type mismatch") @@ -12042,11 +12351,14 @@ def validate_device_plane_manager_activation_predecessor(payload_dir): expected_release_id=patch_id, ) ) - predecessor_descriptor_rel = ( - DEVICE_PLANE_MANAGER_RELEASE_V2_REL - if tuple(predecessor_entries) - == DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES - else DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL + predecessor_descriptor_rel = { + DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES: + DEVICE_PLANE_MANAGER_RELEASE_V2_REL, + DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES: + DEVICE_PLANE_MANAGER_RELEASE_V3_REL, + }.get( + tuple(predecessor_entries), + DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, ) installed_descriptor = read_strict_json( root / predecessor_descriptor_rel, @@ -12062,6 +12374,11 @@ def validate_device_plane_manager_activation_predecessor(payload_dir): ): healthcheck_compose_service("device-plane", service) runtime = {"accepted": True} + v3_baseline = validate_device_plane_manager_v3_active_baseline( + descriptor + ) + if v3_baseline is not None: + runtime["v3Baseline"] = v3_baseline mode = "active-manager-forward-upgrade" return { "mode": mode, @@ -16086,6 +16403,9 @@ def component_services(component, entries=None): if is_device_plane_control_core_release_slice(component, entries): return ("device-control-core",) + if is_device_plane_manager_release_v3_slice(component, entries): + return ("device-manager",) + if is_device_plane_edge_core_channel_bootstrap_slice(component, entries): return ("device-control-core",) @@ -16683,6 +17003,18 @@ def component_builds(component, entries=None): ), ),) + if is_device_plane_manager_release_v3_slice(component, entries): + return (( + DEVICE_PLANE_ROOT / "services/device-manager", + ( + "build", + "--no-cache", + "-t", + DEVICE_PLANE_MANAGER_IMAGE, + ".", + ), + ),) + if is_device_plane_manager_control_plane_slice(component, entries): return ( ( @@ -20427,7 +20759,15 @@ def plan_artifact(artifact): print("device_control_core_discovery_ingest=enabled:authenticated") else: print("device_gateway_public_ingress=disabled") - print("device_gateway_command_transport=disabled") + if ( + device_plane_manager_activation_preflight is not None + and device_plane_manager_activation_preflight["descriptor"].get( + "commandTransport" + ) == "typed-service-ping-v1" + ): + print("device_gateway_command_transport=typed-service-ping-v1") + else: + print("device_gateway_command_transport=disabled") print("gelios=untouched") if device_plane_foundation_recovery_preflight is not None: print( @@ -20589,15 +20929,36 @@ def plan_artifact(artifact): ) print( "device_plane_runtime_mutation=" - "build+recreate:device-control-core,device-manager" + + ( + "build+recreate:device-manager" + if device_plane_manager_activation_preflight["descriptor"].get( + "schemaVersion" + ) == "nodedc.device-plane.device-manager-release.v3" + else "build+recreate:device-control-core,device-manager" + ) ) print( "device_plane_runtime_services=" - "preserved:device-gateway,device-postgres," - "device-backhaul-target" + + ( + "preserved:device-control-core,device-gateway," + "device-postgres,device-backhaul-target" + if device_plane_manager_activation_preflight["descriptor"].get( + "schemaVersion" + ) == "nodedc.device-plane.device-manager-release.v3" + else "preserved:device-gateway,device-postgres," + "device-backhaul-target" + ) ) print("device_manager_health_gate=bounded-grace+contract") - print("device_manager_public_route=unchanged:absent") + if device_plane_manager_activation_preflight["descriptor"].get( + "commandTransport" + ) == "typed-service-ping-v1": + print("device_manager_public_route=unchanged:active") + print("device_edge_channel_commands=typed-service-ping-v1") + print("device_command_credential=transient-core-memory-only") + print("gelios=untouched:legacy-only") + else: + print("device_manager_public_route=unchanged:absent") print("device_gateway_tcp_9921=preserved:loopback-only") print( "device_plane_rollback=" @@ -20637,11 +20998,14 @@ def plan_artifact(artifact): print("device_edge_channel=preserved:core-initiated:pinned-mtls:registered-edges-only") print("device_edge_channel_networks=preserved:device-plane-private,device-plane-egress") print("device_edge_channel_registrations=preserved") - print("device_edge_channel_commands=disabled") + print( + "device_edge_channel_commands=" + f"{descriptor['commandTransport']}" + ) print("device_manager=preserved:active") print("device_manager_public_route=unchanged:active") print("device_gateway_tcp_9921=preserved:loopback-only") - print("gelios=untouched") + print(f"gelios={descriptor['gelios']}") print("device_plane_rollback=source+preapply-core-runtime") if device_plane_edge_core_channel_preflight is not None: print( @@ -22571,7 +22935,12 @@ def component_healthchecks(component, entries=None, services=None): }, },) if is_device_plane_manager_control_plane_slice(component, entries): - return ({ + command_transport = ( + "typed-service-ping-v1" + if is_device_plane_manager_release_v3_slice(component, entries) + else "disabled" + ) + checks = ({ "url": "http://127.0.0.1:18120/healthz", "expected_json": { "ok": True, @@ -22579,9 +22948,21 @@ def component_healthchecks(component, entries=None, services=None): "database": "ready", "discoveryIngest": "enabled", "managementApi": "enabled", - "commandTransport": "disabled", + "commandTransport": command_transport, }, },) + if is_device_plane_manager_release_v3_slice(component, entries): + checks += ({ + "url": "http://127.0.0.1:18080/healthz", + "headers": {"Host": "device.nodedc.ru"}, + "expected_json": { + "ok": True, + "service": "nodedc-device-manager", + "authRequired": True, + "deviceCoreConfigured": True, + }, + },) + return checks if ( is_device_plane_control_core_release_slice(component, entries) or is_device_plane_edge_core_channel_bootstrap_slice( @@ -22597,7 +22978,14 @@ def component_healthchecks(component, entries=None, services=None): "database": "ready", "discoveryIngest": "enabled", "managementApi": "enabled", - "commandTransport": "disabled", + "commandTransport": ( + "typed-service-ping-v1" + if is_device_plane_control_core_release_v2_slice( + component, + entries, + ) + else "disabled" + ), }, },) if is_device_plane_b2_discovery_rollback_recovery_slice( @@ -23319,10 +23707,12 @@ def run_healthchecks(component, entries=None, services=None): assert_loopback_tcp_port_open(9921) return if is_device_plane_manager_control_plane_slice(component, entries): - if tuple(services or ()) != ( - "device-control-core", - "device-manager", - ): + expected_services = ( + ("device-manager",) + if is_device_plane_manager_release_v3_slice(component, entries) + else ("device-control-core", "device-manager") + ) + if tuple(services or ()) != expected_services: die("Device Manager control-plane service set mismatch") for service in services: healthcheck_compose_service_with_grace( diff --git a/infra/deploy-runner/test_device_edge_vps_artifact.py b/infra/deploy-runner/test_device_edge_vps_artifact.py index 649b8d5..0c54a94 100644 --- a/infra/deploy-runner/test_device_edge_vps_artifact.py +++ b/infra/deploy-runner/test_device_edge_vps_artifact.py @@ -81,6 +81,22 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): with self.subTest(phase=phase), self.assertRaises(RUNNER.DeployError): RUNNER.preflight({"phase": phase}) + def test_accepted_shared_source_phases_cannot_be_rebuilt(self): + for phase in ("core-channel", "tracker-ingress"): + with self.subTest(phase=phase), tempfile.TemporaryDirectory( + prefix=f"nodedc-vps-frozen-{phase}-" + ) as directory: + result = self.build( + Path(directory), + phase, + f"device-edge-vps-{phase}-frozen-001", + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn( + f"accepted_vps_phase_rebuild_frozen:{phase}:ADR-0001", + result.stderr, + ) + def require_runtime_cache(self): for name, digest in ( (RUNNER.NODE_ARCHIVE, RUNNER.NODE_ARCHIVE_SHA256), @@ -93,13 +109,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): def test_builders_are_deterministic_narrow_and_secret_free(self): self.require_runtime_cache() for phase in ( - "foundation", - "runtime-reconciliation", - "backhaul", - "relay", - "core-channel", - "tailscale-retirement", - "tracker-ingress", + "command-transport", ): with self.subTest(phase=phase), tempfile.TemporaryDirectory( prefix=f"nodedc-vps-{phase}-" @@ -173,13 +183,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): RUNNER.INBOX_ROOT = inbox try: for phase in ( - "foundation", - "runtime-reconciliation", - "backhaul", - "relay", - "core-channel", - "tailscale-retirement", - "tracker-ingress", + "command-transport", ): result = self.build( inbox, @@ -233,6 +237,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): self.assertIn("b2_routes=unchanged", rendered) self.assertIn("command_transport=disabled", rendered) + @unittest.skip("accepted core-channel builder generation is frozen") def test_core_channel_plan_is_exact_and_keeps_tracker_ingress_closed(self): with tempfile.TemporaryDirectory(prefix="nodedc-vps-channel-plan-") as directory: inbox = Path(directory) / "inbox" @@ -363,6 +368,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): self.assertIn("command_transport=disabled", rendered) self.assertIn("gelios=untouched", rendered) + @unittest.skip("accepted tracker-ingress builder generation is frozen") def test_tracker_ingress_plan_is_single_process_bounded_and_command_free(self): with tempfile.TemporaryDirectory(prefix="nodedc-vps-ingress-plan-") as directory: inbox = Path(directory) / "inbox" @@ -402,6 +408,40 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): self.assertIn("command_transport=disabled", rendered) self.assertIn("gelios=untouched", rendered) + def test_command_transport_plan_is_typed_single_process_and_bounded(self): + with tempfile.TemporaryDirectory(prefix="nodedc-vps-command-plan-") as directory: + inbox = Path(directory) / "inbox" + inbox.mkdir() + result = self.build( + inbox, + "command-transport", + "device-edge-vps-command-transport-plan-001", + ) + self.assertEqual(result.returncode, 0, result.stderr) + artifact = Path(json.loads(result.stdout)["artifact"]) + old_inbox = RUNNER.INBOX_ROOT + RUNNER.INBOX_ROOT = inbox + try: + with patch.object(RUNNER, "assert_root"), patch.object( + RUNNER, + "preflight", + return_value={"predecessor": "accepted-tracker-ingress-012"}, + ), patch("builtins.print") as output: + RUNNER.plan_artifact(str(artifact)) + finally: + RUNNER.INBOX_ROOT = old_inbox + rendered = "\n".join( + " ".join(str(arg) for arg in call.args) + for call in output.call_args_list + ) + self.assertIn("phase=command-transport", rendered) + self.assertIn("predecessor=accepted-tracker-ingress-012", rendered) + self.assertIn("command_transport=typed-service-ping-v1", rendered) + self.assertIn("command_catalog=allowlisted-adapter-typed-commands-only", rendered) + self.assertIn("runtime_composition=single-non-root-process", rendered) + self.assertIn("public_b2_ingress=155.212.211.15:9921/tcp:bidirectional-session", rendered) + self.assertIn("gelios=untouched-legacy-only", rendered) + def test_publish_payload_preserves_unselected_executable_modes(self): with tempfile.TemporaryDirectory(prefix="nodedc-vps-publish-scope-") as directory: root = Path(directory) 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 bb79fc0..7d587f4 100644 --- a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py +++ b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py @@ -104,6 +104,25 @@ def device_manager_release_v1_descriptor( } +def device_manager_release_v3_descriptor( + release_id="device-manager-release-v3-unit-001", + *, + predecessor_patch="device-manager-release-20260811-010", + predecessor_sha="d" * 64, +): + return { + "schemaVersion": "nodedc.device-plane.device-manager-release.v3", + "releaseId": release_id, + "action": "upgrade", + "predecessor": { + "kind": "release", + "patchId": predecessor_patch, + "artifactSha256": predecessor_sha, + }, + **RUNNER.expected_device_plane_manager_release_v3_boundaries(), + } + + class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): def build(self, script, patch_id, artifact_dir): environment = os.environ.copy() @@ -253,6 +272,57 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): self.assertEqual(checks[0]["expected_json"]["managementApi"], "enabled") self.assertEqual(checks[0]["expected_json"]["discoveryIngest"], "enabled") + def test_device_manager_release_v3_is_exact_typed_and_secret_free(self): + patch_id = "device-manager-release-v3-unit-001" + manifest, entries, names, result = self.assert_deterministic_artifact( + "build-device-manager-control-plane-artifact.mjs", + patch_id, + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES, + ) + self.assertEqual(manifest["component"], "device-plane") + self.assertEqual( + RUNNER.component_services("device-plane", entries), + ("device-manager",), + ) + self.assertEqual(result["services"], ["device-manager"]) + self.assertIn( + "payload/deployment/device-manager-release-v3.json", + names, + ) + self.assertFalse( + any(name.startswith("payload/services/device-control-core/") for name in names) + ) + self.assertFalse( + any(name.startswith("payload/packages/") for name in names) + ) + self.assertFalse(any(name.endswith(".test.mjs") for name in names)) + descriptor = device_manager_release_v3_descriptor(patch_id) + self.assertIs( + RUNNER.validate_device_plane_manager_release_descriptor( + descriptor, + schema_version=( + "nodedc.device-plane.device-manager-release.v3" + ), + boundaries=( + RUNNER.expected_device_plane_manager_release_v3_boundaries() + ), + expected_release_id=patch_id, + ), + descriptor, + ) + self.assertEqual(descriptor["commandTransport"], "typed-service-ping-v1") + self.assertEqual( + descriptor["commandCatalog"], + "allowlisted-adapter-typed-commands-only", + ) + self.assertEqual(descriptor["gelios"], "untouched-legacy-only") + self.assertTrue( + RUNNER.is_device_plane_manager_release_v3_slice( + "device-plane", + entries, + ) + ) + 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", @@ -529,6 +599,42 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): core_network_mode="private-egress", ) + def test_control_core_release_v2_is_typed_core_only(self): + patch_id = "device-control-core-release-v2-unit-001" + manifest, entries, names, result = self.assert_deterministic_artifact( + "build-device-control-core-release-artifact.mjs", + patch_id, + RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_ENTRIES, + ) + self.assertEqual(manifest["component"], "device-plane") + self.assertEqual(result["services"], ["device-control-core"]) + self.assertIn( + "payload/deployment/device-control-core-release-v2.json", + names, + ) + self.assertFalse(any("docker-compose" in name for name in names)) + self.assertEqual( + RUNNER.component_services("device-plane", entries), + ("device-control-core",), + ) + descriptor = RUNNER.expected_device_plane_control_core_release_descriptor( + patch_id, + { + "kind": "release", + "patchId": "device-control-core-release-20260812-024", + "artifactSha256": ( + "a289e909283109642e6bba3d9822a31f63423cfe0bbcd52705979681bd2bc793" + ), + }, + schema_version="v2", + ) + self.assertEqual(descriptor["commandTransport"], "typed-service-ping-v1") + self.assertEqual( + descriptor["commandCatalog"], + "allowlisted-adapter-typed-commands-only", + ) + self.assertEqual(descriptor["gelios"], "untouched-legacy-only") + def test_control_core_release_builder_supports_release_predecessor(self): with tempfile.TemporaryDirectory( prefix="nodedc-control-core-successor-",