fix(manager): persist environment presentation and media
This commit is contained in:
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||||
import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
||||||
|
|
||||||
const DEFAULT_ACCENT = "#b9ff4a";
|
const DEFAULT_ACCENT = "#f5f5f5";
|
||||||
|
|
||||||
export function createDeviceManagerPresentationStore({
|
export function createDeviceManagerPresentationStore({
|
||||||
layoutPath,
|
layoutPath,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
|
|
||||||
test("Device Core environment defaults to the product-level canonical identity", () => {
|
test("Device Core environment defaults to the product-level canonical identity", () => {
|
||||||
const presentation = defaultPresentation();
|
const presentation = defaultPresentation();
|
||||||
|
assert.equal(presentation.environment.theme, "dark");
|
||||||
|
assert.equal(presentation.environment.accentHex, "#f5f5f5");
|
||||||
assert.deepEqual(presentation.environment.overview, {
|
assert.deepEqual(presentation.environment.overview, {
|
||||||
headerLabel: "Device Core",
|
headerLabel: "Device Core",
|
||||||
eyebrow: "NODEDC / DEVICE CORE",
|
eyebrow: "NODEDC / DEVICE CORE",
|
||||||
@@ -27,6 +29,38 @@ test("Device Core environment defaults to the product-level canonical identity",
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("environment accent keeps a valid user choice and falls back to neutral white", () => {
|
||||||
|
assert.equal(
|
||||||
|
normalizeEnvironmentPresentation({ accentHex: "#8A2BE2" }).accentHex,
|
||||||
|
"#8a2be2",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
normalizeEnvironmentPresentation({ accentHex: "not-a-color" }).accentHex,
|
||||||
|
"#f5f5f5",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("production Compose provides exact persistent writable presentation storage", async () => {
|
||||||
|
const compose = await readFile(
|
||||||
|
new URL("../../../docker-compose.device-manager.yml", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
for (const expected of [
|
||||||
|
"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",
|
||||||
|
]) assert.ok(compose.includes(expected), expected);
|
||||||
|
const client = await readFile(
|
||||||
|
new URL("../src/DeviceManagerApp.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.ok(client.includes('accentHex: "#f5f5f5"'));
|
||||||
|
assert.ok(!client.includes("#b9ff4a"));
|
||||||
|
});
|
||||||
|
|
||||||
test("legacy single teaser migrates into the environment media playlist", () => {
|
test("legacy single teaser migrates into the environment media playlist", () => {
|
||||||
const environment = normalizeEnvironmentPresentation({
|
const environment = normalizeEnvironmentPresentation({
|
||||||
defaultTeaser: {
|
defaultTeaser: {
|
||||||
@@ -60,6 +94,23 @@ test("environment media accepts MOV even when the browser omits or varies its MI
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("environment media persists an MP4 upload in the configured data root", async (t) => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "nodedc-device-presentation-mp4-"));
|
||||||
|
t.after(() => rm(root, { recursive: true, force: true }));
|
||||||
|
const store = createDeviceManagerPresentationStore({
|
||||||
|
layoutPath: join(root, "device-manager-presentation.json"),
|
||||||
|
uploadRoot: join(root, "media"),
|
||||||
|
});
|
||||||
|
const uploaded = await store.saveMedia({
|
||||||
|
bytes: Buffer.from("mp4-payload"),
|
||||||
|
contentType: "video/mp4",
|
||||||
|
originalName: "environment.mp4",
|
||||||
|
kind: "background",
|
||||||
|
});
|
||||||
|
assert.match(uploaded.fileSrc, /^\/device-manager-media\/background-[0-9a-f-]+\.mp4$/);
|
||||||
|
assert.equal(await readFile(store.resolveMedia(uploaded.fileSrc), "utf8"), "mp4-payload");
|
||||||
|
});
|
||||||
|
|
||||||
test("environment presentation persists ordered mixed media and image duration", async (t) => {
|
test("environment presentation persists ordered mixed media and image duration", async (t) => {
|
||||||
const root = await mkdtemp(join(tmpdir(), "nodedc-device-presentation-"));
|
const root = await mkdtemp(join(tmpdir(), "nodedc-device-presentation-"));
|
||||||
t.after(() => rm(root, { recursive: true, force: true }));
|
t.after(() => rm(root, { recursive: true, force: true }));
|
||||||
@@ -68,6 +119,8 @@ test("environment presentation persists ordered mixed media and image duration",
|
|||||||
uploadRoot: join(root, "media"),
|
uploadRoot: join(root, "media"),
|
||||||
});
|
});
|
||||||
const next = defaultPresentation();
|
const next = defaultPresentation();
|
||||||
|
next.environment.theme = "light";
|
||||||
|
next.environment.accentHex = "#8a2be2";
|
||||||
next.environment.overview.background = {
|
next.environment.overview.background = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
imageDurationSeconds: 17,
|
imageDurationSeconds: 17,
|
||||||
@@ -93,6 +146,8 @@ test("environment presentation persists ordered mixed media and image duration",
|
|||||||
|
|
||||||
await store.write(next);
|
await store.write(next);
|
||||||
const restored = await store.read();
|
const restored = await store.read();
|
||||||
|
assert.equal(restored.environment.theme, "light");
|
||||||
|
assert.equal(restored.environment.accentHex, "#8a2be2");
|
||||||
assert.equal(restored.environment.overview.background.imageDurationSeconds, 17);
|
assert.equal(restored.environment.overview.background.imageDurationSeconds, 17);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
restored.environment.overview.background.items.map((item) => item.id),
|
restored.environment.overview.background.items.map((item) => item.id),
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ const emptyMedia = (): DeviceManagerMediaValue => ({
|
|||||||
const defaultPresentation = (): DeviceManagerPresentation => ({
|
const defaultPresentation = (): DeviceManagerPresentation => ({
|
||||||
environment: {
|
environment: {
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
accentHex: "#b9ff4a",
|
accentHex: "#f5f5f5",
|
||||||
overview: {
|
overview: {
|
||||||
headerLabel: "Device Core",
|
headerLabel: "Device Core",
|
||||||
eyebrow: "NODEDC / DEVICE CORE",
|
eyebrow: "NODEDC / DEVICE CORE",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "nodedc.device-plane.device-manager-release.v4",
|
||||||
|
"releaseId": "__PATCH_ID__",
|
||||||
|
"action": "upgrade",
|
||||||
|
"predecessor": {
|
||||||
|
"kind": "release",
|
||||||
|
"patchId": "device-manager-release-v3-20260822-032",
|
||||||
|
"artifactSha256": "6e0eb3a0a6f19ceab92d46832b93bffbcea21247dbdc2ea50625a51ff460e4ca"
|
||||||
|
},
|
||||||
|
"controlCorePredecessor": {
|
||||||
|
"patchId": "device-control-core-release-v2-20260821-030",
|
||||||
|
"artifactSha256": "8459521a662541a5a87cb0188991cdcfb51727427db8ec2a232ce4846bfc3454"
|
||||||
|
},
|
||||||
|
"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+persistent-data",
|
||||||
|
"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",
|
||||||
|
"presentationPersistence": "runner-managed-host-data-bind",
|
||||||
|
"presentationDataHostPath": "/volume1/docker/nodedc-device-plane/data/device-manager",
|
||||||
|
"presentationDataContainerPath": "/var/lib/nodedc-device-manager",
|
||||||
|
"presentationDataOwnership": "uid-1000-gid-1000-mode-0750",
|
||||||
|
"presentationDataLifecycle": "preserve-across-manager-recreate-and-source-rollback",
|
||||||
|
"presentationPath": "/var/lib/nodedc-device-manager/device-manager-presentation.json",
|
||||||
|
"mediaRoot": "/var/lib/nodedc-device-manager/media",
|
||||||
|
"defaultAccentHex": "#f5f5f5",
|
||||||
|
"gelios": "untouched-legacy-only",
|
||||||
|
"rollback": "restore-preapply-snapshot-preserve-manager-data"
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ services:
|
|||||||
NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token
|
NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token
|
||||||
NODEDC_DEVICE_CORE_INTERNAL_URL: http://device-control-core:18120
|
NODEDC_DEVICE_CORE_INTERNAL_URL: http://device-control-core:18120
|
||||||
NODEDC_DEVICE_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token
|
NODEDC_DEVICE_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token
|
||||||
|
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
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /volume1/docker/nodedc-platform/secrets/device-core-internal-token
|
source: /volume1/docker/nodedc-platform/secrets/device-core-internal-token
|
||||||
@@ -47,6 +49,12 @@ services:
|
|||||||
read_only: true
|
read_only: true
|
||||||
bind:
|
bind:
|
||||||
create_host_path: false
|
create_host_path: false
|
||||||
|
- type: bind
|
||||||
|
source: /volume1/docker/nodedc-device-plane/data/device-manager
|
||||||
|
target: /var/lib/nodedc-device-manager
|
||||||
|
read_only: false
|
||||||
|
bind:
|
||||||
|
create_host_path: false
|
||||||
expose:
|
expose:
|
||||||
- "18122"
|
- "18122"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -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);
|
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]");
|
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-")
|
const descriptorPath = patchId.startsWith("device-manager-release-v4-")
|
||||||
? "deployment/device-manager-release-v3.json"
|
? "deployment/device-manager-release-v4.json"
|
||||||
: "deployment/device-manager-release-v1.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 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",
|
"docker-compose.device-manager.yml",
|
||||||
"services/device-manager",
|
"services/device-manager",
|
||||||
descriptorPath,
|
descriptorPath,
|
||||||
@@ -70,7 +79,7 @@ try {
|
|||||||
}
|
}
|
||||||
await copySafe(resolve(devicePlaneRoot, entry), join(payload, entry), devicePlaneRoot);
|
await copySafe(resolve(devicePlaneRoot, entry), join(payload, entry), devicePlaneRoot);
|
||||||
}
|
}
|
||||||
if (!isV3) {
|
if (!isManagerOnly) {
|
||||||
await validateDockerCopySources(
|
await validateDockerCopySources(
|
||||||
join(payload, "services/device-control-core/Dockerfile"),
|
join(payload, "services/device-control-core/Dockerfile"),
|
||||||
payload,
|
payload,
|
||||||
@@ -80,7 +89,7 @@ try {
|
|||||||
join(payload, "services/device-manager/Dockerfile"),
|
join(payload, "services/device-manager/Dockerfile"),
|
||||||
join(payload, "services/device-manager"),
|
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/sensitive-reference-management.mjs",
|
||||||
"services/device-control-core/src/device-gateway-core-runtime.mjs",
|
"services/device-control-core/src/device-gateway-core-runtime.mjs",
|
||||||
"packages/device-edge-channel-contract/src/index.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_LAUNCHER_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token",
|
||||||
"NODEDC_DEVICE_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token",
|
"NODEDC_DEVICE_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token",
|
||||||
"name: nodedc-platform_edge",
|
"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}`);
|
]) if (!compose.includes(required)) throw new Error(`device_manager_compose_contract_missing:${required}`);
|
||||||
for (const forbidden of [
|
for (const forbidden of [
|
||||||
"NODEDC_INTERNAL_ACCESS_TOKEN:",
|
"NODEDC_INTERNAL_ACCESS_TOKEN:",
|
||||||
@@ -135,11 +152,36 @@ try {
|
|||||||
|| !/^[A-Za-z0-9._-]{1,96}$/.test(predecessor.patchId || "")
|
|| !/^[A-Za-z0-9._-]{1,96}$/.test(predecessor.patchId || "")
|
||||||
|| !/^[a-f0-9]{64}$/.test(predecessor.artifactSha256 || "")
|
|| !/^[a-f0-9]{64}$/.test(predecessor.artifactSha256 || "")
|
||||||
|| (descriptor.action === "activate") !== (predecessor.kind === "reconciliation")
|
|| (descriptor.action === "activate") !== (predecessor.kind === "reconciliation")
|
||||||
|| descriptor.healthGate !== "bounded-container-grace+core-contract"
|
|| descriptor.healthGate !== (isV4
|
||||||
|| descriptor.rollback !== "restore-preapply-snapshot"
|
? "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 (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 (
|
if (
|
||||||
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v3"
|
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v3"
|
||||||
|| descriptor.commandTransport !== "typed-service-ping-v1"
|
|| descriptor.commandTransport !== "typed-service-ping-v1"
|
||||||
@@ -173,7 +215,7 @@ try {
|
|||||||
artifact: target,
|
artifact: target,
|
||||||
sha256,
|
sha256,
|
||||||
entries,
|
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"],
|
preserved: ["device-postgres", "device-gateway", "device-backhaul-target", "Gelios"],
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ def device_manager_release_v3_descriptor(
|
|||||||
|
|
||||||
|
|
||||||
class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
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):
|
def build(self, script, patch_id, artifact_dir):
|
||||||
environment = os.environ.copy()
|
environment = os.environ.copy()
|
||||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||||
@@ -221,6 +228,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
self.assertEqual(result["services"], ["launcher"])
|
self.assertEqual(result["services"], ["launcher"])
|
||||||
|
|
||||||
def test_device_manager_artifact_selects_only_core_and_manager(self):
|
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(
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
"build-device-manager-control-plane-artifact.mjs",
|
"build-device-manager-control-plane-artifact.mjs",
|
||||||
"device-manager-control-plane-unit-001",
|
"device-manager-control-plane-unit-001",
|
||||||
@@ -293,6 +302,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
self.assertEqual(checks[0]["expected_json"]["discoveryIngest"], "enabled")
|
self.assertEqual(checks[0]["expected_json"]["discoveryIngest"], "enabled")
|
||||||
|
|
||||||
def test_device_manager_release_v3_is_exact_typed_and_secret_free(self):
|
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"
|
patch_id = "device-manager-release-v3-unit-001"
|
||||||
manifest, entries, names, result = self.assert_deterministic_artifact(
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
"build-device-manager-control-plane-artifact.mjs",
|
"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):
|
def test_edge_core_channel_bootstrap_is_core_only_and_secret_free(self):
|
||||||
manifest, entries, names, result = self.assert_deterministic_artifact(
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
"build-device-edge-core-channel-bootstrap-artifact.mjs",
|
"build-device-edge-core-channel-bootstrap-artifact.mjs",
|
||||||
@@ -1551,6 +1667,28 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
core_network_mode="private-egress",
|
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):
|
def test_upgrade_v2_apply_gate_checks_preserved_runtime_and_edge_contract(self):
|
||||||
entries = RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES
|
entries = RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES
|
||||||
services = ("device-control-core",)
|
services = ("device-control-core",)
|
||||||
|
|||||||
Reference in New Issue
Block a user