feat(deploy): register Device Control Core releases
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "nodedc.device-plane.device-control-core-release.v1",
|
||||||
|
"releaseId": "__PATCH_ID__",
|
||||||
|
"action": "upgrade",
|
||||||
|
"predecessor": {
|
||||||
|
"kind": "edge-core-channel-upgrade-v4",
|
||||||
|
"patchId": "device-edge-core-channel-upgrade-v4-20260812-023",
|
||||||
|
"artifactSha256": "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|
||||||
|
},
|
||||||
|
"service": "device-control-core",
|
||||||
|
"composeActivation": "preserve-active-v4-topology",
|
||||||
|
"identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export",
|
||||||
|
"identityRecovery": "forbidden-valid-existing-identity-required",
|
||||||
|
"tlsPurpose": "clientAuth",
|
||||||
|
"direction": "core-initiated",
|
||||||
|
"endpointPolicy": "public-ipv4-standard-https-tcp-443-only",
|
||||||
|
"coreNetworks": [
|
||||||
|
"device-plane-private",
|
||||||
|
"device-plane-egress"
|
||||||
|
],
|
||||||
|
"publicIngress": "none-on-synology",
|
||||||
|
"edgeRegistrations": "preserved",
|
||||||
|
"commandTransport": "disabled",
|
||||||
|
"gelios": "untouched",
|
||||||
|
"preservedServices": [
|
||||||
|
"device-manager",
|
||||||
|
"device-gateway",
|
||||||
|
"device-postgres",
|
||||||
|
"device-backhaul-target"
|
||||||
|
],
|
||||||
|
"healthGate": "bounded-container-grace+core-edge-contract+exact-private-egress-network-boundary",
|
||||||
|
"rollback": "restore-preapply-source-and-core-runtime"
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, relative, resolve } from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = resolve(scriptDir, "../..");
|
||||||
|
const devicePlaneRoot = resolve(platformRoot, "device-plane");
|
||||||
|
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||||
|
const [
|
||||||
|
patchId = "device-control-core-release-20260812-024",
|
||||||
|
predecessorPatchId,
|
||||||
|
predecessorSha256,
|
||||||
|
...extra
|
||||||
|
] = process.argv.slice(2);
|
||||||
|
if (
|
||||||
|
extra.length
|
||||||
|
|| !/^device-control-core-release-[A-Za-z0-9._-]{1,67}$/.test(patchId)
|
||||||
|
|| ((predecessorPatchId === undefined) !== (predecessorSha256 === undefined))
|
||||||
|
|| (
|
||||||
|
predecessorPatchId !== undefined
|
||||||
|
&& (
|
||||||
|
!/^device-control-core-release-[A-Za-z0-9._-]{1,67}$/.test(predecessorPatchId)
|
||||||
|
|| predecessorPatchId === patchId
|
||||||
|
|| !/^[0-9a-f]{64}$/.test(predecessorSha256)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"usage: build-device-control-core-release-artifact.mjs "
|
||||||
|
+ "[device-control-core-release-id] [predecessor-release-id predecessor-sha256]",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptorPath = "deployment/device-control-core-release-v1.json";
|
||||||
|
const entries = [
|
||||||
|
".dockerignore",
|
||||||
|
"package.json",
|
||||||
|
"package-lock.json",
|
||||||
|
"packages/device-protocol-contract",
|
||||||
|
"packages/device-edge-channel-contract",
|
||||||
|
"services/device-control-core",
|
||||||
|
descriptorPath,
|
||||||
|
];
|
||||||
|
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-control-core-release-"));
|
||||||
|
const payload = join(stage, "payload");
|
||||||
|
const target = join(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(payload, { recursive: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry === descriptorPath) {
|
||||||
|
const descriptor = JSON.parse(await readFile(resolve(devicePlaneRoot, entry), "utf8"));
|
||||||
|
if (descriptor.releaseId !== "__PATCH_ID__") {
|
||||||
|
throw new Error("device_control_core_release_template_id_mismatch");
|
||||||
|
}
|
||||||
|
descriptor.releaseId = patchId;
|
||||||
|
if (predecessorPatchId !== undefined) {
|
||||||
|
descriptor.predecessor = {
|
||||||
|
kind: "release",
|
||||||
|
patchId: predecessorPatchId,
|
||||||
|
artifactSha256: predecessorSha256,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const destination = join(payload, entry);
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await writeFile(destination, `${JSON.stringify(descriptor, null, 2)}\n`, "utf8");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await copySafe(resolve(devicePlaneRoot, entry), join(payload, entry), devicePlaneRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
await validateDockerCopySources(
|
||||||
|
join(payload, "services/device-control-core/Dockerfile"),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
for (const modulePath of [
|
||||||
|
"services/device-control-core/src/device-gateway-core-runtime.mjs",
|
||||||
|
"packages/device-protocol-contract/src/index.mjs",
|
||||||
|
"packages/device-edge-channel-contract/src/index.mjs",
|
||||||
|
]) {
|
||||||
|
const imported = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
["--input-type=module", "--eval", `import(${JSON.stringify(pathToFileURL(join(payload, modulePath)).href)})`],
|
||||||
|
{ cwd: payload, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
|
||||||
|
);
|
||||||
|
if (imported.status !== 0) {
|
||||||
|
throw new Error(`device_control_core_release_staged_module_import_failed:${modulePath}:${imported.stderr || imported.stdout}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8"));
|
||||||
|
if (
|
||||||
|
descriptor.schemaVersion !== "nodedc.device-plane.device-control-core-release.v1"
|
||||||
|
|| descriptor.releaseId !== patchId
|
||||||
|
|| descriptor.action !== "upgrade"
|
||||||
|
|| descriptor.service !== "device-control-core"
|
||||||
|
|| descriptor.composeActivation !== "preserve-active-v4-topology"
|
||||||
|
|| descriptor.identityRecovery !== "forbidden-valid-existing-identity-required"
|
||||||
|
|| descriptor.tlsPurpose !== "clientAuth"
|
||||||
|
|| descriptor.direction !== "core-initiated"
|
||||||
|
|| descriptor.endpointPolicy !== "public-ipv4-standard-https-tcp-443-only"
|
||||||
|
|| JSON.stringify(descriptor.coreNetworks) !== JSON.stringify(["device-plane-private", "device-plane-egress"])
|
||||||
|
|| descriptor.publicIngress !== "none-on-synology"
|
||||||
|
|| descriptor.edgeRegistrations !== "preserved"
|
||||||
|
|| descriptor.commandTransport !== "disabled"
|
||||||
|
|| descriptor.gelios !== "untouched"
|
||||||
|
|| descriptor.rollback !== "restore-preapply-source-and-core-runtime"
|
||||||
|
) {
|
||||||
|
throw new Error("device_control_core_release_contract_mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, "utf8");
|
||||||
|
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
|
||||||
|
await mkdir(artifactDir, { recursive: true });
|
||||||
|
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 128 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||||
|
const sha256 = createHash("sha256").update(await readFile(target)).digest("hex");
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
patchId,
|
||||||
|
component: "device-plane",
|
||||||
|
artifact: target,
|
||||||
|
sha256,
|
||||||
|
entries,
|
||||||
|
services: ["device-control-core"],
|
||||||
|
preserved: ["device-manager", "device-gateway", "device-postgres", "device-backhaul-target", "edge registrations", "mTLS identity", "Gelios"],
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(stage, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySafe(source, destination, sourceBoundary) {
|
||||||
|
const sourceStat = await lstat(source);
|
||||||
|
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(sourceBoundary, source)}`);
|
||||||
|
if (sourceStat.isFile()) {
|
||||||
|
if (source.endsWith(".test.mjs") || source.endsWith(".map")) return;
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${source}`);
|
||||||
|
await mkdir(destination, { recursive: true });
|
||||||
|
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||||
|
if ([".DS_Store", ".git", "node_modules", "test"].includes(entry.name) || entry.name.startsWith(".env")) continue;
|
||||||
|
await copySafe(join(source, entry.name), join(destination, entry.name), sourceBoundary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateDockerCopySources(dockerfilePath, buildContext) {
|
||||||
|
const dockerfile = await readFile(dockerfilePath, "utf8");
|
||||||
|
for (const [index, rawLine] of dockerfile.split("\n").entries()) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!/^COPY\s+/i.test(line)) continue;
|
||||||
|
if (line.endsWith("\\") || /^COPY\s+\[/i.test(line)) {
|
||||||
|
throw new Error(`unsupported_docker_copy_syntax:${dockerfilePath}:${index + 1}`);
|
||||||
|
}
|
||||||
|
const tokens = line.split(/\s+/).slice(1);
|
||||||
|
while (tokens[0]?.startsWith("--")) tokens.shift();
|
||||||
|
if (tokens.length < 2) throw new Error(`invalid_docker_copy:${dockerfilePath}:${index + 1}`);
|
||||||
|
for (const source of tokens.slice(0, -1)) {
|
||||||
|
if (/[*?[\]{}]/.test(source)) throw new Error(`docker_copy_glob_rejected:${dockerfilePath}:${index + 1}:${source}`);
|
||||||
|
const resolvedSource = resolve(buildContext, source);
|
||||||
|
const relativeSource = relative(buildContext, resolvedSource);
|
||||||
|
if (!relativeSource || relativeSource.startsWith("..") || resolve(buildContext, relativeSource) !== resolvedSource) {
|
||||||
|
throw new Error(`docker_copy_source_outside_context:${dockerfilePath}:${index + 1}:${source}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await lstat(resolvedSource);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ENOENT") throw new Error(`docker_copy_source_missing:${dockerfilePath}:${index + 1}:${source}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTarScript() {
|
||||||
|
return [
|
||||||
|
"import gzip,io,pathlib,sys,tarfile",
|
||||||
|
"root=pathlib.Path(sys.argv[2])",
|
||||||
|
"with open(sys.argv[1],'wb') as out:",
|
||||||
|
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||||
|
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||||
|
" for top in ('manifest.env','files.txt','payload'):",
|
||||||
|
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||||
|
" for x in paths:",
|
||||||
|
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||||
|
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
@@ -259,6 +259,9 @@ DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL = (
|
|||||||
DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL = (
|
DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL = (
|
||||||
"deployment/device-edge-core-channel-upgrade-v4.json"
|
"deployment/device-edge-core-channel-upgrade-v4.json"
|
||||||
)
|
)
|
||||||
|
DEVICE_PLANE_CONTROL_CORE_RELEASE_REL = (
|
||||||
|
"deployment/device-control-core-release-v1.json"
|
||||||
|
)
|
||||||
DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL = (
|
DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL = (
|
||||||
"docker-compose.device-edge-core-channel.yml"
|
"docker-compose.device-edge-core-channel.yml"
|
||||||
)
|
)
|
||||||
@@ -311,6 +314,21 @@ DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_ENTRIES = (
|
|||||||
"services/device-control-core",
|
"services/device-control-core",
|
||||||
DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL,
|
DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL,
|
||||||
)
|
)
|
||||||
|
DEVICE_PLANE_CONTROL_CORE_RELEASE_ENTRIES = (
|
||||||
|
".dockerignore",
|
||||||
|
"package.json",
|
||||||
|
"package-lock.json",
|
||||||
|
"packages/device-protocol-contract",
|
||||||
|
"packages/device-edge-channel-contract",
|
||||||
|
"services/device-control-core",
|
||||||
|
DEVICE_PLANE_CONTROL_CORE_RELEASE_REL,
|
||||||
|
)
|
||||||
|
DEVICE_PLANE_CONTROL_CORE_RELEASE_FIRST_PREDECESSOR_PATCH_ID = (
|
||||||
|
"device-edge-core-channel-upgrade-v4-20260812-023"
|
||||||
|
)
|
||||||
|
DEVICE_PLANE_CONTROL_CORE_RELEASE_FIRST_PREDECESSOR_ARTIFACT_SHA256 = (
|
||||||
|
"c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|
||||||
|
)
|
||||||
DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_PREDECESSOR_PATCH_ID = (
|
DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_PREDECESSOR_PATCH_ID = (
|
||||||
"device-edge-core-channel-bootstrap-20260812-018"
|
"device-edge-core-channel-bootstrap-20260812-018"
|
||||||
)
|
)
|
||||||
@@ -3875,6 +3893,7 @@ def allowed_payload_path(component, rel):
|
|||||||
DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_REL,
|
DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_REL,
|
||||||
DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL,
|
DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL,
|
||||||
DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL,
|
DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL,
|
||||||
|
DEVICE_PLANE_CONTROL_CORE_RELEASE_REL,
|
||||||
DEVICE_PLANE_MANAGER_RECONCILIATION_REL,
|
DEVICE_PLANE_MANAGER_RECONCILIATION_REL,
|
||||||
DEVICE_PLANE_MANAGER_V2_RECONCILIATION_REL,
|
DEVICE_PLANE_MANAGER_V2_RECONCILIATION_REL,
|
||||||
"packages/device-protocol-contract",
|
"packages/device-protocol-contract",
|
||||||
@@ -8927,6 +8946,14 @@ def load_artifact(artifact, work_dir):
|
|||||||
payload_dir,
|
payload_dir,
|
||||||
expected_release_id=manifest["id"],
|
expected_release_id=manifest["id"],
|
||||||
)
|
)
|
||||||
|
if is_device_plane_control_core_release_slice(
|
||||||
|
manifest["component"],
|
||||||
|
entries,
|
||||||
|
):
|
||||||
|
validate_device_plane_control_core_release_payload(
|
||||||
|
payload_dir,
|
||||||
|
expected_release_id=manifest["id"],
|
||||||
|
)
|
||||||
if is_device_plane_edge_core_channel_bootstrap_slice(
|
if is_device_plane_edge_core_channel_bootstrap_slice(
|
||||||
manifest["component"],
|
manifest["component"],
|
||||||
entries,
|
entries,
|
||||||
@@ -9485,6 +9512,14 @@ def is_device_plane_edge_core_channel_upgrade_v4_slice(component, entries):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_device_plane_control_core_release_slice(component, entries):
|
||||||
|
return (
|
||||||
|
component == "device-plane"
|
||||||
|
and entries is not None
|
||||||
|
and tuple(entries) == DEVICE_PLANE_CONTROL_CORE_RELEASE_ENTRIES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_device_plane_manager_v2_control_plane_slice(component, entries):
|
def is_device_plane_manager_v2_control_plane_slice(component, entries):
|
||||||
return (
|
return (
|
||||||
component == "device-plane"
|
component == "device-plane"
|
||||||
@@ -9785,6 +9820,49 @@ def expected_device_plane_edge_core_channel_upgrade_v4_descriptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def expected_device_plane_control_core_release_descriptor(
|
||||||
|
release_id,
|
||||||
|
predecessor,
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"schemaVersion": (
|
||||||
|
"nodedc.device-plane.device-control-core-release.v1"
|
||||||
|
),
|
||||||
|
"releaseId": release_id,
|
||||||
|
"action": "upgrade",
|
||||||
|
"predecessor": predecessor,
|
||||||
|
"service": "device-control-core",
|
||||||
|
"composeActivation": "preserve-active-v4-topology",
|
||||||
|
"identity": (
|
||||||
|
"reuse-existing-runner-managed-host-local-private-key-"
|
||||||
|
"public-certificate-export"
|
||||||
|
),
|
||||||
|
"identityRecovery": "forbidden-valid-existing-identity-required",
|
||||||
|
"tlsPurpose": "clientAuth",
|
||||||
|
"direction": "core-initiated",
|
||||||
|
"endpointPolicy": "public-ipv4-standard-https-tcp-443-only",
|
||||||
|
"coreNetworks": [
|
||||||
|
"device-plane-private",
|
||||||
|
"device-plane-egress",
|
||||||
|
],
|
||||||
|
"publicIngress": "none-on-synology",
|
||||||
|
"edgeRegistrations": "preserved",
|
||||||
|
"commandTransport": "disabled",
|
||||||
|
"gelios": "untouched",
|
||||||
|
"preservedServices": [
|
||||||
|
"device-manager",
|
||||||
|
"device-gateway",
|
||||||
|
"device-postgres",
|
||||||
|
"device-backhaul-target",
|
||||||
|
],
|
||||||
|
"healthGate": (
|
||||||
|
"bounded-container-grace+core-edge-contract+"
|
||||||
|
"exact-private-egress-network-boundary"
|
||||||
|
),
|
||||||
|
"rollback": "restore-preapply-source-and-core-runtime",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def expected_device_plane_manager_failed_control_plane_descriptor():
|
def expected_device_plane_manager_failed_control_plane_descriptor():
|
||||||
return {
|
return {
|
||||||
"schemaVersion": (
|
"schemaVersion": (
|
||||||
@@ -10202,6 +10280,70 @@ def validate_device_plane_edge_core_channel_upgrade_v4_payload(
|
|||||||
return descriptor
|
return descriptor
|
||||||
|
|
||||||
|
|
||||||
|
def validate_device_plane_control_core_release_payload(
|
||||||
|
payload_dir,
|
||||||
|
*,
|
||||||
|
expected_release_id=None,
|
||||||
|
):
|
||||||
|
descriptor = read_strict_json(
|
||||||
|
payload_dir / DEVICE_PLANE_CONTROL_CORE_RELEASE_REL,
|
||||||
|
"Device Control Core release descriptor",
|
||||||
|
max_bytes=16 * 1024,
|
||||||
|
)
|
||||||
|
release_id = descriptor.get("releaseId")
|
||||||
|
predecessor = descriptor.get("predecessor")
|
||||||
|
valid_predecessor = (
|
||||||
|
isinstance(predecessor, dict)
|
||||||
|
and set(predecessor) == {"kind", "patchId", "artifactSha256"}
|
||||||
|
and predecessor.get("kind") in (
|
||||||
|
"edge-core-channel-upgrade-v4",
|
||||||
|
"release",
|
||||||
|
)
|
||||||
|
and isinstance(predecessor.get("patchId"), str)
|
||||||
|
and re.fullmatch(
|
||||||
|
r"[A-Za-z0-9._-]{1,96}",
|
||||||
|
predecessor["patchId"],
|
||||||
|
)
|
||||||
|
and isinstance(predecessor.get("artifactSha256"), str)
|
||||||
|
and re.fullmatch(r"[0-9a-f]{64}", predecessor["artifactSha256"])
|
||||||
|
)
|
||||||
|
if valid_predecessor and predecessor["kind"] == (
|
||||||
|
"edge-core-channel-upgrade-v4"
|
||||||
|
):
|
||||||
|
valid_predecessor = (
|
||||||
|
predecessor["patchId"]
|
||||||
|
== DEVICE_PLANE_CONTROL_CORE_RELEASE_FIRST_PREDECESSOR_PATCH_ID
|
||||||
|
and predecessor["artifactSha256"]
|
||||||
|
== DEVICE_PLANE_CONTROL_CORE_RELEASE_FIRST_PREDECESSOR_ARTIFACT_SHA256
|
||||||
|
)
|
||||||
|
elif valid_predecessor:
|
||||||
|
valid_predecessor = (
|
||||||
|
predecessor["patchId"].startswith(
|
||||||
|
"device-control-core-release-"
|
||||||
|
)
|
||||||
|
and predecessor["patchId"] != release_id
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not isinstance(release_id, str)
|
||||||
|
or not re.fullmatch(
|
||||||
|
r"device-control-core-release-[A-Za-z0-9._-]{1,67}",
|
||||||
|
release_id,
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
expected_release_id is not None
|
||||||
|
and release_id != expected_release_id
|
||||||
|
)
|
||||||
|
or not valid_predecessor
|
||||||
|
or descriptor
|
||||||
|
!= expected_device_plane_control_core_release_descriptor(
|
||||||
|
release_id,
|
||||||
|
predecessor,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
die("Device Control Core release descriptor mismatch")
|
||||||
|
return descriptor
|
||||||
|
|
||||||
|
|
||||||
def validate_device_plane_edge_core_channel_compose(payload_dir):
|
def validate_device_plane_edge_core_channel_compose(payload_dir):
|
||||||
compose = payload_dir / DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL
|
compose = payload_dir / DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL
|
||||||
if sha256_file(compose) != DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_SHA256:
|
if sha256_file(compose) != DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_SHA256:
|
||||||
@@ -12389,6 +12531,114 @@ def validate_device_plane_edge_core_channel_upgrade_v4_predecessor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_device_plane_control_core_release_predecessor(payload_dir):
|
||||||
|
descriptor = validate_device_plane_control_core_release_payload(
|
||||||
|
payload_dir
|
||||||
|
)
|
||||||
|
predecessor = descriptor["predecessor"]
|
||||||
|
artifact_name = f"nodedc-device-plane-{predecessor['patchId']}.tgz"
|
||||||
|
artifact = APPLIED_DIR / artifact_name
|
||||||
|
if (
|
||||||
|
not artifact.is_file()
|
||||||
|
or artifact.is_symlink()
|
||||||
|
or sha256_file(artifact) != predecessor["artifactSha256"]
|
||||||
|
):
|
||||||
|
die("Device Control Core release predecessor mismatch")
|
||||||
|
records = [
|
||||||
|
row for row in load_state(STATE_FILE)
|
||||||
|
if row.get("id") == predecessor["patchId"]
|
||||||
|
and row.get("sha256") == predecessor["artifactSha256"]
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
len(records) != 1
|
||||||
|
or records[0].get("status") != "ok"
|
||||||
|
or records[0].get("component") != "device-plane"
|
||||||
|
or records[0].get("artifact") != artifact_name
|
||||||
|
):
|
||||||
|
die("Device Control Core release predecessor journal mismatch")
|
||||||
|
|
||||||
|
installed_release = DEVICE_PLANE_ROOT / DEVICE_PLANE_CONTROL_CORE_RELEASE_REL
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix="device-control-core-release-predecessor-",
|
||||||
|
dir=TMP_DIR,
|
||||||
|
) as directory:
|
||||||
|
manifest, entries, predecessor_payload = load_artifact(
|
||||||
|
artifact,
|
||||||
|
Path(directory),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
manifest.get("id") != predecessor["patchId"]
|
||||||
|
or manifest.get("component") != "device-plane"
|
||||||
|
or manifest.get("type") != "app-overlay"
|
||||||
|
):
|
||||||
|
die("Device Control Core release predecessor type mismatch")
|
||||||
|
if predecessor["kind"] == "edge-core-channel-upgrade-v4":
|
||||||
|
if tuple(entries) != DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_ENTRIES:
|
||||||
|
die("Device Control Core release v4 predecessor type mismatch")
|
||||||
|
validate_device_plane_edge_core_channel_upgrade_v4_payload(
|
||||||
|
predecessor_payload,
|
||||||
|
expected_transition_id=predecessor["patchId"],
|
||||||
|
)
|
||||||
|
source_entries = tuple(
|
||||||
|
rel for rel in DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_ENTRIES
|
||||||
|
if rel not in (
|
||||||
|
"docker-compose.device-plane.yml",
|
||||||
|
DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if installed_release.exists() or installed_release.is_symlink():
|
||||||
|
die("Device Control Core first release is already installed")
|
||||||
|
else:
|
||||||
|
if tuple(entries) != DEVICE_PLANE_CONTROL_CORE_RELEASE_ENTRIES:
|
||||||
|
die("Device Control Core release predecessor type mismatch")
|
||||||
|
expected_installed = validate_device_plane_control_core_release_payload(
|
||||||
|
predecessor_payload,
|
||||||
|
expected_release_id=predecessor["patchId"],
|
||||||
|
)
|
||||||
|
installed_descriptor = read_strict_json(
|
||||||
|
installed_release,
|
||||||
|
"installed Device Control Core release predecessor",
|
||||||
|
max_bytes=16 * 1024,
|
||||||
|
)
|
||||||
|
if installed_descriptor != expected_installed:
|
||||||
|
die("Device Control Core release predecessor is not current")
|
||||||
|
source_entries = DEVICE_PLANE_CONTROL_CORE_RELEASE_ENTRIES
|
||||||
|
expected_source = collect_exact_files(
|
||||||
|
predecessor_payload,
|
||||||
|
source_entries,
|
||||||
|
"Device Control Core release predecessor source",
|
||||||
|
)
|
||||||
|
actual_source = collect_exact_files(
|
||||||
|
DEVICE_PLANE_ROOT,
|
||||||
|
source_entries,
|
||||||
|
"installed Device Control Core release predecessor source",
|
||||||
|
)
|
||||||
|
if actual_source != expected_source:
|
||||||
|
die("installed Device Control Core release source drift detected")
|
||||||
|
|
||||||
|
component_compose_files("device-plane")
|
||||||
|
identity_state = inspect_device_edge_channel_core_identity_state()
|
||||||
|
if identity_state != "valid-reuse-at-apply":
|
||||||
|
die("Device Control Core release requires the valid active identity")
|
||||||
|
for service in (
|
||||||
|
"device-control-core",
|
||||||
|
"device-manager",
|
||||||
|
"device-gateway",
|
||||||
|
"device-postgres",
|
||||||
|
):
|
||||||
|
healthcheck_compose_service("device-plane", service)
|
||||||
|
validate_device_manager_control_plane_runtime(
|
||||||
|
require_edge_channel=True,
|
||||||
|
core_network_mode="private-egress",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"mode": "active-device-control-core-forward-release",
|
||||||
|
"descriptor": descriptor,
|
||||||
|
"identityState": identity_state,
|
||||||
|
"predecessorArtifact": artifact,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def validate_device_plane_manager_v2_reconciliation_backup():
|
def validate_device_plane_manager_v2_reconciliation_backup():
|
||||||
backup_dir = (
|
backup_dir = (
|
||||||
BACKUPS_DIR / DEVICE_PLANE_MANAGER_V2_RECONCILIATION_BACKUP_ID
|
BACKUPS_DIR / DEVICE_PLANE_MANAGER_V2_RECONCILIATION_BACKUP_ID
|
||||||
@@ -15833,6 +16083,9 @@ def is_platform_provider_catalog_only(entries):
|
|||||||
|
|
||||||
|
|
||||||
def component_services(component, entries=None):
|
def component_services(component, entries=None):
|
||||||
|
if is_device_plane_control_core_release_slice(component, entries):
|
||||||
|
return ("device-control-core",)
|
||||||
|
|
||||||
if is_device_plane_edge_core_channel_bootstrap_slice(component, entries):
|
if is_device_plane_edge_core_channel_bootstrap_slice(component, entries):
|
||||||
return ("device-control-core",)
|
return ("device-control-core",)
|
||||||
|
|
||||||
@@ -16409,7 +16662,13 @@ def component_builds(component, entries=None):
|
|||||||
if is_platform_device_manager_public_route_slice(component, entries):
|
if is_platform_device_manager_public_route_slice(component, entries):
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
if is_device_plane_edge_core_channel_bootstrap_slice(component, entries):
|
if (
|
||||||
|
is_device_plane_control_core_release_slice(component, entries)
|
||||||
|
or is_device_plane_edge_core_channel_bootstrap_slice(
|
||||||
|
component,
|
||||||
|
entries,
|
||||||
|
)
|
||||||
|
):
|
||||||
return ((
|
return ((
|
||||||
DEVICE_PLANE_ROOT,
|
DEVICE_PLANE_ROOT,
|
||||||
(
|
(
|
||||||
@@ -18481,6 +18740,7 @@ def plan_artifact(artifact):
|
|||||||
device_plane_b2_recovery_preflight = None
|
device_plane_b2_recovery_preflight = None
|
||||||
device_plane_manager_activation_preflight = None
|
device_plane_manager_activation_preflight = None
|
||||||
device_plane_edge_core_channel_preflight = None
|
device_plane_edge_core_channel_preflight = None
|
||||||
|
device_plane_control_core_release_preflight = None
|
||||||
device_plane_manager_reconciliation_preflight = None
|
device_plane_manager_reconciliation_preflight = None
|
||||||
device_plane_manager_v2_reconciliation_preflight = None
|
device_plane_manager_v2_reconciliation_preflight = None
|
||||||
device_plane_backhaul_preflight = None
|
device_plane_backhaul_preflight = None
|
||||||
@@ -18711,6 +18971,15 @@ def plan_artifact(artifact):
|
|||||||
payload_dir
|
payload_dir
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if is_device_plane_control_core_release_slice(
|
||||||
|
manifest["component"],
|
||||||
|
entries,
|
||||||
|
):
|
||||||
|
device_plane_control_core_release_preflight = (
|
||||||
|
validate_device_plane_control_core_release_predecessor(
|
||||||
|
payload_dir
|
||||||
|
)
|
||||||
|
)
|
||||||
if is_device_plane_manager_reconciliation_slice(
|
if is_device_plane_manager_reconciliation_slice(
|
||||||
manifest["component"],
|
manifest["component"],
|
||||||
entries,
|
entries,
|
||||||
@@ -20129,9 +20398,12 @@ def plan_artifact(artifact):
|
|||||||
"runtime_secret=runner-managed:"
|
"runtime_secret=runner-managed:"
|
||||||
f"{PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE}"
|
f"{PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE}"
|
||||||
)
|
)
|
||||||
if is_device_plane_edge_core_channel_bootstrap_slice(
|
if (
|
||||||
component,
|
is_device_plane_control_core_release_slice(component, entries)
|
||||||
entries,
|
or is_device_plane_edge_core_channel_bootstrap_slice(
|
||||||
|
component,
|
||||||
|
entries,
|
||||||
|
)
|
||||||
):
|
):
|
||||||
print(
|
print(
|
||||||
"runtime_secret=runner-managed:"
|
"runtime_secret=runner-managed:"
|
||||||
@@ -20331,6 +20603,46 @@ def plan_artifact(artifact):
|
|||||||
"device_plane_rollback="
|
"device_plane_rollback="
|
||||||
"source+reconciled-baseline-runtime"
|
"source+reconciled-baseline-runtime"
|
||||||
)
|
)
|
||||||
|
if device_plane_control_core_release_preflight is not None:
|
||||||
|
core_release = device_plane_control_core_release_preflight
|
||||||
|
descriptor = core_release["descriptor"]
|
||||||
|
predecessor = descriptor["predecessor"]
|
||||||
|
print(
|
||||||
|
"device_plane_transition="
|
||||||
|
f"{core_release['mode']}"
|
||||||
|
)
|
||||||
|
print(f"device_control_core_release={descriptor['releaseId']}")
|
||||||
|
print(
|
||||||
|
"device_plane_predecessor_kind="
|
||||||
|
f"{predecessor['kind']}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"device_plane_predecessor_patch="
|
||||||
|
f"{predecessor['patchId']}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"device_plane_predecessor_artifact_sha256="
|
||||||
|
f"{predecessor['artifactSha256']}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"device_edge_channel_identity_preflight="
|
||||||
|
f"{core_release['identityState']}"
|
||||||
|
)
|
||||||
|
print("device_plane_runtime_mutation=build+recreate:device-control-core")
|
||||||
|
print(
|
||||||
|
"device_plane_runtime_services="
|
||||||
|
"preserved:device-manager,device-gateway,device-postgres,"
|
||||||
|
"device-backhaul-target"
|
||||||
|
)
|
||||||
|
print("device_edge_channel=preserved:core-initiated:pinned-mtls:registered-edges-only")
|
||||||
|
print("device_edge_channel_networks=preserved:device-plane-private,device-plane-egress")
|
||||||
|
print("device_edge_channel_registrations=preserved")
|
||||||
|
print("device_edge_channel_commands=disabled")
|
||||||
|
print("device_manager=preserved:active")
|
||||||
|
print("device_manager_public_route=unchanged:active")
|
||||||
|
print("device_gateway_tcp_9921=preserved:loopback-only")
|
||||||
|
print("gelios=untouched")
|
||||||
|
print("device_plane_rollback=source+preapply-core-runtime")
|
||||||
if device_plane_edge_core_channel_preflight is not None:
|
if device_plane_edge_core_channel_preflight is not None:
|
||||||
print(
|
print(
|
||||||
"device_plane_transition="
|
"device_plane_transition="
|
||||||
@@ -21096,7 +21408,25 @@ def rollback_device_plane_apply(
|
|||||||
baseline_entries,
|
baseline_entries,
|
||||||
baseline_services,
|
baseline_services,
|
||||||
)
|
)
|
||||||
if is_device_plane_edge_core_channel_bootstrap_slice(
|
if is_device_plane_control_core_release_slice(
|
||||||
|
"device-plane",
|
||||||
|
entries,
|
||||||
|
):
|
||||||
|
for service in (
|
||||||
|
"device-control-core",
|
||||||
|
"device-manager",
|
||||||
|
"device-gateway",
|
||||||
|
"device-postgres",
|
||||||
|
):
|
||||||
|
healthcheck_compose_service_with_grace(
|
||||||
|
"device-plane",
|
||||||
|
service,
|
||||||
|
)
|
||||||
|
validate_device_manager_control_plane_runtime(
|
||||||
|
require_edge_channel=True,
|
||||||
|
core_network_mode="private-egress",
|
||||||
|
)
|
||||||
|
elif is_device_plane_edge_core_channel_bootstrap_slice(
|
||||||
"device-plane",
|
"device-plane",
|
||||||
entries,
|
entries,
|
||||||
):
|
):
|
||||||
@@ -21921,9 +22251,12 @@ def prepare_component_runtime(component, entries=None):
|
|||||||
MAP_GATEWAY_SECRET_RE,
|
MAP_GATEWAY_SECRET_RE,
|
||||||
"Device Core Hub handoff",
|
"Device Core Hub handoff",
|
||||||
)
|
)
|
||||||
if is_device_plane_edge_core_channel_bootstrap_slice(
|
if (
|
||||||
component,
|
is_device_plane_control_core_release_slice(component, entries)
|
||||||
entries,
|
or is_device_plane_edge_core_channel_bootstrap_slice(
|
||||||
|
component,
|
||||||
|
entries,
|
||||||
|
)
|
||||||
):
|
):
|
||||||
ensure_platform_runtime_secret(
|
ensure_platform_runtime_secret(
|
||||||
DEVICE_PLANE_MANAGEMENT_CORE_TOKEN_FILE,
|
DEVICE_PLANE_MANAGEMENT_CORE_TOKEN_FILE,
|
||||||
@@ -21932,7 +22265,11 @@ def prepare_component_runtime(component, entries=None):
|
|||||||
)
|
)
|
||||||
ensure_device_edge_channel_core_identity(
|
ensure_device_edge_channel_core_identity(
|
||||||
allow_invalid_unexported_recovery=(
|
allow_invalid_unexported_recovery=(
|
||||||
not is_device_plane_edge_core_channel_upgrade_slice(
|
not is_device_plane_control_core_release_slice(
|
||||||
|
component,
|
||||||
|
entries,
|
||||||
|
)
|
||||||
|
and not is_device_plane_edge_core_channel_upgrade_slice(
|
||||||
component,
|
component,
|
||||||
entries,
|
entries,
|
||||||
)
|
)
|
||||||
@@ -22245,7 +22582,13 @@ def component_healthchecks(component, entries=None, services=None):
|
|||||||
"commandTransport": "disabled",
|
"commandTransport": "disabled",
|
||||||
},
|
},
|
||||||
},)
|
},)
|
||||||
if is_device_plane_edge_core_channel_bootstrap_slice(component, entries):
|
if (
|
||||||
|
is_device_plane_control_core_release_slice(component, entries)
|
||||||
|
or is_device_plane_edge_core_channel_bootstrap_slice(
|
||||||
|
component,
|
||||||
|
entries,
|
||||||
|
)
|
||||||
|
):
|
||||||
return ({
|
return ({
|
||||||
"url": "http://127.0.0.1:18120/healthz",
|
"url": "http://127.0.0.1:18120/healthz",
|
||||||
"expected_json": {
|
"expected_json": {
|
||||||
@@ -22811,6 +23154,27 @@ process.stdout.write('engine-l2-closed-loop:0.7.0:cas+safe-profile+external-plan
|
|||||||
|
|
||||||
|
|
||||||
def run_healthchecks(component, entries=None, services=None):
|
def run_healthchecks(component, entries=None, services=None):
|
||||||
|
if is_device_plane_control_core_release_slice(component, entries):
|
||||||
|
if tuple(services or ()) != ("device-control-core",):
|
||||||
|
die("Device Control Core release service set mismatch")
|
||||||
|
for service in (
|
||||||
|
"device-control-core",
|
||||||
|
"device-manager",
|
||||||
|
"device-gateway",
|
||||||
|
"device-postgres",
|
||||||
|
):
|
||||||
|
healthcheck_compose_service_with_grace(
|
||||||
|
"device-plane",
|
||||||
|
service,
|
||||||
|
)
|
||||||
|
for check in component_healthchecks(component, entries, services):
|
||||||
|
healthcheck_url(check)
|
||||||
|
validate_device_manager_control_plane_runtime(
|
||||||
|
require_edge_channel=True,
|
||||||
|
core_network_mode="private-egress",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if is_device_plane_manager_reconciliation_slice(component, entries):
|
if is_device_plane_manager_reconciliation_slice(component, entries):
|
||||||
if tuple(services or ()) != ():
|
if tuple(services or ()) != ():
|
||||||
die("Device Manager reconciliation service set mismatch")
|
die("Device Manager reconciliation service set mismatch")
|
||||||
@@ -23854,6 +24218,13 @@ def apply_artifact(artifact):
|
|||||||
validate_device_plane_edge_core_channel_bootstrap_predecessor(
|
validate_device_plane_edge_core_channel_bootstrap_predecessor(
|
||||||
payload_dir
|
payload_dir
|
||||||
)
|
)
|
||||||
|
if is_device_plane_control_core_release_slice(
|
||||||
|
component,
|
||||||
|
entries,
|
||||||
|
):
|
||||||
|
validate_device_plane_control_core_release_predecessor(
|
||||||
|
payload_dir
|
||||||
|
)
|
||||||
if is_device_plane_manager_reconciliation_slice(
|
if is_device_plane_manager_reconciliation_slice(
|
||||||
component,
|
component,
|
||||||
entries,
|
entries,
|
||||||
@@ -24174,9 +24545,15 @@ def apply_artifact(artifact):
|
|||||||
*inventory_services,
|
*inventory_services,
|
||||||
"device-manager",
|
"device-manager",
|
||||||
)
|
)
|
||||||
if is_device_plane_edge_core_channel_bootstrap_slice(
|
if (
|
||||||
component,
|
is_device_plane_control_core_release_slice(
|
||||||
entries,
|
component,
|
||||||
|
entries,
|
||||||
|
)
|
||||||
|
or is_device_plane_edge_core_channel_bootstrap_slice(
|
||||||
|
component,
|
||||||
|
entries,
|
||||||
|
)
|
||||||
):
|
):
|
||||||
inventory_services = (
|
inventory_services = (
|
||||||
*inventory_services,
|
*inventory_services,
|
||||||
|
|||||||
@@ -455,6 +455,166 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
root / "payload"
|
root / "payload"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_control_core_release_is_repeatable_core_only_and_compose_free(self):
|
||||||
|
patch_id = "device-control-core-release-unit-001"
|
||||||
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
|
"build-device-control-core-release-artifact.mjs",
|
||||||
|
patch_id,
|
||||||
|
RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_ENTRIES,
|
||||||
|
)
|
||||||
|
self.assertEqual(manifest["component"], "device-plane")
|
||||||
|
self.assertEqual(
|
||||||
|
RUNNER.component_services("device-plane", entries),
|
||||||
|
("device-control-core",),
|
||||||
|
)
|
||||||
|
self.assertEqual(len(RUNNER.component_builds("device-plane", entries)), 1)
|
||||||
|
self.assertEqual(result["services"], ["device-control-core"])
|
||||||
|
self.assertIn(
|
||||||
|
"payload/deployment/device-control-core-release-v1.json",
|
||||||
|
names,
|
||||||
|
)
|
||||||
|
self.assertFalse(any("docker-compose" in name for name in names))
|
||||||
|
descriptor = RUNNER.expected_device_plane_control_core_release_descriptor(
|
||||||
|
patch_id,
|
||||||
|
{
|
||||||
|
"kind": "edge-core-channel-upgrade-v4",
|
||||||
|
"patchId": (
|
||||||
|
RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_FIRST_PREDECESSOR_PATCH_ID
|
||||||
|
),
|
||||||
|
"artifactSha256": (
|
||||||
|
RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_FIRST_PREDECESSOR_ARTIFACT_SHA256
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["coreNetworks"],
|
||||||
|
["device-plane-private", "device-plane-egress"],
|
||||||
|
)
|
||||||
|
self.assertEqual(descriptor["edgeRegistrations"], "preserved")
|
||||||
|
|
||||||
|
def test_control_core_release_apply_gate_checks_preserved_runtime(self):
|
||||||
|
entries = RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_ENTRIES
|
||||||
|
services = ("device-control-core",)
|
||||||
|
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)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[call.args for call in service_health.call_args_list],
|
||||||
|
[
|
||||||
|
("device-plane", "device-control-core"),
|
||||||
|
("device-plane", "device-manager"),
|
||||||
|
("device-plane", "device-gateway"),
|
||||||
|
("device-plane", "device-postgres"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
url_health.assert_called_once_with(
|
||||||
|
RUNNER.component_healthchecks(
|
||||||
|
"device-plane",
|
||||||
|
entries,
|
||||||
|
services,
|
||||||
|
)[0]
|
||||||
|
)
|
||||||
|
runtime_acceptance.assert_called_once_with(
|
||||||
|
require_edge_channel=True,
|
||||||
|
core_network_mode="private-egress",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_control_core_release_builder_supports_release_predecessor(self):
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix="nodedc-control-core-successor-",
|
||||||
|
) as directory:
|
||||||
|
result = self.build(
|
||||||
|
"build-device-control-core-release-artifact.mjs",
|
||||||
|
"device-control-core-release-unit-004",
|
||||||
|
Path(directory),
|
||||||
|
)
|
||||||
|
first_artifact = Path(result["artifact"])
|
||||||
|
# Rebuild through the CLI's repeatable-release predecessor form.
|
||||||
|
environment = os.environ.copy()
|
||||||
|
successor_dir = Path(directory) / "successor"
|
||||||
|
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(successor_dir)
|
||||||
|
predecessor_id = "device-control-core-release-unit-004"
|
||||||
|
predecessor_sha = hashlib.sha256(
|
||||||
|
first_artifact.read_bytes()
|
||||||
|
).hexdigest()
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
"node",
|
||||||
|
str(
|
||||||
|
SCRIPT_DIR
|
||||||
|
/ "build-device-control-core-release-artifact.mjs"
|
||||||
|
),
|
||||||
|
"device-control-core-release-unit-005",
|
||||||
|
predecessor_id,
|
||||||
|
predecessor_sha,
|
||||||
|
],
|
||||||
|
cwd=PLATFORM_ROOT,
|
||||||
|
env=environment,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
successor = json.loads(completed.stdout)
|
||||||
|
extracted = Path(directory) / "extracted-successor"
|
||||||
|
extracted.mkdir()
|
||||||
|
_manifest, _entries, payload = RUNNER.load_artifact(
|
||||||
|
Path(successor["artifact"]),
|
||||||
|
extracted,
|
||||||
|
)
|
||||||
|
descriptor = json.loads(
|
||||||
|
(
|
||||||
|
payload / RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_REL
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
descriptor["predecessor"],
|
||||||
|
{
|
||||||
|
"kind": "release",
|
||||||
|
"patchId": predecessor_id,
|
||||||
|
"artifactSha256": predecessor_sha,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_control_core_release_rejects_direct_legacy_predecessor(self):
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix="nodedc-control-core-release-invalid-",
|
||||||
|
) as directory:
|
||||||
|
payload = Path(directory)
|
||||||
|
descriptor_path = (
|
||||||
|
payload / RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_REL
|
||||||
|
)
|
||||||
|
descriptor_path.parent.mkdir(parents=True)
|
||||||
|
predecessor = {
|
||||||
|
"kind": "release",
|
||||||
|
"patchId": "device-edge-core-channel-upgrade-v4-20260812-023",
|
||||||
|
"artifactSha256": "a" * 64,
|
||||||
|
}
|
||||||
|
descriptor = RUNNER.expected_device_plane_control_core_release_descriptor(
|
||||||
|
"device-control-core-release-unit-002",
|
||||||
|
predecessor,
|
||||||
|
)
|
||||||
|
descriptor_path.write_text(
|
||||||
|
json.dumps(descriptor),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
RUNNER.DeployError,
|
||||||
|
"release descriptor mismatch",
|
||||||
|
):
|
||||||
|
RUNNER.validate_device_plane_control_core_release_payload(
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
|
||||||
def test_edge_core_channel_upgrade_v2_rejects_installed_marker(self):
|
def test_edge_core_channel_upgrade_v2_rejects_installed_marker(self):
|
||||||
with tempfile.TemporaryDirectory(
|
with tempfile.TemporaryDirectory(
|
||||||
prefix="nodedc-device-edge-upgrade-v2-installed-",
|
prefix="nodedc-device-edge-upgrade-v2-installed-",
|
||||||
@@ -1651,6 +1811,80 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
runtime_acceptance.assert_called_once_with(require_edge_channel=True)
|
runtime_acceptance.assert_called_once_with(require_edge_channel=True)
|
||||||
self.assertEqual(result, f"source+runtime-restored:{len(entries)}")
|
self.assertEqual(result, f"source+runtime-restored:{len(entries)}")
|
||||||
|
|
||||||
|
def test_control_core_release_rollback_restores_only_core_on_v4_topology(self):
|
||||||
|
entries = RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_ENTRIES
|
||||||
|
missing = {RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_REL}
|
||||||
|
existing = [entry for entry in entries if entry not in missing]
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix="nodedc-control-core-release-rollback-",
|
||||||
|
) as directory:
|
||||||
|
backup = Path(directory) / "backup"
|
||||||
|
backup.mkdir()
|
||||||
|
(backup / "existing-files.txt").write_text(
|
||||||
|
"\n".join(existing) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(backup / "missing-files.txt").write_text(
|
||||||
|
RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_REL + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(backup / "runtime-before.json").write_text(
|
||||||
|
json.dumps(healthy_device_plane_inventory(include_manager=True)),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"stop_and_remove_compose_services",
|
||||||
|
) as stop,
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"restore_platform_overlay",
|
||||||
|
return_value=len(entries),
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"run_component_runtime",
|
||||||
|
) as restore_runtime,
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"healthcheck_compose_service_with_grace",
|
||||||
|
) as restore_health,
|
||||||
|
mock.patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"validate_device_manager_control_plane_runtime",
|
||||||
|
) as runtime_acceptance,
|
||||||
|
):
|
||||||
|
result = RUNNER.rollback_device_plane_apply(
|
||||||
|
Path(directory) / "live",
|
||||||
|
backup,
|
||||||
|
entries,
|
||||||
|
"test-stamp",
|
||||||
|
True,
|
||||||
|
("device-control-core",),
|
||||||
|
)
|
||||||
|
|
||||||
|
stop.assert_not_called()
|
||||||
|
restore_runtime.assert_called_once_with(
|
||||||
|
"device-plane",
|
||||||
|
existing,
|
||||||
|
("device-control-core",),
|
||||||
|
)
|
||||||
|
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"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
runtime_acceptance.assert_called_once_with(
|
||||||
|
require_edge_channel=True,
|
||||||
|
core_network_mode="private-egress",
|
||||||
|
)
|
||||||
|
self.assertEqual(result, f"source+runtime-restored:{len(entries)}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
Reference in New Issue
Block a user