fix(manager): persist environment presentation and media

This commit is contained in:
DCCONSTRUCTIONS
2026-08-22 09:47:57 +03:00
parent 40cd637f72
commit 506d786ee6
7 changed files with 294 additions and 12 deletions
@@ -14,12 +14,21 @@ const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(sc
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 descriptorPath = patchId.startsWith("device-manager-release-v3-")
? "deployment/device-manager-release-v3.json"
: "deployment/device-manager-release-v1.json";
const descriptorPath = patchId.startsWith("device-manager-release-v4-")
? "deployment/device-manager-release-v4.json"
: 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 ? [
const isV4 = descriptorPath.endsWith("release-v4.json");
const isManagerOnly = isV3 || isV4;
const composeSource = resolve(devicePlaneRoot, "docker-compose.device-manager.yml");
const composeSourceSha256 = createHash("sha256").update(await readFile(composeSource)).digest("hex");
if (!isV4 && composeSourceSha256 !== "4954120aaddc999798b64c304d8cf692b79714feb727d873117bd1f3434e865e") {
throw new Error("historical_device_manager_compose_has_advanced");
}
const entries = isManagerOnly ? [
"docker-compose.device-manager.yml",
"services/device-manager",
descriptorPath,
@@ -70,7 +79,7 @@ try {
}
await copySafe(resolve(devicePlaneRoot, entry), join(payload, entry), devicePlaneRoot);
}
if (!isV3) {
if (!isManagerOnly) {
await validateDockerCopySources(
join(payload, "services/device-control-core/Dockerfile"),
payload,
@@ -80,7 +89,7 @@ try {
join(payload, "services/device-manager/Dockerfile"),
join(payload, "services/device-manager"),
);
for (const modulePath of isV3 ? [] : [
for (const modulePath of isManagerOnly ? [] : [
"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",
@@ -111,6 +120,14 @@ try {
"NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token",
"NODEDC_DEVICE_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token",
"name: nodedc-platform_edge",
...(isV4 ? [
"NODEDC_DEVICE_MANAGER_PRESENTATION_PATH: /var/lib/nodedc-device-manager/device-manager-presentation.json",
"NODEDC_DEVICE_MANAGER_MEDIA_ROOT: /var/lib/nodedc-device-manager/media",
"source: /volume1/docker/nodedc-device-plane/data/device-manager",
"target: /var/lib/nodedc-device-manager",
"read_only: false",
"create_host_path: false",
] : []),
]) if (!compose.includes(required)) throw new Error(`device_manager_compose_contract_missing:${required}`);
for (const forbidden of [
"NODEDC_INTERNAL_ACCESS_TOKEN:",
@@ -135,11 +152,36 @@ try {
|| !/^[A-Za-z0-9._-]{1,96}$/.test(predecessor.patchId || "")
|| !/^[a-f0-9]{64}$/.test(predecessor.artifactSha256 || "")
|| (descriptor.action === "activate") !== (predecessor.kind === "reconciliation")
|| descriptor.healthGate !== "bounded-container-grace+core-contract"
|| descriptor.rollback !== "restore-preapply-snapshot"
|| descriptor.healthGate !== (isV4
? "bounded-container-grace+core-contract+persistent-data"
: "bounded-container-grace+core-contract")
|| descriptor.rollback !== (isV4
? "restore-preapply-snapshot-preserve-manager-data"
: "restore-preapply-snapshot")
);
if (commonContractInvalid) throw new Error("device_manager_activation_successor_contract_mismatch");
if (descriptorPath.endsWith("release-v3.json")) {
if (descriptorPath.endsWith("release-v4.json")) {
if (
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v4"
|| 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-20260821-030"
|| descriptor.controlCorePredecessor?.artifactSha256 !== "8459521a662541a5a87cb0188991cdcfb51727427db8ec2a232ce4846bfc3454"
|| descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023"
|| descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|| descriptor.presentationPersistence !== "runner-managed-host-data-bind"
|| descriptor.presentationDataHostPath !== "/volume1/docker/nodedc-device-plane/data/device-manager"
|| descriptor.presentationDataContainerPath !== "/var/lib/nodedc-device-manager"
|| descriptor.presentationDataOwnership !== "uid-1000-gid-1000-mode-0750"
|| descriptor.presentationDataLifecycle !== "preserve-across-manager-recreate-and-source-rollback"
|| descriptor.presentationPath !== "/var/lib/nodedc-device-manager/device-manager-presentation.json"
|| descriptor.mediaRoot !== "/var/lib/nodedc-device-manager/media"
|| descriptor.defaultAccentHex !== "#f5f5f5"
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|| descriptor.gelios !== "untouched-legacy-only"
) throw new Error("device_manager_v4_persistence_contract_mismatch");
} else if (descriptorPath.endsWith("release-v3.json")) {
if (
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v3"
|| descriptor.commandTransport !== "typed-service-ping-v1"
@@ -173,7 +215,7 @@ try {
artifact: target,
sha256,
entries,
services: isV3 ? ["device-manager"] : ["device-control-core", "device-manager"],
services: isManagerOnly ? ["device-manager"] : ["device-control-core", "device-manager"],
preserved: ["device-postgres", "device-gateway", "device-backhaul-target", "Gelios"],
}, null, 2));
} finally {
@@ -137,6 +137,13 @@ def device_manager_release_v3_descriptor(
class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
def historical_manager_compose_is_current(self):
compose = DEVICE_CORE_ROOT / "docker-compose.device-manager.yml"
return (
hashlib.sha256(compose.read_bytes()).hexdigest()
== RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V1_COMPOSE_SHA256
)
def build(self, script, patch_id, artifact_dir):
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
@@ -221,6 +228,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
self.assertEqual(result["services"], ["launcher"])
def test_device_manager_artifact_selects_only_core_and_manager(self):
if not self.historical_manager_compose_is_current():
self.skipTest("historical Manager v1 Compose has advanced to v4")
manifest, entries, names, result = self.assert_deterministic_artifact(
"build-device-manager-control-plane-artifact.mjs",
"device-manager-control-plane-unit-001",
@@ -293,6 +302,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
self.assertEqual(checks[0]["expected_json"]["discoveryIngest"], "enabled")
def test_device_manager_release_v3_is_exact_typed_and_secret_free(self):
if not self.historical_manager_compose_is_current():
self.skipTest("historical Manager v3 Compose has advanced to v4")
patch_id = "device-manager-release-v3-unit-001"
manifest, entries, names, result = self.assert_deterministic_artifact(
"build-device-manager-control-plane-artifact.mjs",
@@ -360,6 +371,111 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
)
)
def test_device_manager_release_v4_is_persistent_manager_only_and_white(self):
patch_id = "device-manager-release-v4-unit-001"
manifest, entries, names, result = self.assert_deterministic_artifact(
"build-device-manager-control-plane-artifact.mjs",
patch_id,
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V4_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-v4.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))
template = json.loads(
(
DEVICE_CORE_ROOT
/ "deployment/device-manager-release-v4.json"
).read_text(encoding="utf-8")
)
descriptor = {
**template,
"releaseId": patch_id,
}
self.assertIs(
RUNNER.validate_device_plane_manager_release_descriptor(
descriptor,
schema_version=(
"nodedc.device-plane.device-manager-release.v4"
),
boundaries=(
RUNNER.expected_device_plane_manager_release_v4_boundaries()
),
expected_release_id=patch_id,
),
descriptor,
)
self.assertEqual(descriptor["defaultAccentHex"], "#f5f5f5")
self.assertEqual(
descriptor["predecessor"],
{
"kind": "release",
"patchId": "device-manager-release-v3-20260822-032",
"artifactSha256": (
"6e0eb3a0a6f19ceab92d46832b93bffbcea21247dbdc2ea50625a51ff460e4ca"
),
},
)
compose = (
DEVICE_CORE_ROOT / "docker-compose.device-manager.yml"
).read_text(encoding="utf-8")
for expected in (
"NODEDC_DEVICE_MANAGER_PRESENTATION_PATH: "
"/var/lib/nodedc-device-manager/device-manager-presentation.json",
"NODEDC_DEVICE_MANAGER_MEDIA_ROOT: "
"/var/lib/nodedc-device-manager/media",
"source: /volume1/docker/nodedc-device-plane/data/device-manager",
"target: /var/lib/nodedc-device-manager",
"read_only: false",
"create_host_path: false",
):
self.assertIn(expected, compose)
self.assertTrue(
RUNNER.is_device_plane_manager_release_v4_slice(
"device-plane",
entries,
)
)
def test_historical_manager_builder_fails_closed_after_v4_compose(self):
if self.historical_manager_compose_is_current():
self.skipTest("historical Manager Compose is still current")
with tempfile.TemporaryDirectory(
prefix="nodedc-device-manager-historical-reject-",
) as directory:
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = directory
completed = subprocess.run(
[
"node",
str(
SCRIPT_DIR
/ "build-device-manager-control-plane-artifact.mjs"
),
"device-manager-release-v3-rebuild-forbidden-001",
],
cwd=DEVICE_CORE_ROOT,
env=environment,
capture_output=True,
text=True,
)
self.assertNotEqual(completed.returncode, 0)
self.assertIn(
"historical_device_manager_compose_has_advanced",
completed.stderr,
)
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",
@@ -1551,6 +1667,28 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
core_network_mode="private-egress",
)
def test_manager_v4_apply_gate_requires_persistent_data_boundary(self):
entries = RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES
services = ("device-manager",)
with (
mock.patch.object(
RUNNER,
"healthcheck_compose_service_with_grace",
),
mock.patch.object(RUNNER, "healthcheck_url"),
mock.patch.object(
RUNNER,
"validate_device_manager_control_plane_runtime",
) as runtime_acceptance,
):
RUNNER.run_healthchecks("device-plane", entries, services)
runtime_acceptance.assert_called_once_with(
require_edge_channel=True,
core_network_mode="private-egress",
require_persistent_data=True,
)
def test_upgrade_v2_apply_gate_checks_preserved_runtime_and_edge_contract(self):
entries = RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES
services = ("device-control-core",)