Compare commits
5
Commits
1d3ed5e16e
...
07ffb22d38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07ffb22d38 | ||
|
|
06cb780777 | ||
|
|
506d786ee6 | ||
|
|
40cd637f72 | ||
|
|
b601b4ea46 |
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#101114" />
|
||||
<link rel="icon" type="image/svg+xml" sizes="any" href="/favicon/icon-adaptive.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/favicon/icon-192.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/favicon/icon-512.png" />
|
||||
<link rel="manifest" href="/favicon/manifest.webmanifest.json" />
|
||||
<title>NODE.DC Device Core</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.icon-fav-01 { fill: #333334; } /* Тёмный цвет для светлого таб-бара */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.icon-fav-01 { fill: #FEFEFE; } /* Светлый цвет для тёмного таб-бара */
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.icon-fav-01 { fill: #000000; } /* Тёмный цвет для светлого таб-бара (для явности) */
|
||||
}
|
||||
</style>
|
||||
<path class="icon-fav-01" d="M17.8738 15.9159L16.0002 18.9718L14.1267 15.9159H17.8738ZM23.6307 12.7864H8.36914L15.9999 25.2308L23.6307 12.7864Z"/>
|
||||
<path class="icon-fav-01" d="M10.9793 19.4872L6.67269 11.5918H25.5344L21.2281 19.4872H25.0581L31.2614 8H0.738281L7.16244 19.4872H10.9793Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 852 B |
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"theme_color": "#eeeff4",
|
||||
"background_color": "#eeeff4",
|
||||
"display": "browser",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon-adaptive.svg",
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "any"
|
||||
},
|
||||
{
|
||||
"src": "icon-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "icon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
},
|
||||
{
|
||||
"src": "apple-touch-icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "180x180"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
||||
|
||||
const DEFAULT_ACCENT = "#b9ff4a";
|
||||
const DEFAULT_ACCENT = "#f5f5f5";
|
||||
|
||||
export function createDeviceManagerPresentationStore({
|
||||
layoutPath,
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
|
||||
test("Device Core environment defaults to the product-level canonical identity", () => {
|
||||
const presentation = defaultPresentation();
|
||||
assert.equal(presentation.environment.theme, "dark");
|
||||
assert.equal(presentation.environment.accentHex, "#f5f5f5");
|
||||
assert.deepEqual(presentation.environment.overview, {
|
||||
headerLabel: "Device Core",
|
||||
eyebrow: "NODEDC / DEVICE CORE",
|
||||
@@ -27,6 +29,61 @@ 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("Device Core overview follows the Mission Core landing-stage geometry", async () => {
|
||||
const styles = await readFile(
|
||||
new URL("../src/styles.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
for (const expected of [
|
||||
".device-manager-stage {\n height: 100%;\n overflow: hidden;",
|
||||
"left: clamp(2rem, 5vw, 6rem);",
|
||||
"width: min(39rem, 50%);",
|
||||
"transform: translateY(-55%);",
|
||||
"linear-gradient(90deg, rgb(5 6 8 / 0.82) 0%, rgb(5 6 8 / 0.54) 46%, rgb(5 6 8 / 0.24) 100%)",
|
||||
"font-size: clamp(3rem, 7vw, 7.6rem);",
|
||||
]) assert.ok(styles.includes(expected), expected);
|
||||
assert.ok(!/\.device-manager-home\s*\{[^}]*max-width:\s*1420px/s.test(styles));
|
||||
const client = await readFile(
|
||||
new URL("../src/DeviceManagerApp.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.ok(client.includes('className="device-manager-home"'));
|
||||
assert.ok(client.includes('data-has-media={hasMedia ? "true" : undefined}'));
|
||||
assert.ok(!client.includes('<GlassSurface className="device-manager-home"'));
|
||||
});
|
||||
|
||||
test("legacy single teaser migrates into the environment media playlist", () => {
|
||||
const environment = normalizeEnvironmentPresentation({
|
||||
defaultTeaser: {
|
||||
@@ -60,6 +117,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) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "nodedc-device-presentation-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
@@ -68,6 +142,8 @@ test("environment presentation persists ordered mixed media and image duration",
|
||||
uploadRoot: join(root, "media"),
|
||||
});
|
||||
const next = defaultPresentation();
|
||||
next.environment.theme = "light";
|
||||
next.environment.accentHex = "#8a2be2";
|
||||
next.environment.overview.background = {
|
||||
enabled: true,
|
||||
imageDurationSeconds: 17,
|
||||
@@ -93,6 +169,8 @@ test("environment presentation persists ordered mixed media and image duration",
|
||||
|
||||
await store.write(next);
|
||||
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.deepEqual(
|
||||
restored.environment.overview.background.items.map((item) => item.id),
|
||||
|
||||
@@ -130,7 +130,7 @@ const emptyMedia = (): DeviceManagerMediaValue => ({
|
||||
const defaultPresentation = (): DeviceManagerPresentation => ({
|
||||
environment: {
|
||||
theme: "dark",
|
||||
accentHex: "#b9ff4a",
|
||||
accentHex: "#f5f5f5",
|
||||
overview: {
|
||||
headerLabel: "Device Core",
|
||||
eyebrow: "NODEDC / DEVICE CORE",
|
||||
@@ -684,6 +684,9 @@ function DeviceStage({
|
||||
&& primarySections.some((section) => section.value === value)
|
||||
&& values.indexOf(value) === index
|
||||
));
|
||||
const hasMedia = overview.background.enabled && overview.background.items.some(
|
||||
(item) => Boolean(mediaSource(item)) && Boolean(item.mediaKind),
|
||||
);
|
||||
return (
|
||||
<div className="device-manager-stage">
|
||||
{error ? (
|
||||
@@ -694,7 +697,10 @@ function DeviceStage({
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
<GlassSurface className="device-manager-home" padding="lg" tone="soft">
|
||||
<section
|
||||
className="device-manager-home"
|
||||
data-has-media={hasMedia ? "true" : undefined}
|
||||
>
|
||||
<EnvironmentBackdrop background={overview.background} />
|
||||
<div className="device-manager-home__scrim" aria-hidden="true" />
|
||||
<section className="device-manager-hero">
|
||||
@@ -715,7 +721,7 @@ function DeviceStage({
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</GlassSurface>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -257,46 +257,56 @@ body {
|
||||
|
||||
.device-manager-stage {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: clamp(1rem, 2.5vw, 2.5rem);
|
||||
scrollbar-width: thin;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.device-manager-home {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: calc(100% - 1px);
|
||||
max-width: 1420px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: clamp(3rem, 7vh, 6rem);
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background: var(--nodedc-canvas);
|
||||
isolation: isolate;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2.5rem, 6vw, 6rem) !important;
|
||||
}
|
||||
|
||||
.device-manager-home__media,
|
||||
.device-manager-home__scrim {
|
||||
position: absolute;
|
||||
z-index: -2;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.device-manager-home__media {
|
||||
z-index: 0;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.device-manager-home__scrim {
|
||||
z-index: -1;
|
||||
background: var(--nodedc-overlay-bg);
|
||||
opacity: 0.82;
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(90deg, rgb(5 6 8 / 0.82) 0%, rgb(5 6 8 / 0.54) 46%, rgb(5 6 8 / 0.24) 100%),
|
||||
linear-gradient(0deg, rgb(5 6 8 / 0.58), transparent 38%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.device-manager-home:not([data-has-media="true"]) .device-manager-home__scrim {
|
||||
background:
|
||||
radial-gradient(circle at 68% 42%, rgb(255 255 255 / 0.035), transparent 34%),
|
||||
linear-gradient(90deg, rgb(5 6 8 / 0.2), transparent 62%);
|
||||
}
|
||||
|
||||
.device-manager-hero {
|
||||
max-width: 900px;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 50%;
|
||||
left: clamp(2rem, 5vw, 6rem);
|
||||
width: min(39rem, 50%);
|
||||
transform: translateY(-55%);
|
||||
}
|
||||
|
||||
.device-manager-hero__icon {
|
||||
@@ -316,27 +326,33 @@ body {
|
||||
}
|
||||
|
||||
.device-manager-hero h1 {
|
||||
margin: 0.7rem 0 0.9rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: clamp(4rem, 8vw, 8.5rem);
|
||||
font-weight: 520;
|
||||
letter-spacing: -0.065em;
|
||||
line-height: 0.9;
|
||||
font-size: clamp(3rem, 7vw, 7.6rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.072em;
|
||||
line-height: 0.87;
|
||||
}
|
||||
|
||||
.device-manager-hero p {
|
||||
max-width: 680px;
|
||||
max-width: 34rem;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: clamp(0.96rem, 1.4vw, 1.14rem);
|
||||
line-height: 1.55;
|
||||
font-size: clamp(0.8rem, 1vw, 1rem);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.device-manager-hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1480px) {
|
||||
.device-manager-hero {
|
||||
width: min(35rem, 54%);
|
||||
}
|
||||
}
|
||||
|
||||
.environment-settings {
|
||||
@@ -948,20 +964,36 @@ body {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-height: 720px) and (min-width: 761px) {
|
||||
.device-manager-hero h1 {
|
||||
font-size: clamp(2.7rem, 6vw, 5rem);
|
||||
}
|
||||
|
||||
.device-manager-hero p {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.device-manager-hero__actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
body { overflow: auto; }
|
||||
|
||||
.device-manager-stage {
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.device-manager-home {
|
||||
min-height: auto;
|
||||
padding: 2.5rem 1.25rem !important;
|
||||
.device-manager-hero {
|
||||
top: 42%;
|
||||
right: 1.2rem;
|
||||
left: 1.2rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.device-manager-hero h1 {
|
||||
font-size: clamp(3.4rem, 17vw, 6rem);
|
||||
font-size: clamp(2.8rem, 14vw, 5rem);
|
||||
}
|
||||
|
||||
.device-manager-panel-toolbar,
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e"
|
||||
},
|
||||
"controlCorePredecessor": {
|
||||
"patchId": "device-control-core-release-v2-20260813-028",
|
||||
"artifactSha256": "816ef07fd81923328c3ff54d833dcc49f873aef13256259ae4f66061e7453635"
|
||||
"patchId": "device-control-core-release-v2-20260821-030",
|
||||
"artifactSha256": "8459521a662541a5a87cb0188991cdcfb51727427db8ec2a232ce4846bfc3454"
|
||||
},
|
||||
"edgeChannelPredecessor": {
|
||||
"patchId": "device-edge-core-channel-upgrade-v4-20260812-023",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.device-manager-release.v5",
|
||||
"releaseId": "__PATCH_ID__",
|
||||
"action": "upgrade",
|
||||
"predecessor": {
|
||||
"kind": "release",
|
||||
"patchId": "device-manager-release-v4-20260822-033",
|
||||
"artifactSha256": "52ba322042f1e4f595bbfea99f8bb35630b15984e0da648dc55348bc9e5b2066"
|
||||
},
|
||||
"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",
|
||||
"overviewLayout": "mission-core-landing-stage-v1",
|
||||
"gelios": "untouched-legacy-only",
|
||||
"rollback": "restore-preapply-snapshot-preserve-manager-data"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.device-manager-release.v6",
|
||||
"releaseId": "__PATCH_ID__",
|
||||
"action": "upgrade",
|
||||
"predecessor": {
|
||||
"kind": "release",
|
||||
"patchId": "device-manager-release-v5-20260822-034",
|
||||
"artifactSha256": "acc1d2ae2cda66861054826928c25d01a2428e688cc8132a9c381831bf29ab5a"
|
||||
},
|
||||
"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",
|
||||
"overviewLayout": "mission-core-landing-stage-v1",
|
||||
"faviconSet": "nodedc-adaptive-v1",
|
||||
"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_DEVICE_CORE_INTERNAL_URL: http://device-control-core:18120
|
||||
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:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-platform/secrets/device-core-internal-token
|
||||
@@ -47,6 +49,12 @@ services:
|
||||
read_only: true
|
||||
bind:
|
||||
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:
|
||||
- "18122"
|
||||
networks:
|
||||
|
||||
@@ -11,15 +11,31 @@ const platformRoot = resolve(scriptDir, "../..");
|
||||
const devicePlaneRoot = platformRoot;
|
||||
const managerRoot = resolve(platformRoot, "apps/device-manager");
|
||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||
const [patchId = "device-manager-release-v3-20260812-026", ...extra] = process.argv.slice(2);
|
||||
const [patchId = "device-manager-release-v6-20260822-035", ...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-")
|
||||
const descriptorPath = patchId.startsWith("device-manager-release-v6-")
|
||||
? "deployment/device-manager-release-v6.json"
|
||||
: patchId.startsWith("device-manager-release-v5-")
|
||||
? "deployment/device-manager-release-v5.json"
|
||||
: 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 isV5 = descriptorPath.endsWith("release-v5.json");
|
||||
const isV6 = descriptorPath.endsWith("release-v6.json");
|
||||
const isPersistent = isV4 || isV5 || isV6;
|
||||
const isManagerOnly = isV3 || isPersistent;
|
||||
const composeSource = resolve(devicePlaneRoot, "docker-compose.device-manager.yml");
|
||||
const composeSourceSha256 = createHash("sha256").update(await readFile(composeSource)).digest("hex");
|
||||
if (!isPersistent && 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 +86,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 +96,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 +127,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",
|
||||
...(isPersistent ? [
|
||||
"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,18 +159,117 @@ 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 !== (isPersistent
|
||||
? "bounded-container-grace+core-contract+persistent-data"
|
||||
: "bounded-container-grace+core-contract")
|
||||
|| descriptor.rollback !== (isPersistent
|
||||
? "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-v6.json")) {
|
||||
if (
|
||||
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v6"
|
||||
|| descriptor.predecessor?.kind !== "release"
|
||||
|| descriptor.predecessor?.patchId !== "device-manager-release-v5-20260822-034"
|
||||
|| descriptor.predecessor?.artifactSha256 !== "acc1d2ae2cda66861054826928c25d01a2428e688cc8132a9c381831bf29ab5a"
|
||||
|| 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.overviewLayout !== "mission-core-landing-stage-v1"
|
||||
|| descriptor.faviconSet !== "nodedc-adaptive-v1"
|
||||
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|
||||
|| descriptor.gelios !== "untouched-legacy-only"
|
||||
) throw new Error("device_manager_v6_favicon_contract_mismatch");
|
||||
const faviconHashes = {
|
||||
"favicon.ico": "f8933114a85646335ea5c94944f56d3cd8c48a6032016719f7905ff244ec0aa2",
|
||||
"favicon/favicon.ico": "f8933114a85646335ea5c94944f56d3cd8c48a6032016719f7905ff244ec0aa2",
|
||||
"favicon/icon-adaptive.svg": "481984e83997d786bb0a72ad1ee80037db13aef3a0792ab3109df95c2199b38e",
|
||||
"favicon/apple-touch-icon.png": "afdccc28152a566e264e533ca218362f05d5bcec936c647f9a54f414c0bd4763",
|
||||
"favicon/icon-192.png": "5b10a24feb4754f15c69761cef42f91a01f885d04095156b1a12e254875fdd4d",
|
||||
"favicon/icon-512.png": "f98bac3dba59b7eefbe89f8bb8abc25567226a54ab7ed3b6b1a4caffbdd9ee15",
|
||||
"favicon/manifest.webmanifest.json": "2a8ecdc6e6c64833f812ae02bbc0c7bd9b435e0cfa75cc21d6edf054d41275fc",
|
||||
};
|
||||
for (const [relativePath, expectedSha256] of Object.entries(faviconHashes)) {
|
||||
const content = await readFile(join(payload, "services/device-manager/dist", relativePath));
|
||||
const actualSha256 = createHash("sha256").update(content).digest("hex");
|
||||
if (actualSha256 !== expectedSha256) throw new Error(`device_manager_v6_favicon_hash_mismatch:${relativePath}`);
|
||||
}
|
||||
const indexHtml = await readFile(join(payload, "services/device-manager/dist/index.html"), "utf8");
|
||||
for (const requiredLink of [
|
||||
'href="/favicon/icon-adaptive.svg"',
|
||||
'href="/favicon/favicon.ico"',
|
||||
'href="/favicon/apple-touch-icon.png"',
|
||||
'href="/favicon/icon-192.png"',
|
||||
'href="/favicon/icon-512.png"',
|
||||
'href="/favicon/manifest.webmanifest.json"',
|
||||
]) if (!indexHtml.includes(requiredLink)) throw new Error(`device_manager_v6_favicon_link_missing:${requiredLink}`);
|
||||
} else if (descriptorPath.endsWith("release-v5.json")) {
|
||||
if (
|
||||
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v5"
|
||||
|| descriptor.predecessor?.kind !== "release"
|
||||
|| descriptor.predecessor?.patchId !== "device-manager-release-v4-20260822-033"
|
||||
|| descriptor.predecessor?.artifactSha256 !== "52ba322042f1e4f595bbfea99f8bb35630b15984e0da648dc55348bc9e5b2066"
|
||||
|| 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.overviewLayout !== "mission-core-landing-stage-v1"
|
||||
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|
||||
|| descriptor.gelios !== "untouched-legacy-only"
|
||||
) throw new Error("device_manager_v5_overview_layout_contract_mismatch");
|
||||
} else 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"
|
||||
|| 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-20260813-028"
|
||||
|| descriptor.controlCorePredecessor?.artifactSha256 !== "816ef07fd81923328c3ff54d833dcc49f873aef13256259ae4f66061e7453635"
|
||||
|| 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.edgeChannel !== "preserve-active-v4-core-initiated-pinned-mtls"
|
||||
@@ -173,7 +296,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 {
|
||||
|
||||
@@ -43,7 +43,11 @@ LAUNCHER_HUB_SERVICE_TRUST_UI_ENTRIES = (
|
||||
)
|
||||
|
||||
|
||||
def healthy_device_plane_inventory(*, include_manager=False):
|
||||
def healthy_device_plane_inventory(
|
||||
*,
|
||||
include_manager=False,
|
||||
include_backhaul=False,
|
||||
):
|
||||
services = [
|
||||
("device-control-core", "a"),
|
||||
("device-gateway", "b"),
|
||||
@@ -51,6 +55,8 @@ def healthy_device_plane_inventory(*, include_manager=False):
|
||||
]
|
||||
if include_manager:
|
||||
services.append(("device-manager", "d"))
|
||||
if include_backhaul:
|
||||
services.append(("device-backhaul-target", "e"))
|
||||
return {
|
||||
"schemaVersion": "nodedc.device-plane.runtime-inventory.v1",
|
||||
"composeProject": "nodedc-device-plane",
|
||||
@@ -131,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)
|
||||
@@ -215,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",
|
||||
@@ -287,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",
|
||||
@@ -299,6 +316,23 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
("device-manager",),
|
||||
)
|
||||
self.assertEqual(result["services"], ["device-manager"])
|
||||
template = json.loads(
|
||||
(
|
||||
DEVICE_CORE_ROOT
|
||||
/ "deployment/device-manager-release-v3.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(
|
||||
template["controlCorePredecessor"],
|
||||
{
|
||||
"patchId": (
|
||||
"device-control-core-release-v2-20260821-030"
|
||||
),
|
||||
"artifactSha256": (
|
||||
"8459521a662541a5a87cb0188991cdcfb51727427db8ec2a232ce4846bfc3454"
|
||||
),
|
||||
},
|
||||
)
|
||||
self.assertIn(
|
||||
"payload/deployment/device-manager-release-v3.json",
|
||||
names,
|
||||
@@ -337,6 +371,233 @@ 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_device_manager_release_v5_matches_mission_core_landing_geometry(self):
|
||||
patch_id = "device-manager-release-v5-unit-001"
|
||||
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||
"build-device-manager-control-plane-artifact.mjs",
|
||||
patch_id,
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES,
|
||||
)
|
||||
self.assertEqual(manifest["component"], "device-plane")
|
||||
self.assertEqual(result["services"], ["device-manager"])
|
||||
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", ".map")) for name in names)
|
||||
)
|
||||
template = json.loads(
|
||||
(
|
||||
DEVICE_CORE_ROOT
|
||||
/ "deployment/device-manager-release-v5.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.v5"
|
||||
),
|
||||
boundaries=(
|
||||
RUNNER.expected_device_plane_manager_release_v5_boundaries()
|
||||
),
|
||||
expected_release_id=patch_id,
|
||||
),
|
||||
descriptor,
|
||||
)
|
||||
self.assertEqual(
|
||||
descriptor["overviewLayout"],
|
||||
"mission-core-landing-stage-v1",
|
||||
)
|
||||
self.assertEqual(
|
||||
descriptor["predecessor"],
|
||||
{
|
||||
"kind": "release",
|
||||
"patchId": "device-manager-release-v4-20260822-033",
|
||||
"artifactSha256": (
|
||||
"52ba322042f1e4f595bbfea99f8bb35630b15984e0da648dc55348bc9e5b2066"
|
||||
),
|
||||
},
|
||||
)
|
||||
self.assertTrue(
|
||||
RUNNER.is_device_plane_manager_release_v5_slice(
|
||||
"device-plane",
|
||||
entries,
|
||||
)
|
||||
)
|
||||
|
||||
def test_device_manager_release_v6_includes_canonical_favicon_set(self):
|
||||
patch_id = "device-manager-release-v6-unit-001"
|
||||
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||
"build-device-manager-control-plane-artifact.mjs",
|
||||
patch_id,
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V6_ENTRIES,
|
||||
)
|
||||
self.assertEqual(manifest["component"], "device-plane")
|
||||
self.assertEqual(result["services"], ["device-manager"])
|
||||
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", ".map")) for name in names))
|
||||
for name in (
|
||||
"payload/services/device-manager/dist/favicon.ico",
|
||||
"payload/services/device-manager/dist/favicon/favicon.ico",
|
||||
"payload/services/device-manager/dist/favicon/icon-adaptive.svg",
|
||||
"payload/services/device-manager/dist/favicon/apple-touch-icon.png",
|
||||
"payload/services/device-manager/dist/favicon/icon-192.png",
|
||||
"payload/services/device-manager/dist/favicon/icon-512.png",
|
||||
"payload/services/device-manager/dist/favicon/manifest.webmanifest.json",
|
||||
):
|
||||
self.assertIn(name, names)
|
||||
template = json.loads(
|
||||
(
|
||||
DEVICE_CORE_ROOT
|
||||
/ "deployment/device-manager-release-v6.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.v6"
|
||||
),
|
||||
boundaries=(
|
||||
RUNNER.expected_device_plane_manager_release_v6_boundaries()
|
||||
),
|
||||
expected_release_id=patch_id,
|
||||
),
|
||||
descriptor,
|
||||
)
|
||||
self.assertEqual(descriptor["faviconSet"], "nodedc-adaptive-v1")
|
||||
self.assertEqual(
|
||||
descriptor["predecessor"],
|
||||
{
|
||||
"kind": "release",
|
||||
"patchId": "device-manager-release-v5-20260822-034",
|
||||
"artifactSha256": (
|
||||
"acc1d2ae2cda66861054826928c25d01a2428e688cc8132a9c381831bf29ab5a"
|
||||
),
|
||||
},
|
||||
)
|
||||
self.assertTrue(
|
||||
RUNNER.is_device_plane_manager_release_v6_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",
|
||||
@@ -599,6 +860,7 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
("device-plane", "device-manager"),
|
||||
("device-plane", "device-gateway"),
|
||||
("device-plane", "device-postgres"),
|
||||
("device-plane", "device-backhaul-target"),
|
||||
],
|
||||
)
|
||||
url_health.assert_called_once_with(
|
||||
@@ -849,6 +1111,11 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
"inspect_device_edge_channel_core_identity_state",
|
||||
return_value="valid-reuse-at-apply",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_control_core_selected_predecessor_runtime",
|
||||
return_value={"health": "unhealthy"},
|
||||
),
|
||||
mock.patch.object(RUNNER, "healthcheck_compose_service"),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
@@ -857,7 +1124,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
):
|
||||
result = (
|
||||
RUNNER.validate_device_plane_control_core_release_predecessor(
|
||||
root / "candidate-payload"
|
||||
root / "candidate-payload",
|
||||
preflight_phase="plan",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1485,6 +1753,64 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
)
|
||||
runtime_acceptance.assert_called_once_with()
|
||||
|
||||
def test_manager_v3_apply_gate_uses_private_egress_core_boundary(self):
|
||||
entries = RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES
|
||||
services = ("device-manager",)
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service_with_grace",
|
||||
) as service_health,
|
||||
mock.patch.object(RUNNER, "healthcheck_url") as url_health,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_manager_control_plane_runtime",
|
||||
) as runtime_acceptance,
|
||||
):
|
||||
RUNNER.run_healthchecks("device-plane", entries, services)
|
||||
|
||||
service_health.assert_called_once_with(
|
||||
"device-plane",
|
||||
"device-manager",
|
||||
)
|
||||
self.assertEqual(
|
||||
[call.args for call in url_health.call_args_list],
|
||||
[
|
||||
(check,)
|
||||
for check in RUNNER.component_healthchecks(
|
||||
"device-plane",
|
||||
entries,
|
||||
services,
|
||||
)
|
||||
],
|
||||
)
|
||||
runtime_acceptance.assert_called_once_with(
|
||||
require_edge_channel=True,
|
||||
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",)
|
||||
@@ -1648,7 +1974,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
):
|
||||
result = (
|
||||
RUNNER.validate_device_plane_manager_activation_predecessor(
|
||||
root / "payload"
|
||||
root / "payload",
|
||||
preflight_phase="plan",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1782,18 +2109,15 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
):
|
||||
result = (
|
||||
RUNNER.validate_device_plane_manager_activation_predecessor(
|
||||
root / "candidate-payload"
|
||||
root / "candidate-payload",
|
||||
preflight_phase="plan",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(result["mode"], "active-manager-forward-upgrade")
|
||||
self.assertEqual(
|
||||
[call.args for call in health.call_args_list],
|
||||
[
|
||||
("device-plane", "device-control-core"),
|
||||
("device-plane", "device-manager"),
|
||||
("device-plane", "device-postgres"),
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
def test_initial_install_rollback_removes_manager_and_restores_core_only(self):
|
||||
@@ -2110,9 +2434,13 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
encoding="utf-8",
|
||||
)
|
||||
(backup / "runtime-before.json").write_text(
|
||||
json.dumps(healthy_device_plane_inventory(include_manager=True)),
|
||||
json.dumps(healthy_device_plane_inventory(
|
||||
include_manager=True,
|
||||
include_backhaul=True,
|
||||
)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
restored_core_id = "f" * 64
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
@@ -2131,6 +2459,23 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
RUNNER,
|
||||
"healthcheck_compose_service_with_grace",
|
||||
) as restore_health,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"compose_service_container_id",
|
||||
return_value=restored_core_id,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"inspect_device_plane_container",
|
||||
return_value={
|
||||
"Id": restored_core_id,
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": True,
|
||||
"Health": {"Status": "healthy"},
|
||||
},
|
||||
},
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_manager_control_plane_runtime",
|
||||
@@ -2154,10 +2499,10 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
[call.args for call in restore_health.call_args_list],
|
||||
[
|
||||
("device-plane", "device-control-core"),
|
||||
("device-plane", "device-manager"),
|
||||
("device-plane", "device-gateway"),
|
||||
("device-plane", "device-postgres"),
|
||||
("device-plane", "device-backhaul-target"),
|
||||
],
|
||||
)
|
||||
runtime_acceptance.assert_called_once_with(
|
||||
|
||||
Reference in New Issue
Block a user