feat: establish standalone Device Core repository
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
#!/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 = platformRoot;
|
||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||
const [
|
||||
patchId = "device-control-core-release-v2-20260812-025",
|
||||
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 isV2 = patchId.startsWith("device-control-core-release-v2-");
|
||||
const expectedV2Predecessor = Object.freeze({
|
||||
patchId: predecessorPatchId ?? "device-control-core-release-20260812-024",
|
||||
artifactSha256: predecessorSha256 ?? "a289e909283109642e6bba3d9822a31f63423cfe0bbcd52705979681bd2bc793",
|
||||
});
|
||||
const descriptorPath = isV2
|
||||
? "deployment/device-control-core-release-v2.json"
|
||||
: "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.${isV2 ? "v2" : "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 !== (isV2 ? "typed-service-ping-v1" : "disabled")
|
||||
|| descriptor.gelios !== (isV2 ? "untouched-legacy-only" : "untouched")
|
||||
|| descriptor.rollback !== "restore-preapply-source-and-core-runtime"
|
||||
|| (
|
||||
isV2
|
||||
&& (
|
||||
descriptor.commandCatalog !== "allowlisted-adapter-typed-commands-only"
|
||||
|| descriptor.credentialBoundary !== "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned"
|
||||
|| descriptor.predecessor?.patchId !== expectedV2Predecessor.patchId
|
||||
|| descriptor.predecessor?.artifactSha256 !== expectedV2Predecessor.artifactSha256
|
||||
)
|
||||
)
|
||||
) {
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/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 = platformRoot;
|
||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||
const [patchId = "device-edge-core-channel-bootstrap-20260811-017", ...extra] = process.argv.slice(2);
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error("usage: build-device-edge-core-channel-bootstrap-artifact.mjs [patch-id]");
|
||||
}
|
||||
|
||||
const upgradeV4 = patchId.startsWith("device-edge-core-channel-upgrade-v4-");
|
||||
const upgradeV2 = !upgradeV4 && patchId.startsWith("device-edge-core-channel-upgrade-v2-");
|
||||
const upgradeV1 = !upgradeV4 && !upgradeV2 && patchId.startsWith("device-edge-core-channel-upgrade-");
|
||||
const upgrade = upgradeV1 || upgradeV2 || upgradeV4;
|
||||
const descriptorPath = upgradeV4
|
||||
? "deployment/device-edge-core-channel-upgrade-v4.json"
|
||||
: upgradeV2
|
||||
? "deployment/device-edge-core-channel-upgrade-v2.json"
|
||||
: upgradeV1
|
||||
? "deployment/device-edge-core-channel-upgrade-v1.json"
|
||||
: "deployment/device-edge-core-channel-bootstrap-v1.json";
|
||||
const composePath = upgradeV4
|
||||
? "docker-compose.device-plane.yml"
|
||||
: "docker-compose.device-edge-core-channel.yml";
|
||||
const entries = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
composePath,
|
||||
"packages/device-protocol-contract",
|
||||
"packages/device-edge-channel-contract",
|
||||
"services/device-control-core",
|
||||
descriptorPath,
|
||||
];
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-edge-core-channel-"));
|
||||
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.transitionId !== "__PATCH_ID__") {
|
||||
throw new Error("device_edge_core_channel_template_id_mismatch");
|
||||
}
|
||||
descriptor.transitionId = patchId;
|
||||
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/sensitive-reference-management.mjs",
|
||||
"services/device-control-core/src/device-gateway-core-runtime.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_edge_core_channel_staged_module_import_failed:${modulePath}:${imported.stderr || imported.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
const compose = await readFile(join(payload, composePath), "utf8");
|
||||
const commonComposeRequired = [
|
||||
"device-control-core:",
|
||||
];
|
||||
const composeRequired = upgradeV4
|
||||
? [
|
||||
...commonComposeRequired,
|
||||
" - device-plane-private",
|
||||
" - device-plane-control",
|
||||
]
|
||||
: [
|
||||
...commonComposeRequired,
|
||||
"DEVICE_EDGE_CHANNEL_ENABLED: \"true\"",
|
||||
"DEVICE_EDGE_CHANNEL_CORE_KEY_FILE: /run/nodedc-secrets/device-edge-channel/core-private-key.pem",
|
||||
"DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE: /run/nodedc-secrets/device-edge-channel/core-certificate.pem",
|
||||
"DEVICE_EDGE_CHANNEL_TRUST_ROOT: /run/nodedc-secrets/device-edge-channel/peers",
|
||||
"source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-private-key.pem",
|
||||
"source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-certificate.pem",
|
||||
"source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/peers",
|
||||
"name: nodedc-device-plane-egress",
|
||||
];
|
||||
for (const required of composeRequired) {
|
||||
if (!compose.includes(required)) {
|
||||
throw new Error(`device_edge_core_channel_compose_contract_missing:${required}`);
|
||||
}
|
||||
}
|
||||
if (upgradeV4) {
|
||||
const coreBlock = compose.split("\n device-control-core:", 2)[1]?.split("\n device-gateway:", 1)[0] || "";
|
||||
const gatewayBlock = compose.split("\n device-gateway:", 2)[1]?.split("\nnetworks:", 1)[0] || "";
|
||||
if (
|
||||
!coreBlock.includes(" - device-plane-private")
|
||||
|| coreBlock.includes(" - device-plane-control")
|
||||
|| !gatewayBlock.includes(" - device-plane-private")
|
||||
|| !gatewayBlock.includes(" - device-plane-control")
|
||||
) {
|
||||
throw new Error("device_edge_core_channel_v4_network_boundary_mismatch");
|
||||
}
|
||||
}
|
||||
const composeForbidden = upgradeV4
|
||||
? ["gw_priority:", "network_mode:", "privileged:"]
|
||||
: [
|
||||
"device-manager:",
|
||||
"device-gateway:",
|
||||
"device-postgres:",
|
||||
"PRIVATE KEY",
|
||||
"ports:",
|
||||
"network_mode:",
|
||||
"privileged:",
|
||||
];
|
||||
for (const forbidden of composeForbidden) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(`device_edge_core_channel_compose_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8"));
|
||||
const descriptorContractMatches = upgradeV4
|
||||
? (
|
||||
descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-upgrade.v4"
|
||||
&& descriptor.action === "upgrade"
|
||||
&& descriptor.composeActivation === "replace-core-network-membership-with-private-plus-egress"
|
||||
&& descriptor.identityRecovery === "forbidden-valid-existing-identity-required"
|
||||
&& descriptor.endpointPolicy === "public-ipv4-standard-https-tcp-443-only"
|
||||
&& descriptor.upgradePredecessor?.patchId === "device-edge-core-channel-upgrade-v2-20260812-021"
|
||||
&& descriptor.upgradePredecessor?.artifactSha256 === "e40a6fd24edfecac09e42cd82635a77850541bcf047788db3e9c55d2b9e58867"
|
||||
&& descriptor.failedAttempt?.patchId === "device-edge-core-channel-upgrade-v3-20260812-022"
|
||||
&& descriptor.failedAttempt?.artifactSha256 === "9e2b409a4b2d19711db434e90d03ac8e3db77bd74949f83cace7949f33caf613"
|
||||
&& descriptor.failedAttempt?.backupId === "device-plane-device-edge-core-channel-upgrade-v3-20260812-022-20260812-123620"
|
||||
&& JSON.stringify(descriptor.coreNetworks) === JSON.stringify(["device-plane-private", "device-plane-egress"])
|
||||
&& descriptor.removedCoreNetwork === "device-plane-control"
|
||||
&& descriptor.composeCompatibility === "synology-compose-v2.20-no-gw-priority"
|
||||
&& descriptor.edgeRegistrations === "preserved"
|
||||
&& descriptor.rollback === "restore-upgrade-v2-021-source-and-preapply-core-runtime"
|
||||
)
|
||||
: upgradeV2
|
||||
? (
|
||||
descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-upgrade.v2"
|
||||
&& descriptor.action === "upgrade"
|
||||
&& descriptor.composeActivation === "preserve-dedicated-additive-override"
|
||||
&& descriptor.identityRecovery === "forbidden-valid-existing-identity-required"
|
||||
&& descriptor.endpointPolicy === "public-ipv4-standard-https-tcp-443-only"
|
||||
&& descriptor.upgradePredecessor?.patchId === "device-edge-core-channel-upgrade-20260812-019"
|
||||
&& descriptor.upgradePredecessor?.artifactSha256 === "8e9a220275959f378c1c4b00be5c7192e79afe2134eaab808a64e515870a8438"
|
||||
&& descriptor.edgeRegistrations === "preserved"
|
||||
&& descriptor.rollback === "restore-upgrade-019-source-and-preapply-core-runtime"
|
||||
)
|
||||
: upgradeV1
|
||||
? (
|
||||
descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-upgrade.v1"
|
||||
&& descriptor.action === "upgrade"
|
||||
&& descriptor.composeActivation === "preserve-dedicated-additive-override"
|
||||
&& descriptor.identityRecovery === "forbidden-valid-existing-identity-required"
|
||||
&& descriptor.endpointPolicy === "public-ipv4-standard-https-tcp-443-only"
|
||||
&& descriptor.bootstrapPredecessor?.patchId === "device-edge-core-channel-bootstrap-20260812-018"
|
||||
&& descriptor.bootstrapPredecessor?.artifactSha256 === "5598b7388b491fe524ab46038ce476482a93a6cf07d8ca5e00206c69ded02931"
|
||||
)
|
||||
: (
|
||||
descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-bootstrap.v1"
|
||||
&& descriptor.action === "activate"
|
||||
&& descriptor.composeActivation === "dedicated-additive-override"
|
||||
&& descriptor.identityRecovery === "exact-invalid-unexported-failed-predecessor-only"
|
||||
);
|
||||
if (
|
||||
!descriptorContractMatches
|
||||
|| descriptor.transitionId !== patchId
|
||||
|| descriptor.service !== "device-control-core"
|
||||
|| descriptor.tlsPurpose !== "clientAuth"
|
||||
|| descriptor.direction !== "core-initiated"
|
||||
|| descriptor.publicIngress !== "none-on-synology"
|
||||
|| descriptor.commandTransport !== "disabled"
|
||||
|| descriptor.gelios !== "untouched"
|
||||
) {
|
||||
throw new Error("device_edge_core_channel_bootstrap_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", "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");
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
#!/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 } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-edge-admission-gate-20260804-002",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-edge-ingress-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml",
|
||||
"services/device-edge-relay/Dockerfile",
|
||||
"services/device-edge-relay/src",
|
||||
"deployment/device-edge-admission-gate-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
const descriptor = await assertBoundary();
|
||||
if (descriptor.ingressIpv4Approval !== "approved-outside-dhcp-pool") {
|
||||
throw new Error("device_edge_ingress_ipv4_approval_pending");
|
||||
}
|
||||
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-edge-ingress-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-device-edge-${patchId}.tgz`);
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
await copySafe(
|
||||
resolve(sourceRoot, sourceRelative),
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-edge\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.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 digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-edge",
|
||||
transition: "reviewed-ipvlan-b2-relay-admission-gate",
|
||||
entries: files,
|
||||
services: ["device-edge-relay"],
|
||||
preservedRuntime: ["device-edge-backhaul", "tailnet", "Gelios"],
|
||||
ingress: {
|
||||
parent: descriptor.parentInterface,
|
||||
subnet: descriptor.lanSubnet,
|
||||
gateway: descriptor.lanGateway,
|
||||
ipv4: descriptor.ingressIpv4,
|
||||
ipv4Approval: descriptor.ingressIpv4Approval,
|
||||
tcp: 9921,
|
||||
hostPortPublication: "disabled",
|
||||
sourceAdmission: descriptor.sourceAdmission,
|
||||
maxTrackedSourceAddresses: descriptor.maxTrackedSourceAddresses,
|
||||
maxBytesPerDirection: descriptor.maxBytesPerDirection,
|
||||
lifecycle: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
},
|
||||
rollback: descriptor.rollback,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertBoundary() {
|
||||
const baseline = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-edge.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const ingress = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-edge.ingress.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-edge-admission-gate-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
|
||||
for (const fragment of [
|
||||
"DEVICE_EDGE_RELAY_HEALTH_HOST: 127.0.0.1",
|
||||
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "false"',
|
||||
"read_only: true",
|
||||
'user: "1000:1000"',
|
||||
"no-new-privileges:true",
|
||||
"cap_drop:",
|
||||
"- ALL",
|
||||
]) {
|
||||
if (!baseline.includes(fragment)) {
|
||||
throw new Error(`device_edge_baseline_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of ["ports:", "device-edge-control"]){
|
||||
if (baseline.includes(forbidden)) {
|
||||
throw new Error(`device_edge_baseline_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const fragment of [
|
||||
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"',
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul",
|
||||
'DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"',
|
||||
"DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only",
|
||||
'DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"',
|
||||
'DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"',
|
||||
"name: nodedc-device-edge-ingress",
|
||||
"driver: ipvlan",
|
||||
"parent: enp1s0f0",
|
||||
"ipvlan_mode: l2",
|
||||
"ipv4_address: 192.168.71.253",
|
||||
"gw_priority: 100",
|
||||
"subnet: 192.168.68.0/22",
|
||||
"gateway: 192.168.68.1",
|
||||
]) {
|
||||
if (!ingress.includes(fragment)) {
|
||||
throw new Error(`device_edge_ingress_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"ports:",
|
||||
"network_mode: host",
|
||||
"privileged: true",
|
||||
"DEVICE_EDGE_RELAY_COMMAND",
|
||||
"0.0.0.0:9921:9921",
|
||||
]) {
|
||||
if (ingress.includes(forbidden)) {
|
||||
throw new Error(`device_edge_ingress_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-edge.admission-gate.v1",
|
||||
mode: "single-nic-ipvlan-b2-relay-only",
|
||||
runtimeHost: "ndcmini12",
|
||||
component: "device-edge",
|
||||
selectedServices: ["device-edge-relay"],
|
||||
preservedServices: ["device-edge-backhaul", "tailnet"],
|
||||
composeProject: "nodedc-device-edge",
|
||||
composeFiles: [
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml",
|
||||
],
|
||||
parentInterface: "enp1s0f0",
|
||||
lanSubnet: "192.168.68.0/22",
|
||||
lanGateway: "192.168.68.1",
|
||||
ingressIpv4: "192.168.71.253",
|
||||
ingressIpv4Approval: "approved-outside-dhcp-pool",
|
||||
ingressNetwork: "nodedc-device-edge-ingress",
|
||||
deviceTcpListen: "192.168.71.253:9921",
|
||||
hostPortPublication: "disabled",
|
||||
healthPublication: "disabled",
|
||||
privateUpstream: "device-edge-backhaul:19921",
|
||||
sourceAdmission: "public-ipv4-only",
|
||||
maxTrackedSourceAddresses: 2048,
|
||||
maxBytesPerDirection: 262144,
|
||||
protocolInspection: "gateway-owned",
|
||||
identityTrust: "claimed-not-ownership-proof",
|
||||
discoveryLifecycle: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
amneziaHostFullTunnel: "preserved",
|
||||
routerNatFirewall: "separate-manual-gate",
|
||||
rollback: "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_edge_ingress_descriptor_mismatch");
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
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 (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
#!/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 { basename, dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const runtimeCache = resolve(
|
||||
process.env.NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR || "/tmp",
|
||||
);
|
||||
|
||||
const [phase, patchId, ...extra] = process.argv.slice(2);
|
||||
if (
|
||||
extra.length
|
||||
|| ![
|
||||
"foundation",
|
||||
"runtime-reconciliation",
|
||||
"backhaul",
|
||||
"relay",
|
||||
"core-channel",
|
||||
"tailscale-retirement",
|
||||
"tracker-ingress",
|
||||
"command-transport",
|
||||
].includes(phase)
|
||||
|| !/^[A-Za-z0-9._-]{1,96}$/.test(patchId || "")
|
||||
) {
|
||||
throw new Error(
|
||||
"usage: build-device-edge-vps-artifact.mjs <foundation|runtime-reconciliation|backhaul|relay|core-channel|tailscale-retirement|tracker-ingress|command-transport> <patch-id>",
|
||||
);
|
||||
}
|
||||
|
||||
const supersededTransportPhases = new Set(["backhaul", "relay"]);
|
||||
if (
|
||||
supersededTransportPhases.has(phase)
|
||||
&& process.env.NODEDC_ALLOW_SUPERSEDED_TRANSPORT !== "test-only"
|
||||
) {
|
||||
throw new Error("vps_initiated_transport_frozen:ADR-0001");
|
||||
}
|
||||
const acceptedSharedSourcePhases = new Set(["core-channel", "tracker-ingress"]);
|
||||
if (acceptedSharedSourcePhases.has(phase)) {
|
||||
throw new Error(`accepted_vps_phase_rebuild_frozen:${phase}:ADR-0001`);
|
||||
}
|
||||
|
||||
const nodeArchive = "node-v22.23.2-linux-x64.tar.xz";
|
||||
const tailscaleArchive = "tailscale_1.102.2_amd64.tgz";
|
||||
const runtimeDigests = new Map([
|
||||
[nodeArchive, "d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307"],
|
||||
[tailscaleArchive, "ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8"],
|
||||
]);
|
||||
|
||||
const entriesByPhase = {
|
||||
foundation: [
|
||||
"vps/config/00-nodedc-b2-vps.conf",
|
||||
"vps/config/nftables-foundation.conf",
|
||||
"vps/systemd/nodedc-b2-tailscaled.service",
|
||||
"deployment/device-edge-vps-foundation-v1.json",
|
||||
`vendor/${nodeArchive}`,
|
||||
`vendor/${tailscaleArchive}`,
|
||||
],
|
||||
"runtime-reconciliation": [
|
||||
"deployment/device-edge-vps-runtime-reconciliation-v1.json",
|
||||
],
|
||||
backhaul: [
|
||||
"vps/config/backhaul_ssh_config",
|
||||
"vps/systemd/nodedc-b2-backhaul.service",
|
||||
"deployment/device-edge-vps-backhaul-v1.json",
|
||||
],
|
||||
relay: [
|
||||
"vps/config/nftables-relay.conf",
|
||||
"vps/systemd/nodedc-b2-relay.service",
|
||||
"services/device-edge-relay/src",
|
||||
"deployment/device-edge-vps-relay-v1.json",
|
||||
],
|
||||
"core-channel": [
|
||||
"packages/device-protocol-contract/package.json",
|
||||
"packages/device-protocol-contract/src",
|
||||
"packages/device-edge-channel-contract/package.json",
|
||||
"packages/device-edge-channel-contract/src",
|
||||
"services/device-edge-channel/package.json",
|
||||
"services/device-edge-channel/src",
|
||||
"vps/config/nftables-core-channel.conf",
|
||||
"vps/systemd/nodedc-device-edge-channel.service",
|
||||
"deployment/device-edge-vps-core-channel-v1.json",
|
||||
],
|
||||
"tailscale-retirement": [
|
||||
"deployment/device-edge-vps-tailscale-retirement-v1.json",
|
||||
],
|
||||
"tracker-ingress": [
|
||||
"packages/device-adapter-runtime/package.json",
|
||||
"packages/device-adapter-runtime/src",
|
||||
"packages/device-adapter-catalog/package.json",
|
||||
"packages/device-adapter-catalog/src",
|
||||
"packages/arusnavi-b2-adapter/package.json",
|
||||
"packages/arusnavi-b2-adapter/src",
|
||||
"services/device-gateway/src/runtime.mjs",
|
||||
"vps/edge-process/device-edge-runtime.mjs",
|
||||
"vps/config/nftables-tracker-ingress.conf",
|
||||
"vps/systemd/nodedc-device-edge-runtime.service",
|
||||
"deployment/device-edge-vps-tracker-ingress-v1.json",
|
||||
],
|
||||
"command-transport": [
|
||||
"packages/device-edge-channel-contract/package.json",
|
||||
"packages/device-edge-channel-contract/src",
|
||||
"services/device-edge-channel/package.json",
|
||||
"services/device-edge-channel/src",
|
||||
"packages/device-adapter-runtime/package.json",
|
||||
"packages/device-adapter-runtime/src",
|
||||
"packages/device-adapter-catalog/package.json",
|
||||
"packages/device-adapter-catalog/src",
|
||||
"packages/arusnavi-b2-adapter/package.json",
|
||||
"packages/arusnavi-b2-adapter/src",
|
||||
"services/device-gateway/src/runtime.mjs",
|
||||
"vps/edge-process/device-edge-runtime.mjs",
|
||||
"deployment/device-edge-vps-command-transport-v1.json",
|
||||
],
|
||||
};
|
||||
const entries = entriesByPhase[phase];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
const stage = await mkdtemp(join(tmpdir(), `nodedc-device-edge-vps-${phase}-`));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-edge-vps-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith("vendor/")) {
|
||||
const name = basename(entry);
|
||||
const source = resolve(runtimeCache, name);
|
||||
const actual = createHash("sha256").update(await readFile(source)).digest("hex");
|
||||
if (actual !== runtimeDigests.get(name)) {
|
||||
throw new Error(`runtime_digest_mismatch:${name}:${actual}`);
|
||||
}
|
||||
await mkdir(dirname(join(payload, entry)), { recursive: true });
|
||||
await cp(source, join(payload, entry), { force: true });
|
||||
continue;
|
||||
}
|
||||
await copySafe(resolve(sourceRoot, entry), join(payload, entry));
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-edge-vps\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: 256 * 1024 * 1024 },
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const bytes = await readFile(target);
|
||||
const digest = createHash("sha256").update(bytes).digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
phase,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
size: bytes.length,
|
||||
component: "device-edge-vps",
|
||||
entries,
|
||||
publicIngress: phase === "relay"
|
||||
? "tcp/9921"
|
||||
: ["core-channel", "tailscale-retirement"].includes(phase)
|
||||
? "tcp/443-mtls-only"
|
||||
: ["tracker-ingress", "command-transport"].includes(phase)
|
||||
? "tcp/443-mtls+tcp/9921-telemetry"
|
||||
: "disabled",
|
||||
commandTransport: phase === "command-transport"
|
||||
? "typed-service-ping-v1"
|
||||
: "disabled",
|
||||
gelios: "untouched",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertBoundary() {
|
||||
const descriptorPath = resolve(
|
||||
sourceRoot,
|
||||
`deployment/device-edge-vps-${phase}-v1.json`,
|
||||
);
|
||||
const descriptor = JSON.parse(await readFile(descriptorPath, "utf8"));
|
||||
if (
|
||||
descriptor.component !== "device-edge-vps"
|
||||
|| descriptor.runtimeHost !== "koffyvngij"
|
||||
|| descriptor.commandTransport !== (phase === "command-transport"
|
||||
? "typed-service-ping-v1"
|
||||
: "disabled")
|
||||
|| !String(descriptor.gelios || "").startsWith("untouched")
|
||||
|| !String(descriptor.rollback || "").length
|
||||
) {
|
||||
throw new Error(`descriptor_boundary_mismatch:${phase}`);
|
||||
}
|
||||
|
||||
const selectedText = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => !entry.startsWith("vendor/") && !entry.endsWith("/src"))
|
||||
.map((entry) => readFile(resolve(sourceRoot, entry), "utf8")),
|
||||
);
|
||||
const combined = selectedText.join("\n");
|
||||
for (const forbidden of [
|
||||
"PRIVATE KEY",
|
||||
"AuthKey",
|
||||
"TS_AUTHKEY",
|
||||
"PasswordAuthentication yes",
|
||||
"commandTransport\": \"enabled",
|
||||
"device.dc.ru",
|
||||
]) {
|
||||
if (combined.includes(forbidden)) {
|
||||
throw new Error(`vps_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === "foundation") {
|
||||
for (const required of [
|
||||
"PermitRootLogin prohibit-password",
|
||||
"PasswordAuthentication no",
|
||||
"AllowTcpForwarding no",
|
||||
"policy drop",
|
||||
"tcp dport 22",
|
||||
"--tun=userspace-networking",
|
||||
"--socks5-server=127.0.0.1:1055",
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`foundation_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
if (combined.includes("tcp dport 9921")) {
|
||||
throw new Error("foundation_must_not_open_9921");
|
||||
}
|
||||
}
|
||||
if (phase === "runtime-reconciliation") {
|
||||
for (const required of [
|
||||
"recover-exact-runtime-executable-modes-after-failed-core-channel-publish",
|
||||
"restore-root-owned-executable-mode-0755-for-exact-known-binaries",
|
||||
'"publicCoreChannel": "disabled"',
|
||||
'"trackerIngress": "disabled"',
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`runtime_reconciliation_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase === "backhaul") {
|
||||
for (const required of [
|
||||
"\"runtimeUser\": \"nodedc-backhaul\"",
|
||||
"User=nodedc-backhaul",
|
||||
"HostName 100.109.216.21",
|
||||
"Port 2222",
|
||||
"StrictHostKeyChecking yes",
|
||||
"LocalForward 127.0.0.1:19921 127.0.0.1:9921",
|
||||
"ProxyCommand /usr/bin/nc -X 5 -x 127.0.0.1:1055",
|
||||
"MemoryMax=64M",
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`backhaul_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase === "relay") {
|
||||
for (const required of [
|
||||
"\"runtimeUser\": \"nodedc-relay\"",
|
||||
"User=nodedc-relay",
|
||||
"tcp dport 9921",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_HOST=127.0.0.1",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921",
|
||||
"DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only",
|
||||
"MemoryMax=192M",
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`relay_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase === "core-channel") {
|
||||
for (const required of [
|
||||
"\"runtimeUser\": \"nodedc-channel\"",
|
||||
"\"trackerIngress\": \"disabled\"",
|
||||
"User=nodedc-channel",
|
||||
"ExecStart=/opt/nodedc-b2-vps/runtime/node/bin/node /opt/nodedc-b2-vps/services/device-edge-channel/src/server.mjs",
|
||||
"MemoryDenyWriteExecute=no",
|
||||
"CapabilityBoundingSet=CAP_NET_BIND_SERVICE",
|
||||
"AmbientCapabilities=CAP_NET_BIND_SERVICE",
|
||||
"tcp dport 443",
|
||||
"MemoryMax=128M",
|
||||
"MemorySwapMax=0",
|
||||
"CPUQuota=50%",
|
||||
"TasksMax=64",
|
||||
"LimitNOFILE=1024",
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`core_channel_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"tcp dport 9921",
|
||||
"LocalForward",
|
||||
"tailscale-userspace",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM",
|
||||
"--jitless",
|
||||
]) {
|
||||
if (combined.includes(forbidden)) {
|
||||
throw new Error(`core_channel_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase === "tailscale-retirement") {
|
||||
for (const required of [
|
||||
'"predecessorPatch": "device-edge-vps-core-channel-20260812-010"',
|
||||
'"runtimeAction": "stop-disable-remove-userspace-tailscale-runtime-state-and-superseded-trust"',
|
||||
'"trackerIngress": "disabled"',
|
||||
'"externalRevocation": "delete-exact-nodedc-b2-vps-machine-in-tailnet-after-deploy-ok"',
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`tailscale_retirement_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"tcp dport 9921",
|
||||
"LocalForward",
|
||||
"commandTransport\": \"enabled",
|
||||
]) {
|
||||
if (combined.includes(forbidden)) {
|
||||
throw new Error(`tailscale_retirement_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase === "tracker-ingress") {
|
||||
for (const required of [
|
||||
'"predecessorPatch": "device-edge-vps-tailscale-retirement-20260812-011"',
|
||||
'"runtimeComposition": "single-process-core-channel-plus-universal-device-gateway"',
|
||||
'"trackerIngress": "enabled:allowlisted-adapters-only"',
|
||||
'"acknowledgementBoundary": "tracker-ack-only-after-core-durable-acceptance"',
|
||||
'"initialAdapterProfile": "arusnavi.b2.internal.v1"',
|
||||
"createDeviceGatewayRuntime",
|
||||
"DEVICE_ADAPTER_CATALOG.registry",
|
||||
"onDiscovery: (signal) => channel.submitDiscovery(signal)",
|
||||
"onMessage: (message) => channel.submitAdapterMessage(message)",
|
||||
"tcp dport 9921",
|
||||
"User=nodedc-channel",
|
||||
"MemoryMax=192M",
|
||||
"MemorySwapMax=0",
|
||||
"CPUQuota=75%",
|
||||
"TasksMax=128",
|
||||
"LimitNOFILE=1024",
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`tracker_ingress_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"LocalForward",
|
||||
"tailscale-userspace",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM",
|
||||
'commandTransport": "enabled',
|
||||
"device.dc.ru",
|
||||
]) {
|
||||
if (combined.includes(forbidden)) {
|
||||
throw new Error(`tracker_ingress_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase === "command-transport") {
|
||||
for (const required of [
|
||||
'"predecessorPatch": "device-edge-vps-tracker-ingress-20260812-012"',
|
||||
'"runtimeService": "nodedc-device-edge-channel.service"',
|
||||
'"runtimeComposition": "single-process-core-channel-plus-universal-device-gateway"',
|
||||
'"commandTransport": "typed-service-ping-v1"',
|
||||
'"commandCatalog": "allowlisted-adapter-typed-commands-only"',
|
||||
'"responseBoundary": "exact-adapter-parser-serv-ok-only"',
|
||||
"buildTypedCommand",
|
||||
"parseTypedCommandResponse",
|
||||
"submitCommandStatus",
|
||||
'"service.ping"',
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`command_transport_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"LocalForward",
|
||||
"tailscale-userspace",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM",
|
||||
"device.dc.ru",
|
||||
"PRIVATE KEY",
|
||||
"TS_AUTHKEY",
|
||||
]) {
|
||||
if (combined.includes(forbidden)) {
|
||||
throw new Error(`command_transport_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
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");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
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 (ignoredBasenames.has(entry.name) || entry.name.startsWith(".env")) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`source_symlink_rejected:${relative(sourceRoot, childSource)}`);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/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 = 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);
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-device-manager-control-plane-artifact.mjs [patch-id]");
|
||||
|
||||
const descriptorPath = patchId.startsWith("device-manager-release-v3-")
|
||||
? "deployment/device-manager-release-v3.json"
|
||||
: "deployment/device-manager-release-v1.json";
|
||||
|
||||
const isV3 = descriptorPath.endsWith("release-v3.json");
|
||||
const entries = isV3 ? [
|
||||
"docker-compose.device-manager.yml",
|
||||
"services/device-manager",
|
||||
descriptorPath,
|
||||
] : [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-manager.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/device-edge-channel-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway/package.json",
|
||||
"services/device-edge-relay/package.json",
|
||||
"services/device-manager",
|
||||
descriptorPath,
|
||||
];
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-control-plane-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
|
||||
try {
|
||||
const build = spawnSync("npm", ["run", "build", "--workspace", "@nodedc/device-manager"], {
|
||||
cwd: platformRoot,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (build.status !== 0) throw new Error(`device_manager_build_failed:${build.stderr || build.stdout}`);
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
if (entry === descriptorPath) {
|
||||
const descriptor = JSON.parse(await readFile(resolve(devicePlaneRoot, entry), "utf8"));
|
||||
if (descriptor.releaseId !== "__PATCH_ID__") {
|
||||
throw new Error("device_manager_release_template_id_mismatch");
|
||||
}
|
||||
descriptor.releaseId = patchId;
|
||||
const destination = join(payload, entry);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, `${JSON.stringify(descriptor, null, 2)}\n`, "utf8");
|
||||
continue;
|
||||
}
|
||||
if (entry === "services/device-manager") {
|
||||
const destination = join(payload, entry);
|
||||
await mkdir(destination, { recursive: true });
|
||||
await copySafe(resolve(devicePlaneRoot, "services/device-manager/Dockerfile"), join(destination, "Dockerfile"), devicePlaneRoot);
|
||||
await copySafe(resolve(managerRoot, "server"), join(destination, "server"), managerRoot);
|
||||
await copySafe(resolve(managerRoot, "dist"), join(destination, "dist"), managerRoot);
|
||||
continue;
|
||||
}
|
||||
await copySafe(resolve(devicePlaneRoot, entry), join(payload, entry), devicePlaneRoot);
|
||||
}
|
||||
if (!isV3) {
|
||||
await validateDockerCopySources(
|
||||
join(payload, "services/device-control-core/Dockerfile"),
|
||||
payload,
|
||||
);
|
||||
}
|
||||
await validateDockerCopySources(
|
||||
join(payload, "services/device-manager/Dockerfile"),
|
||||
join(payload, "services/device-manager"),
|
||||
);
|
||||
for (const modulePath of isV3 ? [] : [
|
||||
"services/device-control-core/src/sensitive-reference-management.mjs",
|
||||
"services/device-control-core/src/device-gateway-core-runtime.mjs",
|
||||
"packages/device-edge-channel-contract/src/index.mjs",
|
||||
]) {
|
||||
const coreImport = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
`import(${JSON.stringify(pathToFileURL(join(payload, modulePath)).href)})`,
|
||||
],
|
||||
{
|
||||
cwd: payload,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (coreImport.status !== 0) {
|
||||
throw new Error(
|
||||
`device_control_core_staged_module_import_failed:${modulePath}:${coreImport.stderr || coreImport.stdout}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const compose = await readFile(join(payload, "docker-compose.device-manager.yml"), "utf8");
|
||||
for (const required of [
|
||||
"device-manager:",
|
||||
"DEVICE_MANAGEMENT_API_ENABLED: \"true\"",
|
||||
"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",
|
||||
]) if (!compose.includes(required)) throw new Error(`device_manager_compose_contract_missing:${required}`);
|
||||
for (const forbidden of [
|
||||
"NODEDC_INTERNAL_ACCESS_TOKEN:",
|
||||
"NODEDC_PLATFORM_SERVICE_TOKEN:",
|
||||
"PRIVATE KEY",
|
||||
"DEVICE_EDGE_CHANNEL_",
|
||||
"device-edge-channel/",
|
||||
"nodedc-device-plane-egress",
|
||||
"0.0.0.0:18122",
|
||||
"0.0.0.0:9921:9921",
|
||||
"- \"9921:9921\"",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) throw new Error(`device_manager_compose_boundary_violation:${forbidden}`);
|
||||
}
|
||||
const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8"));
|
||||
const predecessor = descriptor.predecessor;
|
||||
const commonContractInvalid = (
|
||||
descriptor.releaseId !== patchId
|
||||
|| !["activate", "upgrade"].includes(descriptor.action)
|
||||
|| !predecessor
|
||||
|| !["reconciliation", "release"].includes(predecessor.kind)
|
||||
|| !/^[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"
|
||||
);
|
||||
if (commonContractInvalid) throw new Error("device_manager_activation_successor_contract_mismatch");
|
||||
if (descriptorPath.endsWith("release-v3.json")) {
|
||||
if (
|
||||
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v3"
|
||||
|| descriptor.commandTransport !== "typed-service-ping-v1"
|
||||
|| descriptor.commandCatalog !== "allowlisted-adapter-typed-commands-only"
|
||||
|| descriptor.credentialBoundary !== "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned"
|
||||
|| descriptor.controlCorePredecessor?.patchId !== "device-control-core-release-v2-20260812-025"
|
||||
|| descriptor.controlCorePredecessor?.artifactSha256 !== "c61b1f0de1bae23de0caa7289036865ea419ff5705611416f736ca929d1592db"
|
||||
|| descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023"
|
||||
|| descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|
||||
|| descriptor.edgeChannel !== "preserve-active-v4-core-initiated-pinned-mtls"
|
||||
|| descriptor.edgeChannelEgress !== "preserve-dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-443-only"
|
||||
|| descriptor.gelios !== "untouched-legacy-only"
|
||||
) throw new Error("device_manager_v3_typed_command_contract_mismatch");
|
||||
} else if (
|
||||
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v1"
|
||||
|| descriptor.commandTransport !== "disabled"
|
||||
|| descriptor.gelios !== "untouched"
|
||||
) {
|
||||
throw new Error("device_manager_v1_contract_mismatch");
|
||||
}
|
||||
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, "utf8");
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
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: isV3 ? ["device-manager"] : ["device-control-core", "device-manager"],
|
||||
preserved: ["device-postgres", "device-gateway", "device-backhaul-target", "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");
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-manager-control-plane-reconciliation-20260811-002",
|
||||
...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-reconciliation-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const entry = "deployment/device-manager-control-plane-reconciliation-v1.json";
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-reconciliation-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = resolve(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
try {
|
||||
const descriptor = JSON.parse(await readFile(resolve(sourceRoot, entry), "utf8"));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.device-manager-control-plane-reconciliation.v1",
|
||||
mode: "failed-control-plane-baseline-adoption",
|
||||
failedPatchId: "device-manager-control-plane-20260810-001",
|
||||
failedArtifactSha256:
|
||||
"50e275c1085286bcb3bb2b273aefc8bbba70f446ca2c7bd464dc745710a291a6",
|
||||
backupId:
|
||||
"device-plane-device-manager-control-plane-20260810-001-20260811-000321",
|
||||
sourceAction: "publish-reconciliation-marker-only",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
preservedServices: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target",
|
||||
],
|
||||
absentService: "device-manager",
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
publicIngress: "disabled",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
rollback: "marker-only-runtime-unchanged",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_manager_reconciliation_descriptor_mismatch");
|
||||
}
|
||||
|
||||
await mkdir(dirname(join(payload, entry)), { recursive: true });
|
||||
await cp(resolve(sourceRoot, entry), join(payload, entry), { force: true });
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${entry}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{ encoding: "utf8", maxBuffer: 16 * 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: [entry],
|
||||
build: [],
|
||||
services: [],
|
||||
transition: "failed-control-plane-baseline-adoption",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
sourceAction: "publish-reconciliation-marker-only",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-manager-control-plane-v2-reconciliation-20260811-004",
|
||||
...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-v2-reconciliation-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const entry = "deployment/device-manager-control-plane-v2-reconciliation-v1.json";
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-v2-reconciliation-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = resolve(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
|
||||
|
||||
try {
|
||||
const descriptor = JSON.parse(await readFile(resolve(sourceRoot, entry), "utf8"));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.device-manager-control-plane-v2-reconciliation.v1",
|
||||
mode: "failed-v2-control-plane-baseline-adoption",
|
||||
failedPatchId: "device-manager-control-plane-20260811-003",
|
||||
failedArtifactSha256:
|
||||
"ba29618ffbfed55448768794f28b18dda439ddb39a1d2a4f1dece19de7f29990",
|
||||
backupId:
|
||||
"device-plane-device-manager-control-plane-20260811-003-20260811-012505",
|
||||
failureClass: "deterministic-runtime-module-resolution",
|
||||
missingModule: "/packages/external-provider-contract/src/credential-reference.mjs",
|
||||
correctiveAction: "runtime-local-contract-adapter+staged-module-import-gate",
|
||||
sourceAction: "publish-reconciliation-marker-only",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
preservedServices: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target",
|
||||
],
|
||||
absentService: "device-manager",
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
publicIngress: "disabled",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
rollback: "marker-only-runtime-unchanged",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_manager_v2_reconciliation_descriptor_mismatch");
|
||||
}
|
||||
|
||||
await mkdir(dirname(join(payload, entry)), { recursive: true });
|
||||
await cp(resolve(sourceRoot, entry), join(payload, entry), { force: true });
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${entry}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{ encoding: "utf8", maxBuffer: 16 * 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: [entry],
|
||||
build: [],
|
||||
services: [],
|
||||
transition: "failed-v2-control-plane-baseline-adoption",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
sourceAction: "publish-reconciliation-marker-only",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/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 } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const failedFoundationCompose = resolve(
|
||||
scriptDir,
|
||||
"fixtures/device-plane-foundation-internal-only-v1.yml",
|
||||
);
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "device-plane-foundation-20260725-001", ...extra] =
|
||||
process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error("usage: build-device-plane-artifact.mjs [patch-id]");
|
||||
}
|
||||
|
||||
const files = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
];
|
||||
const ignoredBasenames = new Set([
|
||||
".DS_Store",
|
||||
".git",
|
||||
"node_modules",
|
||||
]);
|
||||
const ignoredDirectoryNames = new Set(["test"]);
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-plane-artifact-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
|
||||
|
||||
await assertSourceBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
const source = sourceRelative === "docker-compose.device-plane.yml"
|
||||
? failedFoundationCompose
|
||||
: resolve(sourceRoot, sourceRelative);
|
||||
await copySafe(
|
||||
source,
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.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 digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
entries: files,
|
||||
services: ["device-control-core", "device-gateway"],
|
||||
preserved: [
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
excluded: [
|
||||
".env*",
|
||||
"node_modules",
|
||||
"**/test",
|
||||
"docs",
|
||||
"runtime",
|
||||
"secrets",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertSourceBoundary() {
|
||||
const composeSource = failedFoundationCompose;
|
||||
const compose = await readFile(
|
||||
composeSource,
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "false"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "false"',
|
||||
'"127.0.0.1:18120:18120"',
|
||||
'"127.0.0.1:18121:18121"',
|
||||
"source: /volume1/docker/nodedc-device-plane/secrets/postgres-password",
|
||||
"create_host_path: false",
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
"pull_policy: never",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(`device_plane_compose_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"9921:9921",
|
||||
"0.0.0.0:9921",
|
||||
"DEVICE_DISCOVERY_INGEST_ENABLED: \"true\"",
|
||||
"DEVICE_GATEWAY_LISTEN_ENABLED: \"true\"",
|
||||
"POSTGRES_PASSWORD:",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(`device_plane_compose_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, source)}`,
|
||||
);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
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 (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| (entry.isDirectory() && ignoredDirectoryNames.has(entry.name))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/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 } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const composeFixtureRelative =
|
||||
"infra/deploy-runner/fixtures/device-plane-b2-discovery-ingress-v1.yml";
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-b2-discovery-loopback-20260726-002",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-b2-discovery-ingress-artifact.mjs "
|
||||
+ "[patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
"services/device-edge-relay/package.json",
|
||||
"deployment/device-plane-b2-discovery-ingress-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
const ignoredDirectoryNames = new Set(["test"]);
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-b2-discovery-ingress-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
const source = sourceRelative === "docker-compose.device-plane.yml"
|
||||
? resolve(sourceRoot, composeFixtureRelative)
|
||||
: resolve(sourceRoot, sourceRelative);
|
||||
await copySafe(
|
||||
source,
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(stage, "files.txt"),
|
||||
`${files.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 digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "verified-b2-loopback-discovery-only",
|
||||
entries: files,
|
||||
services: ["device-control-core", "device-gateway"],
|
||||
preservedRuntime: [
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
ingress: {
|
||||
transport: "tcp",
|
||||
published: "127.0.0.1:9921:9921",
|
||||
mode: "loopback-discovery-only",
|
||||
framing: "verified-read-only",
|
||||
lifecycle: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
},
|
||||
rollback: "restore-source-and-predecessor-stateless-runtime",
|
||||
excluded: [
|
||||
".env*",
|
||||
"node_modules",
|
||||
"**/test",
|
||||
"docs",
|
||||
"runtime",
|
||||
"secrets",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertBoundary() {
|
||||
const compose = await readFile(
|
||||
resolve(sourceRoot, composeFixtureRelative),
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
|
||||
'DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"',
|
||||
"DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token",
|
||||
'"127.0.0.1:18120:18120"',
|
||||
'"127.0.0.1:18121:18121"',
|
||||
'"127.0.0.1:9921:9921"',
|
||||
"name: nodedc-device-plane-private",
|
||||
"internal: true",
|
||||
"name: nodedc-device-plane-control",
|
||||
"internal: false",
|
||||
'com.docker.network.bridge.enable_ip_masquerade: "false"',
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
"pull_policy: never",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(
|
||||
`device_plane_b2_ingress_boundary_missing:${fragment}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"POSTGRES_PASSWORD:",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN:",
|
||||
"DEVICE_IDENTIFIER_PEPPER:",
|
||||
"DEVICE_GATEWAY_COMMAND",
|
||||
"9921:9921/udp",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(
|
||||
`device_plane_b2_ingress_boundary_violation:${forbidden}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-plane-b2-discovery-ingress-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.b2-discovery-ingress.v1",
|
||||
mode: "verified-b2-loopback-discovery-only",
|
||||
predecessorPatchId:
|
||||
"device-plane-foundation-network-publication-20260725-003",
|
||||
predecessorArtifactSha256:
|
||||
"6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf",
|
||||
sourceAction: "publish-verified-b2-loopback-discovery-source",
|
||||
runtimeAction: "build-and-recreate-stateless-services",
|
||||
selectedServices: ["device-control-core", "device-gateway"],
|
||||
preservedServices: ["device-postgres"],
|
||||
privateNetwork: "nodedc-device-plane-private",
|
||||
controlNetwork: "nodedc-device-plane-control",
|
||||
publishedPorts: [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
"127.0.0.1:9921:9921/tcp",
|
||||
],
|
||||
protocolProfile: "arusnavi.b2.internal.v1",
|
||||
framingSpecification:
|
||||
"arusnavi.internal.protocol-sheet.gid-12.v1",
|
||||
identityTrust: "claimed-not-ownership-proof",
|
||||
discoveryLifecycle: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
rollback: "restore-source-and-predecessor-stateless-runtime",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_b2_ingress_descriptor_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, source)}`,
|
||||
);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
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 (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| (
|
||||
entry.isDirectory()
|
||||
&& ignoredDirectoryNames.has(entry.name)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const descriptorRelative =
|
||||
"deployment/device-plane-b2-discovery-loopback-recovery-v1.json";
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-b2-discovery-loopback-recovery-20260802-004",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: "
|
||||
+ "build-device-plane-b2-discovery-loopback-recovery-artifact.mjs "
|
||||
+ "[patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [descriptorRelative];
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-b2-loopback-recovery-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertRecoveryDescriptor();
|
||||
|
||||
try {
|
||||
const source = resolve(sourceRoot, descriptorRelative);
|
||||
const sourceStat = await lstat(source);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||
throw new Error("device_plane_b2_recovery_descriptor_unsafe");
|
||||
}
|
||||
await mkdir(dirname(join(payload, descriptorRelative)), {
|
||||
recursive: true,
|
||||
});
|
||||
await cp(source, join(payload, descriptorRelative), {
|
||||
force: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(stage, "files.txt"),
|
||||
`${files.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 digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "failed-b2-loopback-build-reconciliation",
|
||||
entries: files,
|
||||
build: [],
|
||||
services: [],
|
||||
preservedRuntime: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
sourceAction: "publish-reconciliation-marker-only",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
ingress: "disabled:127.0.0.1:9921/tcp:closed",
|
||||
rollback: "marker-only-runtime-unchanged",
|
||||
excluded: [
|
||||
"application-source",
|
||||
"compose",
|
||||
"Dockerfile",
|
||||
"secrets",
|
||||
"runtime",
|
||||
"database",
|
||||
"Gelios",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertRecoveryDescriptor() {
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(sourceRoot, descriptorRelative),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.b2-discovery-loopback-recovery.v1",
|
||||
mode: "failed-b2-loopback-build-reconciliation",
|
||||
failedPatchId: "device-plane-b2-discovery-loopback-20260801-003",
|
||||
failedArtifactSha256:
|
||||
"7273c5bf67fe6bc1f1da66ad726009240d39ee3aee58201b96c23d6f707a3d84",
|
||||
failedBackupId:
|
||||
"device-plane-device-plane-b2-discovery-loopback-20260801-003-20260802-154311",
|
||||
sourceAction: "publish-reconciliation-marker-only",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
preservedServices: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
],
|
||||
expectedLoopbackPorts: [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
],
|
||||
closedPort: "127.0.0.1:9921/tcp",
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
rollback: "marker-only-runtime-unchanged",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_b2_recovery_descriptor_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/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 } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-backhaul-target-tailnet-serve-20260804-002",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-backhaul-target-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
"docker-compose.device-plane.backhaul-target.yml",
|
||||
"services/device-backhaul-target",
|
||||
"deployment/device-plane-backhaul-target-tailnet-serve-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-backhaul-target-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
await copySafe(
|
||||
resolve(sourceRoot, sourceRelative),
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.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 digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "failed-backhaul-target-to-loopback-tailnet-serve",
|
||||
entries: files,
|
||||
services: ["device-backhaul-target"],
|
||||
preservedRuntime: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
ingress: {
|
||||
loopbackListen: "127.0.0.1:2222/tcp",
|
||||
tailnetListen: "100.109.216.21:2222/tcp",
|
||||
transport: "tailscale-serve-private-ssh",
|
||||
serveTarget: "tcp://127.0.0.1:2222",
|
||||
permittedTarget: "127.0.0.1:9921",
|
||||
dockerPortPublication: "disabled",
|
||||
routerNatFirewall: "unchanged",
|
||||
edgePublicIngress: "disabled",
|
||||
funnel: "disabled",
|
||||
commandTransport: "disabled",
|
||||
},
|
||||
runtimeTrust: "runner-managed-not-in-artifact",
|
||||
rollback: "remove-tailnet-serve-target-and-restore-source",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertBoundary() {
|
||||
const compose = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-plane.backhaul-target.yml"),
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
"device-backhaul-target:",
|
||||
"image: nodedc/device-backhaul-target:local",
|
||||
"network_mode: host",
|
||||
'"127.0.0.1", "2222"',
|
||||
"/secrets/backhaul-target/ssh_host_ed25519_key",
|
||||
"/secrets/backhaul-target/authorized_keys",
|
||||
"no-new-privileges:true",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(`device_plane_backhaul_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"PasswordAuthentication yes",
|
||||
"0.0.0.0:2222",
|
||||
"9921:9921/udp",
|
||||
"DEVICE_GATEWAY_COMMAND",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(`device_plane_backhaul_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sshd = await readFile(
|
||||
resolve(sourceRoot, "services/device-backhaul-target/sshd_config"),
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
"ListenAddress 127.0.0.1",
|
||||
"PasswordAuthentication no",
|
||||
"KbdInteractiveAuthentication no",
|
||||
"AllowTcpForwarding local",
|
||||
"PermitOpen 127.0.0.1:9921",
|
||||
"GatewayPorts no",
|
||||
"PermitTunnel no",
|
||||
"AllowAgentForwarding no",
|
||||
"PermitTTY no",
|
||||
"ForceCommand /bin/false",
|
||||
]) {
|
||||
if (!sshd.includes(fragment)) {
|
||||
throw new Error(`device_plane_backhaul_sshd_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-plane-backhaul-target-tailnet-serve-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.backhaul-target-tailnet-serve.v1",
|
||||
mode: "failed-backhaul-target-to-loopback-tailnet-serve",
|
||||
failedPatchId: "device-plane-backhaul-target-20260803-001",
|
||||
failedArtifactSha256:
|
||||
"ed0bda4110a756c32be68990e2e0f647409d5a77eec7e26c18502bafbdc1bb76",
|
||||
failedBackupId:
|
||||
"device-plane-device-plane-backhaul-target-20260803-001-20260804-035519",
|
||||
predecessorPatchId:
|
||||
"device-plane-b2-discovery-loopback-20260803-006",
|
||||
predecessorArtifactSha256:
|
||||
"25f9e9e55e283e9b7bb5e128ff14a244f848b1c063acca9724a23206131c9adf",
|
||||
sourceAction: "publish-loopback-backhaul-target-source",
|
||||
runtimeAction: "build-create-target-and-register-private-tailnet-serve",
|
||||
composeOverlay: "docker-compose.device-plane.backhaul-target.yml",
|
||||
selectedServices: ["device-backhaul-target"],
|
||||
preservedServices: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
],
|
||||
loopbackListenAddress: "127.0.0.1",
|
||||
listenPort: 2222,
|
||||
tailnetAddress: "100.109.216.21",
|
||||
tailnetExposure: "tailscale-serve-private",
|
||||
tailscaleServeTarget: "tcp://127.0.0.1:2222",
|
||||
permittedTarget: "127.0.0.1:9921",
|
||||
networkMode: "host",
|
||||
dockerPortPublication: "disabled",
|
||||
routerNatFirewall: "unchanged",
|
||||
edgePublicIngress: "disabled",
|
||||
funnel: "disabled",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
runtimeTrust: "runner-managed",
|
||||
rollback: "remove-tailnet-serve-target-and-restore-source",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_backhaul_descriptor_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
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 (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| entry.name.endsWith("~")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await copySafe(join(source, entry.name), join(destination, entry.name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const descriptorRelative =
|
||||
"deployment/device-plane-backhaul-vps-enrollment-v1.json";
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-backhaul-vps-enrollment-20260806-001",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-backhaul-vps-enrollment-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.NODEDC_ALLOW_SUPERSEDED_TRANSPORT !== "test-only") {
|
||||
throw new Error("vps_initiated_transport_frozen:ADR-0001");
|
||||
}
|
||||
|
||||
const files = [descriptorRelative];
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-vps-enrollment-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertDescriptor();
|
||||
|
||||
try {
|
||||
const source = resolve(sourceRoot, descriptorRelative);
|
||||
const sourceStat = await lstat(source);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||
throw new Error("device_plane_vps_enrollment_descriptor_unsafe");
|
||||
}
|
||||
await mkdir(dirname(join(payload, descriptorRelative)), {
|
||||
recursive: true,
|
||||
});
|
||||
await cp(source, join(payload, descriptorRelative), {
|
||||
force: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const bytes = await readFile(target);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256,
|
||||
component: "device-plane",
|
||||
transition: "rotate-backhaul-client-mini-to-vps",
|
||||
entries: files,
|
||||
build: [],
|
||||
services: ["device-backhaul-target"],
|
||||
preservedRuntime: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Tailscale Serve",
|
||||
"Gelios",
|
||||
],
|
||||
publicIngress: "disabled",
|
||||
commandTransport: "disabled",
|
||||
runtimeKeyMaterial: "external-enrollment-only",
|
||||
rollback: "restore-previous-authorized-key-and-recreate-target",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertDescriptor() {
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(sourceRoot, descriptorRelative),
|
||||
"utf8",
|
||||
));
|
||||
if (
|
||||
descriptor.schemaVersion
|
||||
!== "nodedc.device-plane.backhaul-vps-enrollment.v1"
|
||||
|| descriptor.mode !== "rotate-backhaul-client-mini-to-vps"
|
||||
|| descriptor.predecessorPatchId
|
||||
!== "device-plane-backhaul-target-tailnet-serve-20260804-002"
|
||||
|| descriptor.predecessorArtifactSha256
|
||||
!== "219408705dd4d80a962ed00eeb53a69df0b9ab6458443734d5c9cd1d1f795eba"
|
||||
|| descriptor.nextKeyFingerprint
|
||||
!== "SHA256:HHTiDYiCRxSiKjBLCip6JMSzGfLGrDz5g8SIkosJcVw"
|
||||
|| descriptor.commandTransport !== "disabled"
|
||||
|| descriptor.gelios !== "untouched"
|
||||
|| descriptor.edgePublicIngress !== "disabled"
|
||||
) {
|
||||
throw new Error("device_plane_vps_enrollment_descriptor_mismatch");
|
||||
}
|
||||
const text = JSON.stringify(descriptor);
|
||||
for (const forbidden of [
|
||||
"PRIVATE KEY",
|
||||
"authorized_keys",
|
||||
"TS_AUTHKEY",
|
||||
"password",
|
||||
]) {
|
||||
if (text.includes(forbidden)) {
|
||||
throw new Error(`device_plane_vps_enrollment_boundary:${forbidden}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/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 } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const networkPublicationCompose = resolve(
|
||||
scriptDir,
|
||||
"fixtures/device-plane-foundation-network-publication-v1.yml",
|
||||
);
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-foundation-network-publication-20260725-003",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-foundation-network-publication-artifact.mjs "
|
||||
+ "[patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
"deployment/device-plane-foundation-network-publication-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
const ignoredDirectoryNames = new Set(["test"]);
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-network-publication-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
const source = sourceRelative === "docker-compose.device-plane.yml"
|
||||
? networkPublicationCompose
|
||||
: resolve(sourceRoot, sourceRelative);
|
||||
await copySafe(
|
||||
source,
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(stage, "files.txt"),
|
||||
`${files.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 digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "failed-foundation-network-publication-correction",
|
||||
entries: files,
|
||||
build: [],
|
||||
services: ["device-control-core", "device-gateway"],
|
||||
preservedRuntime: [
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
networkChange: {
|
||||
private: "preserved:internal",
|
||||
control: "create:non-internal:no-masquerade",
|
||||
published: [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
],
|
||||
disabled: ["9921", "public-ingress", "command-transport"],
|
||||
},
|
||||
rollback:
|
||||
"restore-partial-source-and-internal-only-stateless-runtime",
|
||||
excluded: [
|
||||
".env*",
|
||||
"node_modules",
|
||||
"**/test",
|
||||
"docs",
|
||||
"runtime",
|
||||
"secrets",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertBoundary() {
|
||||
const compose = await readFile(
|
||||
networkPublicationCompose,
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "false"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "false"',
|
||||
'"127.0.0.1:18120:18120"',
|
||||
'"127.0.0.1:18121:18121"',
|
||||
"name: nodedc-device-plane-private",
|
||||
"internal: true",
|
||||
"name: nodedc-device-plane-control",
|
||||
"internal: false",
|
||||
'com.docker.network.bridge.enable_ip_masquerade: "false"',
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
"pull_policy: never",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(
|
||||
`device_plane_network_publication_boundary_missing:${fragment}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"9921:9921",
|
||||
"0.0.0.0:9921",
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
|
||||
"POSTGRES_PASSWORD:",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(
|
||||
`device_plane_network_publication_boundary_violation:${forbidden}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-plane-foundation-network-publication-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion:
|
||||
"nodedc.device-plane.foundation-network-publication.v1",
|
||||
mode: "failed-foundation-network-publication-correction",
|
||||
failedRecoveryPatchId:
|
||||
"device-plane-foundation-recovery-20260725-002",
|
||||
failedRecoveryArtifactSha256:
|
||||
"9183cc385142584bfd12510bb0a3e6b833b2fd26607436f2486a564c628ea1bf",
|
||||
failedRecoveryBackupId:
|
||||
"device-plane-device-plane-foundation-recovery-20260725-002-20260725-232447",
|
||||
sourceAction: "publish-network-corrected-foundation-source",
|
||||
runtimeAction: "recreate-stateless-services-no-build",
|
||||
selectedServices: ["device-control-core", "device-gateway"],
|
||||
preservedServices: ["device-postgres"],
|
||||
privateNetwork: "nodedc-device-plane-private",
|
||||
controlNetwork: "nodedc-device-plane-control",
|
||||
publishedLoopbackPorts: [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
],
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
rollback:
|
||||
"restore-partial-source-and-internal-only-stateless-runtime",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
"device_plane_network_publication_descriptor_mismatch",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, source)}`,
|
||||
);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
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 (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| (
|
||||
entry.isDirectory()
|
||||
&& ignoredDirectoryNames.has(entry.name)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/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 } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const predecessorCompose = resolve(
|
||||
scriptDir,
|
||||
"fixtures/device-plane-foundation-internal-only-v1.yml",
|
||||
);
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-foundation-recovery-20260725-002",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-foundation-recovery-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
"deployment/device-plane-foundation-recovery-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([
|
||||
".DS_Store",
|
||||
".git",
|
||||
"node_modules",
|
||||
]);
|
||||
const ignoredDirectoryNames = new Set(["test"]);
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-foundation-recovery-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertRecoveryBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
const source = sourceRelative === "docker-compose.device-plane.yml"
|
||||
? predecessorCompose
|
||||
: resolve(sourceRoot, sourceRelative);
|
||||
await copySafe(
|
||||
source,
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(stage, "files.txt"),
|
||||
`${files.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 digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "failed-foundation-live-runtime-adoption",
|
||||
entries: files,
|
||||
build: [],
|
||||
services: [],
|
||||
preservedRuntime: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
],
|
||||
sourceAction: "publish-exact-failed-artifact-source",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
excluded: [
|
||||
".env*",
|
||||
"node_modules",
|
||||
"**/test",
|
||||
"docs",
|
||||
"runtime",
|
||||
"secrets",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertRecoveryBoundary() {
|
||||
const compose = await readFile(
|
||||
predecessorCompose,
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "false"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "false"',
|
||||
'"127.0.0.1:18120:18120"',
|
||||
'"127.0.0.1:18121:18121"',
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(
|
||||
`device_plane_recovery_compose_boundary_missing:${fragment}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"9921:9921",
|
||||
"0.0.0.0:9921",
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
|
||||
"POSTGRES_PASSWORD:",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(
|
||||
`device_plane_recovery_compose_boundary_violation:${forbidden}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-plane-foundation-recovery-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.foundation-recovery.v1",
|
||||
mode: "failed-foundation-live-runtime-adoption",
|
||||
failedPatchId: "device-plane-foundation-20260725-001",
|
||||
failedArtifactSha256:
|
||||
"23d428de547854ad8b1a026671e2f850386ab0be98bde80f016f1e9db631ee24",
|
||||
backupId:
|
||||
"device-plane-device-plane-foundation-20260725-001-20260725-223441",
|
||||
sourceAction: "publish-exact-failed-artifact-source",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
preservedServices: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
],
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
rollback: "source-only-runtime-unchanged",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_recovery_descriptor_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, source)}`,
|
||||
);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
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 (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| (
|
||||
entry.isDirectory()
|
||||
&& ignoredDirectoryNames.has(entry.name)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = platformRoot;
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "device-plane-postgres-bootstrap-20260725-001", ...extra] =
|
||||
process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-postgres-bootstrap-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
"docker-compose.device-plane.yml",
|
||||
"deployment/device-postgres-bootstrap-v1.json",
|
||||
];
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-postgres-bootstrap-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertSourceBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const relativePath of files) {
|
||||
const source = resolve(sourceRoot, relativePath);
|
||||
const sourceStat = await lstat(source);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||
throw new Error(`bootstrap_source_file_required:${relativePath}`);
|
||||
}
|
||||
const destination = join(payload, relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
force: true,
|
||||
verbatimSymlinks: false,
|
||||
});
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.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,
|
||||
artifact: target,
|
||||
sha256,
|
||||
component: "device-plane",
|
||||
entries: files,
|
||||
services: ["device-postgres"],
|
||||
mode: "create-if-absent",
|
||||
rollbackVolumePolicy: "preserve",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertSourceBoundary() {
|
||||
const descriptor = JSON.parse(
|
||||
await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-postgres-bootstrap-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.postgres-bootstrap.v1",
|
||||
service: "device-postgres",
|
||||
volume: "nodedc-device-plane-postgres-data",
|
||||
mode: "create-if-absent",
|
||||
ordinaryApplicationSelection: "forbidden",
|
||||
rollbackVolumePolicy: "preserve",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_postgres_bootstrap_descriptor_mismatch");
|
||||
}
|
||||
|
||||
const compose = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-plane.yml"),
|
||||
"utf8",
|
||||
);
|
||||
for (const required of [
|
||||
"device-postgres:",
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
"POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password",
|
||||
"create_host_path: false",
|
||||
]) {
|
||||
if (!compose.includes(required)) {
|
||||
throw new Error(`device_plane_postgres_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
const postgresStart = compose.indexOf(" device-postgres:");
|
||||
const postgresEnd = compose.indexOf("\n device-control-core:");
|
||||
if (
|
||||
postgresStart < 0
|
||||
|| postgresEnd <= postgresStart
|
||||
|| compose.slice(postgresStart, postgresEnd).includes("\n ports:")
|
||||
) {
|
||||
throw new Error("device_plane_postgres_host_port_forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
services:
|
||||
device-postgres:
|
||||
image: postgres:16-alpine
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: device_plane
|
||||
POSTGRES_USER: device_plane
|
||||
POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
volumes:
|
||||
- type: volume
|
||||
source: device-plane-postgres-data
|
||||
target: /var/lib/postgresql/data
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
networks:
|
||||
- device-plane-private
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-control-core:
|
||||
image: nodedc/device-control-core:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "18120"
|
||||
DEVICE_DATABASE_HOST: device-postgres
|
||||
DEVICE_DATABASE_PORT: "5432"
|
||||
DEVICE_DATABASE_NAME: device_plane
|
||||
DEVICE_DATABASE_USER: device_plane
|
||||
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
DEVICE_DATABASE_POOL_SIZE: "10"
|
||||
DEVICE_DISCOVERY_INGEST_ENABLED: "true"
|
||||
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
|
||||
DEVICE_IDENTIFIER_PEPPER_FILE: /run/nodedc-secrets/identifier-pepper
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token
|
||||
target: /run/nodedc-secrets/gateway-core-token
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/identifier-pepper
|
||||
target: /run/nodedc-secrets/identifier-pepper
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18120:18120"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
depends_on:
|
||||
device-postgres:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-gateway:
|
||||
image: nodedc/device-gateway:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
|
||||
DEVICE_GATEWAY_HEALTH_PORT: "18121"
|
||||
DEVICE_GATEWAY_LISTEN_ENABLED: "true"
|
||||
DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"
|
||||
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
|
||||
DEVICE_GATEWAY_TCP_PORT: "9921"
|
||||
DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120
|
||||
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
|
||||
DEVICE_GATEWAY_CORE_TIMEOUT_MS: "5000"
|
||||
DEVICE_GATEWAY_MAX_BUFFERED_BYTES: "65536"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS: "100"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS: "10"
|
||||
DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
|
||||
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token
|
||||
target: /run/nodedc-secrets/gateway-core-token
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18121:18121"
|
||||
- "127.0.0.1:9921:9921"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
depends_on:
|
||||
device-control-core:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-plane-private:
|
||||
name: nodedc-device-plane-private
|
||||
internal: true
|
||||
device-plane-control:
|
||||
name: nodedc-device-plane-control
|
||||
driver: bridge
|
||||
internal: false
|
||||
driver_opts:
|
||||
com.docker.network.bridge.enable_ip_masquerade: "false"
|
||||
|
||||
volumes:
|
||||
device-plane-postgres-data:
|
||||
name: nodedc-device-plane-postgres-data
|
||||
@@ -0,0 +1,118 @@
|
||||
services:
|
||||
device-postgres:
|
||||
image: postgres:16-alpine
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: device_plane
|
||||
POSTGRES_USER: device_plane
|
||||
POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
volumes:
|
||||
- type: volume
|
||||
source: device-plane-postgres-data
|
||||
target: /var/lib/postgresql/data
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
networks:
|
||||
- device-plane-private
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-control-core:
|
||||
image: nodedc/device-control-core:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "18120"
|
||||
DEVICE_DATABASE_HOST: device-postgres
|
||||
DEVICE_DATABASE_PORT: "5432"
|
||||
DEVICE_DATABASE_NAME: device_plane
|
||||
DEVICE_DATABASE_USER: device_plane
|
||||
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
DEVICE_DATABASE_POOL_SIZE: "10"
|
||||
DEVICE_DISCOVERY_INGEST_ENABLED: "false"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18120:18120"
|
||||
networks:
|
||||
- device-plane-private
|
||||
depends_on:
|
||||
device-postgres:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-gateway:
|
||||
image: nodedc/device-gateway:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
|
||||
DEVICE_GATEWAY_HEALTH_PORT: "18121"
|
||||
DEVICE_GATEWAY_LISTEN_ENABLED: "false"
|
||||
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
|
||||
DEVICE_GATEWAY_TCP_PORT: "9921"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS: "100"
|
||||
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
|
||||
ports:
|
||||
- "127.0.0.1:18121:18121"
|
||||
networks:
|
||||
- device-plane-private
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-plane-private:
|
||||
name: nodedc-device-plane-private
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
device-plane-postgres-data:
|
||||
name: nodedc-device-plane-postgres-data
|
||||
@@ -0,0 +1,126 @@
|
||||
services:
|
||||
device-postgres:
|
||||
image: postgres:16-alpine
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: device_plane
|
||||
POSTGRES_USER: device_plane
|
||||
POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
volumes:
|
||||
- type: volume
|
||||
source: device-plane-postgres-data
|
||||
target: /var/lib/postgresql/data
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
networks:
|
||||
- device-plane-private
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-control-core:
|
||||
image: nodedc/device-control-core:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "18120"
|
||||
DEVICE_DATABASE_HOST: device-postgres
|
||||
DEVICE_DATABASE_PORT: "5432"
|
||||
DEVICE_DATABASE_NAME: device_plane
|
||||
DEVICE_DATABASE_USER: device_plane
|
||||
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
DEVICE_DATABASE_POOL_SIZE: "10"
|
||||
DEVICE_DISCOVERY_INGEST_ENABLED: "false"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18120:18120"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
depends_on:
|
||||
device-postgres:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-gateway:
|
||||
image: nodedc/device-gateway:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
|
||||
DEVICE_GATEWAY_HEALTH_PORT: "18121"
|
||||
DEVICE_GATEWAY_LISTEN_ENABLED: "false"
|
||||
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
|
||||
DEVICE_GATEWAY_TCP_PORT: "9921"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS: "100"
|
||||
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
|
||||
ports:
|
||||
- "127.0.0.1:18121:18121"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-plane-private:
|
||||
name: nodedc-device-plane-private
|
||||
internal: true
|
||||
device-plane-control:
|
||||
name: nodedc-device-plane-control
|
||||
driver: bridge
|
||||
internal: false
|
||||
driver_opts:
|
||||
com.docker.network.bridge.enable_ip_masquerade: "false"
|
||||
|
||||
volumes:
|
||||
device-plane-postgres-data:
|
||||
name: nodedc-device-plane-postgres-data
|
||||
Executable
+2106
File diff suppressed because it is too large
Load Diff
Executable
+868
@@ -0,0 +1,868 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Canonical data-only deploy runner for the dedicated NODE.DC Device Edge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
RUNNER_PATH = Path("/usr/local/sbin/nodedc-edge-deploy")
|
||||
LIVE_ROOT = Path("/home/ndcsudo/nodedc-device-edge/source")
|
||||
INBOX_ROOT = Path("/home/ndcsudo/nodedc-device-edge/deploy/inbox")
|
||||
STATE_ROOT = Path("/var/lib/nodedc-edge-deploy")
|
||||
APPLIED_ROOT = STATE_ROOT / "applied"
|
||||
FAILED_ROOT = STATE_ROOT / "failed"
|
||||
BACKUP_ROOT = STATE_ROOT / "backups"
|
||||
APPLIED_JOURNAL = STATE_ROOT / "state/applied.jsonl"
|
||||
FAILED_JOURNAL = STATE_ROOT / "state/failed.jsonl"
|
||||
DEPLOY_LOCK = STATE_ROOT / "state/deploy.lock"
|
||||
|
||||
DOCKER = "/usr/bin/docker"
|
||||
COMPONENT = "device-edge"
|
||||
ARTIFACT_TYPE = "app-overlay"
|
||||
PATCH_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
|
||||
MAX_ARTIFACT_BYTES = 16 * 1024 * 1024
|
||||
|
||||
COMPOSE_PROJECT = "nodedc-device-edge"
|
||||
BASE_COMPOSE = LIVE_ROOT / "docker-compose.device-edge.yml"
|
||||
INGRESS_COMPOSE = LIVE_ROOT / "docker-compose.device-edge.ingress.yml"
|
||||
RELAY_SERVICE = "device-edge-relay"
|
||||
RELAY_CONTAINER = "nodedc-device-edge-device-edge-relay-1"
|
||||
BACKHAUL_CONTAINER = "nodedc-device-edge-device-edge-backhaul-1"
|
||||
TAILNET_CONTAINER = "nodedc-device-edge-tailnet-1"
|
||||
RELAY_IMAGE = "nodedc/device-edge-relay:local"
|
||||
|
||||
INGRESS_PARENT = "enp1s0f0"
|
||||
INGRESS_SUBNET = "192.168.68.0/22"
|
||||
INGRESS_GATEWAY = "192.168.68.1"
|
||||
INGRESS_IPV4 = "192.168.71.253"
|
||||
INGRESS_PORT = 9921
|
||||
INGRESS_NETWORK = "nodedc-device-edge-ingress"
|
||||
INGRESS_IPV4_APPROVED = True
|
||||
INGRESS_IPV4_APPROVAL = "approved-outside-dhcp-pool"
|
||||
|
||||
ENTRIES = (
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml",
|
||||
"services/device-edge-relay/Dockerfile",
|
||||
"services/device-edge-relay/src",
|
||||
"deployment/device-edge-admission-gate-v1.json",
|
||||
)
|
||||
|
||||
PAYLOAD_FILE_SHA256 = {
|
||||
"docker-compose.device-edge.yml":
|
||||
"666945ffd9512355e610ecd36a9df96936477315150555def93e0243e8ff1e22",
|
||||
"docker-compose.device-edge.ingress.yml":
|
||||
"11bedfd7fdea749ca1bdb3b35b9c136c86b330f51a9001f0b38c4618f6f96108",
|
||||
"services/device-edge-relay/Dockerfile":
|
||||
"f2f15b7618ac2ab3a1d4dd40041d06d1e9a695edcfc168d8835f9560b4897e70",
|
||||
"services/device-edge-relay/src/runtime.mjs":
|
||||
"21e83678980aa61127bf9f3d77982dd485c4aaae208c43818db7bb1cc150b83a",
|
||||
"services/device-edge-relay/src/server.mjs":
|
||||
"1b99ec944f1d3fbadded045b159f08624e2829620cec39a97f6b4b8cdcd2be22",
|
||||
"deployment/device-edge-admission-gate-v1.json":
|
||||
"e6c1f21ff297b451c42b6746bc2063484874435dfa9f1614410a7cbe84f0ce6f",
|
||||
}
|
||||
|
||||
PREDECESSOR_FILE_SHA256 = {
|
||||
"docker-compose.device-edge.yml":
|
||||
"7f13c11d6d4d541964053c0a8cf791e401947d34c42e0f7c26f9f9df26fa00b5",
|
||||
"docker-compose.device-edge.ingress.yml":
|
||||
"a4afd04755530fc3b9be64d1a65f0f7282a9539bcc1985bfd880aa904e1c4d8f",
|
||||
"services/device-edge-relay/Dockerfile":
|
||||
"f2f15b7618ac2ab3a1d4dd40041d06d1e9a695edcfc168d8835f9560b4897e70",
|
||||
"services/device-edge-relay/src/runtime.mjs":
|
||||
"ae8bf8b55603bab266b6fa6e9bc65c9f310a9d94a54db04e2130704e38622ffc",
|
||||
"services/device-edge-relay/src/server.mjs":
|
||||
"e4b051b74f934bd37322440e6a013fb6774a76607da08f9cc1e844fc109c83c1",
|
||||
"deployment/device-edge-ingress-ipvlan-v1.json":
|
||||
"b9ce402db0c059a76f07a8d4a34297aff2250fd1c0d1aed9970b3a88f4e75d7f",
|
||||
}
|
||||
|
||||
PREDECESSOR_ABSENT = {
|
||||
"deployment/device-edge-admission-gate-v1.json",
|
||||
}
|
||||
|
||||
|
||||
class DeployError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def die(message: str) -> None:
|
||||
raise DeployError(message)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def run(command, *, check=True, capture=True, cwd=None, timeout=180):
|
||||
result = subprocess.run(
|
||||
[str(value) for value in command],
|
||||
check=False,
|
||||
capture_output=capture,
|
||||
text=True,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
timeout=timeout,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "command failed").strip()
|
||||
die(f"command failed: {command[0]}: {detail}")
|
||||
return result
|
||||
|
||||
|
||||
def docker_json(*args):
|
||||
result = run([DOCKER, *args])
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as error:
|
||||
die(f"Docker JSON response invalid: {error}")
|
||||
|
||||
|
||||
def expected_descriptor():
|
||||
return {
|
||||
"schemaVersion": "nodedc.device-edge.admission-gate.v1",
|
||||
"mode": "single-nic-ipvlan-b2-relay-only",
|
||||
"runtimeHost": "ndcmini12",
|
||||
"component": COMPONENT,
|
||||
"selectedServices": [RELAY_SERVICE],
|
||||
"preservedServices": ["device-edge-backhaul", "tailnet"],
|
||||
"composeProject": COMPOSE_PROJECT,
|
||||
"composeFiles": [
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml",
|
||||
],
|
||||
"parentInterface": INGRESS_PARENT,
|
||||
"lanSubnet": INGRESS_SUBNET,
|
||||
"lanGateway": INGRESS_GATEWAY,
|
||||
"ingressIpv4": INGRESS_IPV4,
|
||||
"ingressIpv4Approval": INGRESS_IPV4_APPROVAL,
|
||||
"ingressNetwork": INGRESS_NETWORK,
|
||||
"deviceTcpListen": f"{INGRESS_IPV4}:{INGRESS_PORT}",
|
||||
"hostPortPublication": "disabled",
|
||||
"healthPublication": "disabled",
|
||||
"privateUpstream": "device-edge-backhaul:19921",
|
||||
"sourceAdmission": "public-ipv4-only",
|
||||
"maxTrackedSourceAddresses": 2048,
|
||||
"maxBytesPerDirection": 262144,
|
||||
"protocolInspection": "gateway-owned",
|
||||
"identityTrust": "claimed-not-ownership-proof",
|
||||
"discoveryLifecycle": "quarantine",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"amneziaHostFullTunnel": "preserved",
|
||||
"routerNatFirewall": "separate-manual-gate",
|
||||
"rollback": "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation",
|
||||
}
|
||||
|
||||
|
||||
def assert_root():
|
||||
if os.geteuid() != 0:
|
||||
die("nodedc-edge-deploy must run as root")
|
||||
|
||||
|
||||
def assert_regular_nonsymlink(path: Path, label: str):
|
||||
if not path.exists() or path.is_symlink() or not path.is_file():
|
||||
die(f"{label} must be a regular non-symlink file")
|
||||
|
||||
|
||||
def parse_manifest(raw: str):
|
||||
values = {}
|
||||
for line in raw.splitlines():
|
||||
if not line or "=" not in line:
|
||||
die("artifact manifest is malformed")
|
||||
key, value = line.split("=", 1)
|
||||
if key in values or key not in {"id", "component", "type"}:
|
||||
die("artifact manifest key set is invalid")
|
||||
values[key] = value
|
||||
if set(values) != {"id", "component", "type"}:
|
||||
die("artifact manifest key set is incomplete")
|
||||
if not PATCH_ID_RE.fullmatch(values["id"]):
|
||||
die("artifact patch id is invalid")
|
||||
if values["component"] != COMPONENT or values["type"] != ARTIFACT_TYPE:
|
||||
die("artifact component/type mismatch")
|
||||
return values
|
||||
|
||||
|
||||
def safe_tar_member(member: tarfile.TarInfo):
|
||||
path = PurePosixPath(member.name)
|
||||
if path.is_absolute() or ".." in path.parts or not path.parts:
|
||||
die("artifact contains an unsafe path")
|
||||
if not (member.isfile() or member.isdir()):
|
||||
die("artifact contains a non-file/non-directory member")
|
||||
lowered = {part.lower() for part in path.parts}
|
||||
if any(
|
||||
part.startswith(".env")
|
||||
or part in {
|
||||
".git",
|
||||
"node_modules",
|
||||
"secrets",
|
||||
"keys",
|
||||
"trust",
|
||||
"runtime",
|
||||
"logs",
|
||||
"uploads",
|
||||
}
|
||||
for part in lowered
|
||||
):
|
||||
die("artifact contains a forbidden boundary")
|
||||
if any(part.startswith("._") for part in path.parts):
|
||||
die("artifact contains AppleDouble metadata")
|
||||
|
||||
|
||||
def load_artifact(artifact: Path, extraction_root: Path):
|
||||
artifact = artifact.resolve(strict=True)
|
||||
if artifact.parent != INBOX_ROOT.resolve(strict=True):
|
||||
die("artifact must be an explicit file in the Device Edge inbox")
|
||||
assert_regular_nonsymlink(artifact, "artifact")
|
||||
if artifact.suffix != ".tgz" or artifact.stat().st_size > MAX_ARTIFACT_BYTES:
|
||||
die("artifact extension/size rejected")
|
||||
|
||||
seen = set()
|
||||
with tarfile.open(artifact, "r:gz") as archive:
|
||||
for member in archive.getmembers():
|
||||
safe_tar_member(member)
|
||||
if member.name in seen:
|
||||
die("artifact contains duplicate members")
|
||||
seen.add(member.name)
|
||||
required = {"manifest.env", "files.txt", "payload"}
|
||||
if not required.issubset(seen):
|
||||
die("artifact top-level contract is incomplete")
|
||||
if any(name.split("/", 1)[0] not in required for name in seen):
|
||||
die("artifact contains an unexpected top-level member")
|
||||
archive.extractall(extraction_root, filter="data")
|
||||
|
||||
manifest = parse_manifest(
|
||||
(extraction_root / "manifest.env").read_text(encoding="utf-8")
|
||||
)
|
||||
entries = tuple(
|
||||
line for line in
|
||||
(extraction_root / "files.txt").read_text(encoding="utf-8").splitlines()
|
||||
if line
|
||||
)
|
||||
if entries != ENTRIES or len(entries) != len(set(entries)):
|
||||
die("Device Edge artifact file selection mismatch")
|
||||
payload = extraction_root / "payload"
|
||||
validate_payload(payload)
|
||||
return manifest, entries, payload, sha256_file(artifact), artifact
|
||||
|
||||
|
||||
def validate_payload(payload: Path):
|
||||
actual_files = {
|
||||
path.relative_to(payload).as_posix(): sha256_file(path)
|
||||
for path in payload.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
if actual_files != PAYLOAD_FILE_SHA256:
|
||||
die("Device Edge artifact payload digest set mismatch")
|
||||
descriptor = json.loads(
|
||||
(payload / "deployment/device-edge-admission-gate-v1.json")
|
||||
.read_text(encoding="utf-8")
|
||||
)
|
||||
if descriptor != expected_descriptor():
|
||||
die("Device Edge ingress descriptor mismatch")
|
||||
|
||||
|
||||
def journal_records(path: Path):
|
||||
if not path.exists():
|
||||
return []
|
||||
records = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
records.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
die(f"journal is malformed: {path}")
|
||||
return records
|
||||
|
||||
|
||||
def assert_new_identity(patch_id: str, artifact_sha256: str):
|
||||
records = journal_records(APPLIED_JOURNAL) + journal_records(FAILED_JOURNAL)
|
||||
if any(record.get("patch") == patch_id for record in records):
|
||||
die("Device Edge patch id is terminally recorded")
|
||||
if any(record.get("sha256") == artifact_sha256 for record in records):
|
||||
die("Device Edge artifact digest is terminally recorded")
|
||||
|
||||
|
||||
def current_source_state():
|
||||
state = {}
|
||||
for relative, expected in PREDECESSOR_FILE_SHA256.items():
|
||||
path = LIVE_ROOT / relative
|
||||
assert_regular_nonsymlink(path, f"predecessor {relative}")
|
||||
state[relative] = sha256_file(path)
|
||||
if state[relative] != expected:
|
||||
die(f"Device Edge predecessor drift: {relative}")
|
||||
for relative in PREDECESSOR_ABSENT:
|
||||
if (LIVE_ROOT / relative).exists():
|
||||
die(f"Device Edge predecessor unexpected path: {relative}")
|
||||
return state
|
||||
|
||||
|
||||
def inspect_container(name: str):
|
||||
response = docker_json("inspect", name)
|
||||
if len(response) != 1:
|
||||
die(f"container inspect cardinality mismatch: {name}")
|
||||
return response[0]
|
||||
|
||||
|
||||
def container_health(container):
|
||||
health = container.get("State", {}).get("Health")
|
||||
return health.get("Status") if health else None
|
||||
|
||||
|
||||
def preserved_runtime_snapshot():
|
||||
snapshot = {}
|
||||
for name in (BACKHAUL_CONTAINER, TAILNET_CONTAINER):
|
||||
container = inspect_container(name)
|
||||
if container.get("State", {}).get("Status") != "running":
|
||||
die(f"preserved Device Edge service is not running: {name}")
|
||||
if name == BACKHAUL_CONTAINER and container_health(container) != "healthy":
|
||||
die("Device Edge backhaul is not healthy")
|
||||
snapshot[name] = {
|
||||
"Id": container.get("Id"),
|
||||
"Image": container.get("Image"),
|
||||
"StartedAt": container.get("State", {}).get("StartedAt"),
|
||||
"RestartCount": container.get("RestartCount"),
|
||||
"PortBindings": container.get("HostConfig", {}).get("PortBindings"),
|
||||
}
|
||||
return snapshot
|
||||
|
||||
|
||||
def assert_preserved_runtime(snapshot):
|
||||
current_snapshot = preserved_runtime_snapshot()
|
||||
for name, expected in snapshot.items():
|
||||
current = current_snapshot[name]
|
||||
if current != expected:
|
||||
die(f"preserved Device Edge runtime changed: {name}")
|
||||
|
||||
|
||||
def validate_predecessor_runtime():
|
||||
relay = inspect_container(RELAY_CONTAINER)
|
||||
if relay.get("State", {}).get("Status") != "running":
|
||||
die("Device Edge IPvlan predecessor relay is not running")
|
||||
if container_health(relay) != "healthy":
|
||||
die("Device Edge IPvlan predecessor relay is not healthy")
|
||||
environment = set(relay.get("Config", {}).get("Env") or [])
|
||||
required = {
|
||||
"DEVICE_EDGE_RELAY_HEALTH_HOST=127.0.0.1",
|
||||
"DEVICE_EDGE_RELAY_INGRESS_ENABLED=true",
|
||||
"DEVICE_EDGE_RELAY_TCP_HOST=0.0.0.0",
|
||||
"DEVICE_EDGE_RELAY_TCP_PORT=9921",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_HOST=device-edge-backhaul",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921",
|
||||
}
|
||||
if not required.issubset(environment):
|
||||
die("Device Edge IPvlan predecessor environment mismatch")
|
||||
bindings = relay.get("HostConfig", {}).get("PortBindings") or {}
|
||||
if bindings not in ({}, None):
|
||||
die("Device Edge IPvlan predecessor host publication mismatch")
|
||||
validate_network_runtime(relay)
|
||||
|
||||
|
||||
def validate_host_network_boundary():
|
||||
if socket.gethostname() != "ndcmini12":
|
||||
die("Device Edge runtime host mismatch")
|
||||
route = run(["/usr/sbin/ip", "-4", "route", "show"]).stdout
|
||||
for line in (
|
||||
"0.0.0.0/1 dev amn0 metric 1",
|
||||
"128.0.0.0/1 dev amn0 metric 1",
|
||||
"default via 192.168.68.1 dev enp1s0f0",
|
||||
"192.168.68.0/22 dev enp1s0f0",
|
||||
):
|
||||
if line not in route:
|
||||
die(f"Device Edge host route boundary mismatch: {line}")
|
||||
if run(["/usr/bin/systemctl", "is-active", "AmneziaVPN.service"]).stdout.strip() != "active":
|
||||
die("AmneziaVPN must remain active for this transition")
|
||||
interface = run([
|
||||
"/usr/sbin/ip", "-4", "-brief", "address", "show", "dev", INGRESS_PARENT,
|
||||
]).stdout
|
||||
if "192.168.68.54/22" not in interface or "UP" not in interface:
|
||||
die("Device Edge physical interface boundary mismatch")
|
||||
|
||||
|
||||
def arp_duplicate_detected(target_ip: str, interface: str, attempts=3):
|
||||
protocol = 0x0806
|
||||
raw = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(protocol))
|
||||
try:
|
||||
raw.bind((interface, 0))
|
||||
source_mac = raw.getsockname()[4]
|
||||
target = socket.inet_aton(target_ip)
|
||||
ethernet = b"\xff" * 6 + source_mac + struct.pack("!H", protocol)
|
||||
arp = struct.pack(
|
||||
"!HHBBH6s4s6s4s",
|
||||
1,
|
||||
0x0800,
|
||||
6,
|
||||
4,
|
||||
1,
|
||||
source_mac,
|
||||
b"\x00" * 4,
|
||||
b"\x00" * 6,
|
||||
target,
|
||||
)
|
||||
raw.setblocking(False)
|
||||
for _ in range(attempts):
|
||||
raw.send(ethernet + arp)
|
||||
deadline = time.monotonic() + 0.7
|
||||
while time.monotonic() < deadline:
|
||||
ready, _, _ = select.select([raw], [], [], deadline - time.monotonic())
|
||||
if not ready:
|
||||
break
|
||||
packet = raw.recv(2048)
|
||||
if len(packet) < 42 or packet[12:14] != b"\x08\x06":
|
||||
continue
|
||||
if packet[28:32] == target and packet[22:28] != source_mac:
|
||||
return True
|
||||
return False
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def preflight(manifest, artifact_sha256):
|
||||
if not INGRESS_IPV4_APPROVED:
|
||||
die("Device Edge ingress IPv4 approval is not granted")
|
||||
if INGRESS_IPV4_APPROVAL != "approved-outside-dhcp-pool":
|
||||
die("Device Edge ingress IPv4 approval contract mismatch")
|
||||
assert_new_identity(manifest["id"], artifact_sha256)
|
||||
current_source_state()
|
||||
validate_predecessor_runtime()
|
||||
preserved = preserved_runtime_snapshot()
|
||||
validate_host_network_boundary()
|
||||
return preserved
|
||||
|
||||
|
||||
def compose_command(*args, baseline=False):
|
||||
command = [
|
||||
DOCKER,
|
||||
"compose",
|
||||
"--project-name",
|
||||
COMPOSE_PROJECT,
|
||||
"--file",
|
||||
str(BASE_COMPOSE),
|
||||
]
|
||||
if not baseline:
|
||||
command.extend(["--file", str(INGRESS_COMPOSE)])
|
||||
command.extend(args)
|
||||
return command
|
||||
|
||||
|
||||
def ensure_state_directories():
|
||||
for path in (
|
||||
APPLIED_ROOT,
|
||||
FAILED_ROOT,
|
||||
BACKUP_ROOT,
|
||||
APPLIED_JOURNAL.parent,
|
||||
):
|
||||
path.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
os.chmod(path, 0o750)
|
||||
|
||||
|
||||
def acquire_lock():
|
||||
ensure_state_directories()
|
||||
try:
|
||||
descriptor = os.open(
|
||||
DEPLOY_LOCK,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
0o600,
|
||||
)
|
||||
except FileExistsError:
|
||||
die("Device Edge deploy lock is present")
|
||||
os.write(descriptor, f"pid={os.getpid()}\n".encode())
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def release_lock():
|
||||
try:
|
||||
DEPLOY_LOCK.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def create_backup(patch_id: str):
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
backup_id = f"{patch_id}-{timestamp}"
|
||||
backup = BACKUP_ROOT / backup_id
|
||||
backup.mkdir(parents=False, mode=0o750)
|
||||
present = []
|
||||
absent = []
|
||||
for relative in ENTRIES:
|
||||
source = LIVE_ROOT / relative
|
||||
target = backup / "payload" / relative
|
||||
if not source.exists():
|
||||
absent.append(relative)
|
||||
continue
|
||||
present.append(relative)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, target, symlinks=False)
|
||||
else:
|
||||
shutil.copy2(source, target, follow_symlinks=False)
|
||||
(backup / "backup.json").write_text(json.dumps({
|
||||
"schemaVersion": "nodedc.device-edge.backup.v1",
|
||||
"patch": patch_id,
|
||||
"present": present,
|
||||
"absent": absent,
|
||||
}, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
return backup_id, backup
|
||||
|
||||
|
||||
def publish_payload(payload: Path):
|
||||
for relative in ENTRIES:
|
||||
source = payload / relative
|
||||
target = LIVE_ROOT / relative
|
||||
if target.exists():
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, target, symlinks=False)
|
||||
else:
|
||||
shutil.copy2(source, target, follow_symlinks=False)
|
||||
|
||||
|
||||
def restore_backup(backup: Path):
|
||||
descriptor = json.loads((backup / "backup.json").read_text(encoding="utf-8"))
|
||||
for relative in ENTRIES:
|
||||
target = LIVE_ROOT / relative
|
||||
if target.exists():
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
for relative in descriptor["present"]:
|
||||
source = backup / "payload" / relative
|
||||
target = LIVE_ROOT / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, target, symlinks=False)
|
||||
else:
|
||||
shutil.copy2(source, target, follow_symlinks=False)
|
||||
|
||||
|
||||
def build_relay():
|
||||
run([
|
||||
DOCKER,
|
||||
"build",
|
||||
"--no-cache",
|
||||
"--network=host",
|
||||
"--file",
|
||||
"services/device-edge-relay/Dockerfile",
|
||||
"--tag",
|
||||
RELAY_IMAGE,
|
||||
".",
|
||||
], cwd=LIVE_ROOT, timeout=900, capture=False)
|
||||
|
||||
|
||||
def wait_healthy(name: str, timeout_seconds=150):
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
container = inspect_container(name)
|
||||
except DeployError:
|
||||
time.sleep(2)
|
||||
continue
|
||||
if (
|
||||
container.get("State", {}).get("Status") == "running"
|
||||
and container_health(container) == "healthy"
|
||||
):
|
||||
return container
|
||||
if container.get("State", {}).get("Status") in {"exited", "dead"}:
|
||||
die(f"container stopped before health acceptance: {name}")
|
||||
time.sleep(2)
|
||||
die(f"container health timeout: {name}")
|
||||
|
||||
|
||||
def validate_network_runtime(relay):
|
||||
networks = relay.get("NetworkSettings", {}).get("Networks") or {}
|
||||
if set(networks) != {"nodedc-device-edge-private", INGRESS_NETWORK}:
|
||||
die("Device Edge relay network set mismatch")
|
||||
if networks[INGRESS_NETWORK].get("IPAddress") != INGRESS_IPV4:
|
||||
die("Device Edge relay IPvlan address mismatch")
|
||||
response = docker_json("network", "inspect", INGRESS_NETWORK)
|
||||
if len(response) != 1:
|
||||
die("Device Edge ingress network cardinality mismatch")
|
||||
network = response[0]
|
||||
if network.get("Driver") != "ipvlan" or network.get("Internal") is True:
|
||||
die("Device Edge ingress network driver mismatch")
|
||||
options = network.get("Options") or {}
|
||||
if options.get("parent") != INGRESS_PARENT or options.get("ipvlan_mode") != "l2":
|
||||
die("Device Edge ingress network option mismatch")
|
||||
configs = network.get("IPAM", {}).get("Config") or []
|
||||
if len(configs) != 1:
|
||||
die("Device Edge ingress IPAM cardinality mismatch")
|
||||
if configs[0].get("Subnet") != INGRESS_SUBNET or configs[0].get("Gateway") != INGRESS_GATEWAY:
|
||||
die("Device Edge ingress IPAM mismatch")
|
||||
|
||||
|
||||
def validate_relay_runtime(preserved):
|
||||
relay = wait_healthy(RELAY_CONTAINER)
|
||||
if relay.get("Config", {}).get("User") != "1000:1000":
|
||||
die("Device Edge relay user mismatch")
|
||||
host = relay.get("HostConfig", {})
|
||||
if host.get("ReadonlyRootfs") is not True or host.get("Privileged") is not False:
|
||||
die("Device Edge relay filesystem/privilege mismatch")
|
||||
if set(host.get("CapDrop") or []) != {"ALL"}:
|
||||
die("Device Edge relay capability mismatch")
|
||||
if host.get("PortBindings") not in ({}, None):
|
||||
die("Device Edge relay host port publication detected")
|
||||
environment = set(relay.get("Config", {}).get("Env") or [])
|
||||
required = {
|
||||
"DEVICE_EDGE_RELAY_HEALTH_HOST=127.0.0.1",
|
||||
"DEVICE_EDGE_RELAY_INGRESS_ENABLED=true",
|
||||
"DEVICE_EDGE_RELAY_TCP_HOST=0.0.0.0",
|
||||
"DEVICE_EDGE_RELAY_TCP_PORT=9921",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_HOST=device-edge-backhaul",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921",
|
||||
"DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only",
|
||||
"DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES=2048",
|
||||
"DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION=262144",
|
||||
}
|
||||
if not required.issubset(environment):
|
||||
die("Device Edge relay environment mismatch")
|
||||
validate_network_runtime(relay)
|
||||
health_result = run([
|
||||
DOCKER,
|
||||
"exec",
|
||||
RELAY_CONTAINER,
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:18221/healthz').then(async r=>{if(!r.ok)process.exit(2);console.log(await r.text())}).catch(()=>process.exit(3))",
|
||||
])
|
||||
try:
|
||||
health = json.loads(health_result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
die("Device Edge relay health JSON invalid")
|
||||
expected_health = {
|
||||
"ok": True,
|
||||
"service": "nodedc-device-edge-relay",
|
||||
"ingress": "relay-only",
|
||||
"protocolInspection": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"sourceAdmission": "public-ipv4-only",
|
||||
}
|
||||
for key, expected in expected_health.items():
|
||||
if health.get(key) != expected:
|
||||
die(f"Device Edge relay health contract mismatch: {key}")
|
||||
run([
|
||||
DOCKER,
|
||||
"exec",
|
||||
RELAY_CONTAINER,
|
||||
"node",
|
||||
"-e",
|
||||
"const n=require('node:net');const s=n.connect({host:'device-edge-backhaul',port:19921});s.setTimeout(5000);s.once('connect',()=>{s.destroy();process.exit(0)});s.once('timeout',()=>process.exit(2));s.once('error',()=>process.exit(3))",
|
||||
])
|
||||
validate_host_network_boundary()
|
||||
assert_preserved_runtime(preserved)
|
||||
|
||||
|
||||
def write_journal(path: Path, record):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def archive_artifact(artifact: Path, destination_root: Path):
|
||||
destination = destination_root / artifact.name
|
||||
if destination.exists():
|
||||
die("Device Edge artifact archive collision")
|
||||
os.replace(artifact, destination)
|
||||
return destination
|
||||
|
||||
|
||||
def rollback(backup: Path, preserved):
|
||||
restore_backup(backup)
|
||||
run(compose_command(
|
||||
"up",
|
||||
"--detach",
|
||||
"--no-deps",
|
||||
"--force-recreate",
|
||||
"--pull",
|
||||
"never",
|
||||
RELAY_SERVICE,
|
||||
), cwd=LIVE_ROOT, timeout=300, capture=False)
|
||||
wait_healthy(RELAY_CONTAINER)
|
||||
current_source_state()
|
||||
validate_predecessor_runtime()
|
||||
assert_preserved_runtime(preserved)
|
||||
|
||||
|
||||
def plan_artifact(artifact_argument: str):
|
||||
assert_root()
|
||||
artifact = Path(artifact_argument)
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-edge-plan-") as directory:
|
||||
manifest, entries, _payload, digest, resolved = load_artifact(
|
||||
artifact,
|
||||
Path(directory),
|
||||
)
|
||||
preflight(manifest, digest)
|
||||
print("== plan ==")
|
||||
print(f"artifact={resolved.name}")
|
||||
print(f"sha256={digest}")
|
||||
print(f"id={manifest['id']}")
|
||||
print(f"component={COMPONENT}")
|
||||
print(f"type={ARTIFACT_TYPE}")
|
||||
print(f"payload_root={LIVE_ROOT}")
|
||||
print(f"compose_root={LIVE_ROOT}")
|
||||
print(f"compose_project={COMPOSE_PROJECT}")
|
||||
print("compose_files=docker-compose.device-edge.yml docker-compose.device-edge.ingress.yml")
|
||||
print("build=/usr/bin/docker build --no-cache --network=host -f services/device-edge-relay/Dockerfile -t nodedc/device-edge-relay:local .")
|
||||
print("services=device-edge-relay")
|
||||
print("preserved_services=device-edge-backhaul tailnet")
|
||||
print(f"device_edge_ingress=ipvlan:l2:{INGRESS_PARENT}:{INGRESS_IPV4}:{INGRESS_PORT}/tcp")
|
||||
print(f"device_edge_lan={INGRESS_SUBNET}:gateway:{INGRESS_GATEWAY}")
|
||||
print(f"device_edge_ingress_ipv4_approval={INGRESS_IPV4_APPROVAL}")
|
||||
print("device_edge_host_port_publication=disabled")
|
||||
print("device_edge_health_publication=disabled")
|
||||
print("device_edge_private_upstream=device-edge-backhaul:19921")
|
||||
print("device_edge_source_admission=public-ipv4-only")
|
||||
print("device_edge_source_table_limit=2048")
|
||||
print("device_edge_byte_limit_per_direction=262144")
|
||||
print("device_edge_command_transport=disabled")
|
||||
print("device_edge_discovery_lifecycle=quarantine")
|
||||
print("device_edge_gelios=untouched")
|
||||
print("device_edge_amnezia=preserved:active:host-full-tunnel")
|
||||
print("device_edge_router_nat_firewall=unchanged")
|
||||
print("device_edge_rollback=restore-reviewed-ipvlan-predecessor-no-router-mutation")
|
||||
print("state=new")
|
||||
print("== files ==")
|
||||
for entry in entries:
|
||||
print(f" {entry}")
|
||||
|
||||
|
||||
def apply_artifact(artifact_argument: str):
|
||||
assert_root()
|
||||
artifact = Path(artifact_argument)
|
||||
acquire_lock()
|
||||
manifest = None
|
||||
digest = None
|
||||
resolved = None
|
||||
backup_id = None
|
||||
backup = None
|
||||
preserved = None
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-edge-apply-") as directory:
|
||||
manifest, _entries, payload, digest, resolved = load_artifact(
|
||||
artifact,
|
||||
Path(directory),
|
||||
)
|
||||
preserved = preflight(manifest, digest)
|
||||
backup_id, backup = create_backup(manifest["id"])
|
||||
publish_payload(payload)
|
||||
build_relay()
|
||||
run(compose_command(
|
||||
"up",
|
||||
"--detach",
|
||||
"--no-deps",
|
||||
"--force-recreate",
|
||||
"--pull",
|
||||
"never",
|
||||
RELAY_SERVICE,
|
||||
), cwd=LIVE_ROOT, timeout=300, capture=False)
|
||||
validate_relay_runtime(preserved)
|
||||
archived = archive_artifact(resolved, APPLIED_ROOT)
|
||||
write_journal(APPLIED_JOURNAL, {
|
||||
"status": "ok",
|
||||
"patch": manifest["id"],
|
||||
"component": COMPONENT,
|
||||
"sha256": digest,
|
||||
"artifact": archived.name,
|
||||
"backup": backup_id,
|
||||
"appliedAt": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
print(
|
||||
f"deploy-ok patch={manifest['id']} component={COMPONENT} "
|
||||
f"backup={backup_id}"
|
||||
)
|
||||
except Exception as error:
|
||||
rollback_status = "not-started"
|
||||
if backup is not None and preserved is not None:
|
||||
try:
|
||||
rollback(backup, preserved)
|
||||
rollback_status = "ok"
|
||||
except Exception as rollback_error:
|
||||
rollback_status = f"failed:{type(rollback_error).__name__}"
|
||||
if resolved is not None and resolved.exists():
|
||||
failed_name = (
|
||||
FAILED_ROOT
|
||||
/ f"{resolved.name}.{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
|
||||
)
|
||||
os.replace(resolved, failed_name)
|
||||
if manifest is not None and digest is not None:
|
||||
write_journal(FAILED_JOURNAL, {
|
||||
"status": "failed",
|
||||
"patch": manifest["id"],
|
||||
"component": COMPONENT,
|
||||
"sha256": digest,
|
||||
"backup": backup_id,
|
||||
"rollback": rollback_status,
|
||||
"error": type(error).__name__,
|
||||
"failedAt": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
if rollback_status.startswith("failed"):
|
||||
die(f"apply failed and rollback failed: {error}")
|
||||
die(f"apply failed; automatic rollback={rollback_status}: {error}")
|
||||
finally:
|
||||
release_lock()
|
||||
|
||||
|
||||
def verify_install():
|
||||
assert_root()
|
||||
path = RUNNER_PATH if RUNNER_PATH.exists() else Path(__file__).resolve()
|
||||
assert_regular_nonsymlink(path, "runner")
|
||||
docker_version = run([DOCKER, "version", "--format", "{{.Server.Version}}"]).stdout.strip()
|
||||
compose_version = run([DOCKER, "compose", "version", "--short"]).stdout.strip()
|
||||
print(f"path={path}")
|
||||
print(f"sha256={sha256_file(path)}")
|
||||
print(f"python={sys.version.split()[0]}")
|
||||
print(f"docker={docker_version}")
|
||||
print(f"compose={compose_version}")
|
||||
print(f"device_edge_ingress_ipv4={INGRESS_IPV4}")
|
||||
print(f"device_edge_ingress_ipv4_approval={INGRESS_IPV4_APPROVAL}")
|
||||
print("device_edge_source_admission=public-ipv4-only")
|
||||
print("verify-install-ok")
|
||||
|
||||
|
||||
def main(arguments):
|
||||
if len(arguments) == 1 and arguments[0] == "verify-install":
|
||||
verify_install()
|
||||
return 0
|
||||
if len(arguments) == 2 and arguments[0] == "plan":
|
||||
plan_artifact(arguments[1])
|
||||
return 0
|
||||
if len(arguments) == 2 and arguments[0] == "apply":
|
||||
apply_artifact(arguments[1])
|
||||
return 0
|
||||
print(
|
||||
"usage: nodedc-edge-deploy verify-install | plan <artifact.tgz> | apply <artifact.tgz>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
except DeployError as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
RUNNER_PATH = Path(os.environ.get("NODEDC_DEPLOY_RUNNER", SCRIPT_DIR.parents[2] / "platform" / "infra" / "deploy-runner" / "nodedc-deploy"))
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_edge_core_channel_deploy_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DeviceEdgeCoreChannelBootstrapTest(unittest.TestCase):
|
||||
def test_identity_generation_ignores_synology_global_ca_extensions(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-openssl-",
|
||||
) as directory:
|
||||
root = Path(directory)
|
||||
malicious = root / "synology-openssl.cnf"
|
||||
malicious.write_text(
|
||||
"""[ req ]
|
||||
prompt = no
|
||||
distinguished_name = dn
|
||||
x509_extensions = v3_ca
|
||||
|
||||
[ dn ]
|
||||
CN = synology-global-default
|
||||
|
||||
[ v3_ca ]
|
||||
basicConstraints = critical,CA:TRUE
|
||||
keyUsage = critical,keyCertSign,cRLSign
|
||||
""",
|
||||
encoding="ascii",
|
||||
)
|
||||
private_key = root / "core-private-key.pem"
|
||||
certificate = root / "core-certificate.pem"
|
||||
with (
|
||||
mock.patch.dict(
|
||||
os.environ,
|
||||
{"OPENSSL_CONF": str(malicious)},
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"resolve_openssl_binary",
|
||||
return_value=Path(shutil.which("openssl")),
|
||||
),
|
||||
):
|
||||
RUNNER.generate_device_edge_channel_core_identity(
|
||||
private_key,
|
||||
certificate,
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.validate_device_edge_channel_certificate_extensions(
|
||||
certificate
|
||||
),
|
||||
"exact-clientAuth",
|
||||
)
|
||||
text = RUNNER.device_edge_channel_certificate_text(certificate)
|
||||
self.assertEqual(
|
||||
text.count("X509v3 Basic Constraints: critical"),
|
||||
1,
|
||||
)
|
||||
self.assertIn("CA:FALSE", text)
|
||||
self.assertNotIn("CA:TRUE", text)
|
||||
RUNNER.run_openssl(
|
||||
[
|
||||
"verify",
|
||||
"-purpose",
|
||||
"sslclient",
|
||||
"-CAfile",
|
||||
str(certificate),
|
||||
str(certificate),
|
||||
],
|
||||
"unit Device Edge client certificate",
|
||||
)
|
||||
|
||||
def test_bootstrap_acceptance_is_core_only_and_preserves_manager(self):
|
||||
entries = RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_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
|
||||
)
|
||||
|
||||
def test_exact_failed_016_recovery_is_required_for_replacement(self):
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_edge_channel_invalid_identity_is_exact_recoverable",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"does not match the exact unexported failed-016",
|
||||
):
|
||||
RUNNER.recover_invalid_device_edge_channel_core_identity()
|
||||
|
||||
def test_failed_016_recovery_accepts_actual_synology_constraint_shape(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-failed-016-",
|
||||
) as directory:
|
||||
root = Path(directory)
|
||||
private_key = root / "core-private-key.pem"
|
||||
certificate = root / "core-certificate.pem"
|
||||
peers = root / "peers"
|
||||
private_key.write_text("private-placeholder\n", encoding="ascii")
|
||||
certificate.write_text("certificate-placeholder\n", encoding="ascii")
|
||||
peers.mkdir()
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_CORE_PRIVATE_KEY_FILE",
|
||||
private_key,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE",
|
||||
certificate,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_PEER_TRUST_DIR",
|
||||
peers,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_CERTIFICATE_FILE",
|
||||
root / "exported-certificate.pem",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_FINGERPRINT_FILE",
|
||||
root / "exported-fingerprint.txt",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_edge_channel_certificate_fingerprint",
|
||||
return_value=(
|
||||
RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_INVALID_CERTIFICATE_FINGERPRINT
|
||||
),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_edge_channel_certificate_text",
|
||||
return_value="""
|
||||
X509v3 Basic Constraints:
|
||||
CA:TRUE
|
||||
X509v3 Basic Constraints: critical
|
||||
CA:FALSE
|
||||
X509v3 Key Usage: critical
|
||||
Digital Signature
|
||||
X509v3 Extended Key Usage:
|
||||
TLS Web Client Authentication
|
||||
""",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"capture_openssl",
|
||||
side_effect=[b"same-public-key", b"same-public-key"],
|
||||
),
|
||||
):
|
||||
self.assertTrue(
|
||||
RUNNER.device_edge_channel_invalid_identity_is_exact_recoverable()
|
||||
)
|
||||
|
||||
def test_failed_016_recovery_rejects_ambiguous_constraint_shape(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-ambiguous-",
|
||||
) as directory:
|
||||
root = Path(directory)
|
||||
private_key = root / "core-private-key.pem"
|
||||
certificate = root / "core-certificate.pem"
|
||||
peers = root / "peers"
|
||||
private_key.write_text("private-placeholder\n", encoding="ascii")
|
||||
certificate.write_text("certificate-placeholder\n", encoding="ascii")
|
||||
peers.mkdir()
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_CORE_PRIVATE_KEY_FILE",
|
||||
private_key,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE",
|
||||
certificate,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_PEER_TRUST_DIR",
|
||||
peers,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_CERTIFICATE_FILE",
|
||||
root / "exported-certificate.pem",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_FINGERPRINT_FILE",
|
||||
root / "exported-fingerprint.txt",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_edge_channel_certificate_fingerprint",
|
||||
return_value=(
|
||||
RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_INVALID_CERTIFICATE_FINGERPRINT
|
||||
),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_edge_channel_certificate_text",
|
||||
return_value="""
|
||||
X509v3 Basic Constraints:
|
||||
CA:TRUE
|
||||
X509v3 Basic Constraints: critical
|
||||
CA:FALSE
|
||||
X509v3 Basic Constraints: critical
|
||||
CA:FALSE
|
||||
""",
|
||||
),
|
||||
):
|
||||
self.assertFalse(
|
||||
RUNNER.device_edge_channel_invalid_identity_is_exact_recoverable()
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = SCRIPT_DIR / "build-device-edge-ingress-artifact.mjs"
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-edge-deploy"
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_edge_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DeviceEdgeIngressArtifactTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
return subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
|
||||
def test_builder_is_deterministic_narrow_and_secret_free(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-artifact-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
patch_id = "device-edge-ingress-ipvlan-unit-001"
|
||||
first_result = self.build(artifact_dir, patch_id)
|
||||
self.assertEqual(first_result.returncode, 0, first_result.stderr)
|
||||
first = json.loads(first_result.stdout)
|
||||
first_bytes = Path(first["artifact"]).read_bytes()
|
||||
second_result = self.build(artifact_dir, patch_id)
|
||||
self.assertEqual(second_result.returncode, 0, second_result.stderr)
|
||||
second = json.loads(second_result.stdout)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(first["sha256"], second["sha256"])
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(first["component"], "device-edge")
|
||||
self.assertEqual(first["entries"], list(RUNNER.ENTRIES))
|
||||
self.assertEqual(first["services"], ["device-edge-relay"])
|
||||
self.assertEqual(
|
||||
first["ingress"]["ipv4Approval"],
|
||||
"approved-outside-dhcp-pool",
|
||||
)
|
||||
|
||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
names = {member.name for member in members}
|
||||
manifest = archive.extractfile("manifest.env").read().decode()
|
||||
files = archive.extractfile("files.txt").read().decode().splitlines()
|
||||
payload_bytes = b"\n".join(
|
||||
archive.extractfile(member).read()
|
||||
for member in members
|
||||
if member.isfile()
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
manifest,
|
||||
f"id={patch_id}\ncomponent=device-edge\ntype=app-overlay\n",
|
||||
)
|
||||
self.assertEqual(files, list(RUNNER.ENTRIES))
|
||||
self.assertIn(
|
||||
"payload/docker-compose.device-edge.ingress.yml",
|
||||
names,
|
||||
)
|
||||
self.assertNotIn(b"PRIVATE KEY", payload_bytes)
|
||||
self.assertFalse(any(
|
||||
"/test/" in name
|
||||
or "/secrets/" in name
|
||||
or "/keys/" in name
|
||||
or "/trust/" in name
|
||||
or "/node_modules/" in name
|
||||
or Path(name).name.startswith(".env")
|
||||
for name in names
|
||||
))
|
||||
|
||||
def test_production_builder_accepts_the_explicitly_approved_address(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-address-gate-",
|
||||
) as directory:
|
||||
result = self.build(
|
||||
Path(directory),
|
||||
"device-edge-admission-gate-20260804-002",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
built = json.loads(result.stdout)
|
||||
self.assertEqual(
|
||||
built["ingress"]["ipv4Approval"],
|
||||
"approved-outside-dhcp-pool",
|
||||
)
|
||||
self.assertTrue(Path(built["artifact"]).is_file())
|
||||
|
||||
def test_runner_loads_exact_artifact_and_enters_runtime_preflight(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-runner-load-",
|
||||
) as directory:
|
||||
workspace = Path(directory)
|
||||
inbox = workspace / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"device-edge-admission-gate-20260804-003",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
extracted = workspace / "extracted"
|
||||
extracted.mkdir()
|
||||
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
manifest, entries, payload, digest, resolved = (
|
||||
RUNNER.load_artifact(artifact, extracted)
|
||||
)
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
|
||||
self.assertEqual(manifest["component"], "device-edge")
|
||||
self.assertEqual(entries, RUNNER.ENTRIES)
|
||||
self.assertEqual(resolved, artifact.resolve())
|
||||
self.assertEqual(digest, hashlib.sha256(artifact.read_bytes()).hexdigest())
|
||||
self.assertEqual(
|
||||
json.loads(
|
||||
(payload / "deployment/device-edge-admission-gate-v1.json")
|
||||
.read_text(encoding="utf-8")
|
||||
),
|
||||
RUNNER.expected_descriptor(),
|
||||
)
|
||||
preserved = {
|
||||
RUNNER.BACKHAUL_CONTAINER: {"Id": "backhaul"},
|
||||
RUNNER.TAILNET_CONTAINER: {"Id": "tailnet"},
|
||||
}
|
||||
with patch.object(RUNNER, "assert_new_identity"), patch.object(
|
||||
RUNNER,
|
||||
"current_source_state",
|
||||
), patch.object(RUNNER, "validate_predecessor_runtime"), patch.object(
|
||||
RUNNER,
|
||||
"preserved_runtime_snapshot",
|
||||
return_value=preserved,
|
||||
), patch.object(RUNNER, "validate_host_network_boundary"), patch.object(
|
||||
RUNNER,
|
||||
"arp_duplicate_detected",
|
||||
return_value=False,
|
||||
), patch.object(
|
||||
RUNNER,
|
||||
"run",
|
||||
return_value=subprocess.CompletedProcess([], 1, "", ""),
|
||||
):
|
||||
self.assertEqual(RUNNER.preflight(manifest, digest), preserved)
|
||||
|
||||
def test_backup_restore_preserves_exact_predecessor_partition(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-backup-",
|
||||
) as directory:
|
||||
workspace = Path(directory)
|
||||
live = workspace / "live"
|
||||
backups = workspace / "backups"
|
||||
live.mkdir()
|
||||
backups.mkdir()
|
||||
for relative in RUNNER.ENTRIES:
|
||||
if relative in RUNNER.PREDECESSOR_ABSENT:
|
||||
continue
|
||||
target = live / relative
|
||||
if relative.endswith("/src"):
|
||||
target.mkdir(parents=True)
|
||||
(target / "server.mjs").write_text("old\n", encoding="utf-8")
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(f"old:{relative}\n", encoding="utf-8")
|
||||
|
||||
old_live = RUNNER.LIVE_ROOT
|
||||
old_backups = RUNNER.BACKUP_ROOT
|
||||
RUNNER.LIVE_ROOT = live
|
||||
RUNNER.BACKUP_ROOT = backups
|
||||
try:
|
||||
_backup_id, backup = RUNNER.create_backup("unit-backup")
|
||||
for relative in RUNNER.ENTRIES:
|
||||
target = live / relative
|
||||
if target.exists():
|
||||
if target.is_dir():
|
||||
import shutil
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("candidate\n", encoding="utf-8")
|
||||
RUNNER.restore_backup(backup)
|
||||
finally:
|
||||
RUNNER.LIVE_ROOT = old_live
|
||||
RUNNER.BACKUP_ROOT = old_backups
|
||||
|
||||
for relative in RUNNER.PREDECESSOR_ABSENT:
|
||||
self.assertFalse((live / relative).exists())
|
||||
self.assertEqual(
|
||||
(live / "docker-compose.device-edge.yml").read_text(),
|
||||
"old:docker-compose.device-edge.yml\n",
|
||||
)
|
||||
self.assertEqual(
|
||||
(live / "services/device-edge-relay/src/server.mjs").read_text(),
|
||||
"old\n",
|
||||
)
|
||||
|
||||
def test_runner_selection_and_compose_commands_are_exact(self):
|
||||
self.assertTrue(RUNNER.INGRESS_IPV4_APPROVED)
|
||||
self.assertEqual(
|
||||
RUNNER.INGRESS_IPV4_APPROVAL,
|
||||
"approved-outside-dhcp-pool",
|
||||
)
|
||||
self.assertEqual(RUNNER.RELAY_SERVICE, "device-edge-relay")
|
||||
self.assertEqual(
|
||||
RUNNER.expected_descriptor()["preservedServices"],
|
||||
["device-edge-backhaul", "tailnet"],
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.compose_command(
|
||||
"up",
|
||||
"--detach",
|
||||
"--no-deps",
|
||||
"--force-recreate",
|
||||
"--pull",
|
||||
"never",
|
||||
RUNNER.RELAY_SERVICE,
|
||||
),
|
||||
[
|
||||
RUNNER.DOCKER,
|
||||
"compose",
|
||||
"--project-name",
|
||||
RUNNER.COMPOSE_PROJECT,
|
||||
"--file",
|
||||
str(RUNNER.BASE_COMPOSE),
|
||||
"--file",
|
||||
str(RUNNER.INGRESS_COMPOSE),
|
||||
"up",
|
||||
"--detach",
|
||||
"--no-deps",
|
||||
"--force-recreate",
|
||||
"--pull",
|
||||
"never",
|
||||
RUNNER.RELAY_SERVICE,
|
||||
],
|
||||
)
|
||||
self.assertNotIn("down", RUNNER_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
def test_preserved_runtime_is_compared_from_one_atomic_snapshot(self):
|
||||
expected = {
|
||||
RUNNER.BACKHAUL_CONTAINER: {"Id": "backhaul"},
|
||||
RUNNER.TAILNET_CONTAINER: {"Id": "tailnet"},
|
||||
}
|
||||
with patch.object(
|
||||
RUNNER,
|
||||
"preserved_runtime_snapshot",
|
||||
return_value=expected,
|
||||
) as snapshot:
|
||||
RUNNER.assert_preserved_runtime(expected)
|
||||
snapshot.assert_called_once_with()
|
||||
|
||||
def test_network_acceptance_rejects_any_non_ipvlan_substitution(self):
|
||||
relay = {
|
||||
"NetworkSettings": {
|
||||
"Networks": {
|
||||
"nodedc-device-edge-private": {"IPAddress": "172.18.0.4"},
|
||||
RUNNER.INGRESS_NETWORK: {"IPAddress": RUNNER.INGRESS_IPV4},
|
||||
},
|
||||
},
|
||||
}
|
||||
accepted_network = [{
|
||||
"Driver": "ipvlan",
|
||||
"Internal": False,
|
||||
"Options": {
|
||||
"parent": RUNNER.INGRESS_PARENT,
|
||||
"ipvlan_mode": "l2",
|
||||
},
|
||||
"IPAM": {
|
||||
"Config": [{
|
||||
"Subnet": RUNNER.INGRESS_SUBNET,
|
||||
"Gateway": RUNNER.INGRESS_GATEWAY,
|
||||
}],
|
||||
},
|
||||
}]
|
||||
with patch.object(RUNNER, "docker_json", return_value=accepted_network):
|
||||
RUNNER.validate_network_runtime(relay)
|
||||
|
||||
rejected_network = json.loads(json.dumps(accepted_network))
|
||||
rejected_network[0]["Driver"] = "bridge"
|
||||
with patch.object(RUNNER, "docker_json", return_value=rejected_network):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"ingress network driver mismatch",
|
||||
):
|
||||
RUNNER.validate_network_runtime(relay)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,773 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = SCRIPT_DIR / "build-device-edge-vps-artifact.mjs"
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-b2-vps-deploy"
|
||||
DEFAULT_RUNTIME_CACHE = Path(
|
||||
os.environ.get("NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR", "/tmp")
|
||||
)
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_b2_vps_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, phase, patch_id, runtime_cache=None):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
environment["NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR"] = str(
|
||||
runtime_cache or DEFAULT_RUNTIME_CACHE
|
||||
)
|
||||
if phase in {"backhaul", "relay"}:
|
||||
environment["NODEDC_ALLOW_SUPERSEDED_TRANSPORT"] = "test-only"
|
||||
return subprocess.run(
|
||||
["node", str(BUILDER), phase, patch_id],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
|
||||
def test_superseded_transport_builds_fail_closed_by_default(self):
|
||||
environment = os.environ.copy()
|
||||
environment.pop("NODEDC_ALLOW_SUPERSEDED_TRANSPORT", None)
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-frozen-") as directory:
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = directory
|
||||
for phase in ("backhaul", "relay"):
|
||||
with self.subTest(phase=phase):
|
||||
result = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
str(BUILDER),
|
||||
phase,
|
||||
f"device-edge-vps-{phase}-frozen-001",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn(
|
||||
"vps_initiated_transport_frozen:ADR-0001",
|
||||
result.stderr,
|
||||
)
|
||||
|
||||
def test_runner_rejects_superseded_transport_before_host_preflight(self):
|
||||
for phase in ("backhaul", "relay"):
|
||||
with self.subTest(phase=phase), self.assertRaises(RUNNER.DeployError):
|
||||
RUNNER.preflight({"phase": phase})
|
||||
|
||||
def test_accepted_shared_source_phases_cannot_be_rebuilt(self):
|
||||
for phase in ("core-channel", "tracker-ingress"):
|
||||
with self.subTest(phase=phase), tempfile.TemporaryDirectory(
|
||||
prefix=f"nodedc-vps-frozen-{phase}-"
|
||||
) as directory:
|
||||
result = self.build(
|
||||
Path(directory),
|
||||
phase,
|
||||
f"device-edge-vps-{phase}-frozen-001",
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn(
|
||||
f"accepted_vps_phase_rebuild_frozen:{phase}:ADR-0001",
|
||||
result.stderr,
|
||||
)
|
||||
|
||||
def require_runtime_cache(self):
|
||||
missing = []
|
||||
for name, digest in (
|
||||
(RUNNER.NODE_ARCHIVE, RUNNER.NODE_ARCHIVE_SHA256),
|
||||
(RUNNER.TAILSCALE_ARCHIVE, RUNNER.TAILSCALE_ARCHIVE_SHA256),
|
||||
):
|
||||
path = DEFAULT_RUNTIME_CACHE / name
|
||||
if not path.is_file():
|
||||
missing.append(str(path))
|
||||
continue
|
||||
self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), digest)
|
||||
if missing:
|
||||
self.skipTest(
|
||||
"immutable VPS runtime cache is not available: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
def test_builders_are_deterministic_narrow_and_secret_free(self):
|
||||
self.require_runtime_cache()
|
||||
for phase in (
|
||||
"command-transport",
|
||||
):
|
||||
with self.subTest(phase=phase), tempfile.TemporaryDirectory(
|
||||
prefix=f"nodedc-vps-{phase}-"
|
||||
) as directory:
|
||||
root = Path(directory)
|
||||
patch_id = f"device-edge-vps-{phase}-unit-001"
|
||||
first = self.build(root, phase, patch_id)
|
||||
self.assertEqual(first.returncode, 0, first.stderr)
|
||||
first_result = json.loads(first.stdout)
|
||||
first_bytes = Path(first_result["artifact"]).read_bytes()
|
||||
second = self.build(root, phase, patch_id)
|
||||
self.assertEqual(second.returncode, 0, second.stderr)
|
||||
second_result = json.loads(second.stdout)
|
||||
second_bytes = Path(second_result["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(first_result["sha256"], second_result["sha256"])
|
||||
self.assertEqual(
|
||||
first_result["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(first_result["entries"], list(RUNNER.PHASE_ENTRIES[phase]))
|
||||
|
||||
with tarfile.open(first_result["artifact"], "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
names = {member.name for member in members}
|
||||
payload = b"\n".join(
|
||||
archive.extractfile(member).read()
|
||||
for member in members
|
||||
if member.isfile() and member.size < 2 * 1024 * 1024
|
||||
)
|
||||
self.assertIn("manifest.env", names)
|
||||
self.assertIn("files.txt", names)
|
||||
self.assertFalse(any(
|
||||
"/secrets/" in name
|
||||
or "/keys/" in name
|
||||
or "/trust/" in name
|
||||
or "/runtime/" in name
|
||||
or "/node_modules/" in name
|
||||
or Path(name).name.startswith(".env")
|
||||
for name in names
|
||||
))
|
||||
self.assertNotIn(b"PRIVATE KEY", payload)
|
||||
self.assertNotIn(b"TS_AUTHKEY", payload)
|
||||
|
||||
def test_foundation_builder_rejects_modified_runtime_archive(self):
|
||||
self.require_runtime_cache()
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-corrupt-") as directory:
|
||||
cache = Path(directory) / "cache"
|
||||
artifacts = Path(directory) / "artifacts"
|
||||
cache.mkdir()
|
||||
for name in (RUNNER.NODE_ARCHIVE, RUNNER.TAILSCALE_ARCHIVE):
|
||||
(cache / name).write_bytes((DEFAULT_RUNTIME_CACHE / name).read_bytes())
|
||||
with (cache / RUNNER.NODE_ARCHIVE).open("ab") as handle:
|
||||
handle.write(b"corrupt")
|
||||
result = self.build(
|
||||
artifacts,
|
||||
"foundation",
|
||||
"device-edge-vps-foundation-corrupt-001",
|
||||
runtime_cache=cache,
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("runtime_digest_mismatch", result.stderr)
|
||||
|
||||
def test_runner_loads_each_exact_phase(self):
|
||||
self.require_runtime_cache()
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-load-") as directory:
|
||||
inbox = Path(directory) / "inbox"
|
||||
inbox.mkdir()
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
for phase in (
|
||||
"command-transport",
|
||||
):
|
||||
result = self.build(
|
||||
inbox,
|
||||
phase,
|
||||
f"device-edge-vps-{phase}-load-001",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
extraction = Path(directory) / f"extract-{phase}"
|
||||
extraction.mkdir()
|
||||
loaded = RUNNER.load_artifact(artifact, extraction)
|
||||
self.assertEqual(loaded["phase"], phase)
|
||||
self.assertEqual(loaded["entries"], RUNNER.PHASE_ENTRIES[phase])
|
||||
self.assertEqual(
|
||||
loaded["sha256"],
|
||||
hashlib.sha256(artifact.read_bytes()).hexdigest(),
|
||||
)
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
|
||||
def test_plan_is_exact_and_never_claims_dns_or_b2_mutation(self):
|
||||
self.require_runtime_cache()
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-plan-") as directory:
|
||||
inbox = Path(directory) / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"foundation",
|
||||
"device-edge-vps-foundation-plan-001",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
with patch.object(RUNNER, "assert_root"), patch.object(
|
||||
RUNNER,
|
||||
"preflight",
|
||||
return_value={"predecessor": "unit-predecessor"},
|
||||
), patch("builtins.print") as output:
|
||||
RUNNER.plan_artifact(str(artifact))
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
rendered = "\n".join(
|
||||
" ".join(str(arg) for arg in call.args)
|
||||
for call in output.call_args_list
|
||||
)
|
||||
self.assertIn("phase=foundation", rendered)
|
||||
self.assertIn("public_b2_ingress=disabled", rendered)
|
||||
self.assertIn("dns=unchanged", rendered)
|
||||
self.assertIn("b2_routes=unchanged", rendered)
|
||||
self.assertIn("command_transport=disabled", rendered)
|
||||
|
||||
@unittest.skip("accepted core-channel builder generation is frozen")
|
||||
def test_core_channel_plan_is_exact_and_keeps_tracker_ingress_closed(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-channel-plan-") as directory:
|
||||
inbox = Path(directory) / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"core-channel",
|
||||
"device-edge-vps-core-channel-plan-001",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
with patch.object(RUNNER, "assert_root"), patch.object(
|
||||
RUNNER,
|
||||
"preflight",
|
||||
return_value={"predecessor": "accepted-foundation-closed-channel"},
|
||||
), patch("builtins.print") as output:
|
||||
RUNNER.plan_artifact(str(artifact))
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
rendered = "\n".join(
|
||||
" ".join(str(arg) for arg in call.args)
|
||||
for call in output.call_args_list
|
||||
)
|
||||
self.assertIn("phase=core-channel", rendered)
|
||||
self.assertIn(
|
||||
"predecessor=accepted-foundation-closed-channel",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn(
|
||||
"public_core_channel=155.212.211.15:443/tcp:tls13-mtls-h2",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn("tracker_tcp_9921=closed", rendered)
|
||||
self.assertIn("public_b2_ingress=disabled", rendered)
|
||||
self.assertIn(
|
||||
"peer_trust=preprovisioned-pinned-self-signed-core-certificate+fingerprint",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn("command_transport=disabled", rendered)
|
||||
self.assertIn("gelios=untouched", rendered)
|
||||
|
||||
def test_runtime_reconciliation_plan_is_exact_and_opens_no_port(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-reconcile-plan-") as directory:
|
||||
inbox = Path(directory) / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"runtime-reconciliation",
|
||||
"device-edge-vps-runtime-reconciliation-plan-001",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
with patch.object(RUNNER, "assert_root"), patch.object(
|
||||
RUNNER,
|
||||
"preflight",
|
||||
return_value={
|
||||
"predecessor": (
|
||||
"failed-core-channel-001-rollback-runtime-mode-drift"
|
||||
),
|
||||
},
|
||||
), patch("builtins.print") as output:
|
||||
RUNNER.plan_artifact(str(artifact))
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
rendered = "\n".join(
|
||||
" ".join(str(arg) for arg in call.args)
|
||||
for call in output.call_args_list
|
||||
)
|
||||
self.assertIn("phase=runtime-reconciliation", rendered)
|
||||
self.assertIn(
|
||||
"runtime_reconciliation=exact-known-binaries:0644=>0755",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn("public_core_channel=disabled", rendered)
|
||||
self.assertIn("tracker_tcp_9921=closed", rendered)
|
||||
|
||||
def test_tailscale_retirement_plan_preserves_channel_and_opens_no_tracker_port(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-retirement-plan-") as directory:
|
||||
inbox = Path(directory) / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"tailscale-retirement",
|
||||
"device-edge-vps-tailscale-retirement-plan-001",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
with patch.object(RUNNER, "assert_root"), patch.object(
|
||||
RUNNER,
|
||||
"preflight",
|
||||
return_value={
|
||||
"predecessor": "accepted-core-channel-010-with-live-tailnet",
|
||||
},
|
||||
), patch("builtins.print") as output:
|
||||
RUNNER.plan_artifact(str(artifact))
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
rendered = "\n".join(
|
||||
" ".join(str(arg) for arg in call.args)
|
||||
for call in output.call_args_list
|
||||
)
|
||||
self.assertIn("phase=tailscale-retirement", rendered)
|
||||
self.assertIn(
|
||||
"predecessor=accepted-core-channel-010-with-live-tailnet",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn(
|
||||
"public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn(
|
||||
"tailscale=stop+disable+destroy-local-runtime-state",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn("tailscale_socks_1055=removed", rendered)
|
||||
self.assertIn("superseded_backhaul_private_key=removed", rendered)
|
||||
self.assertIn("tracker_tcp_9921=closed", rendered)
|
||||
self.assertIn("external_tailnet_machine_cleanup=required-after-deploy-ok", rendered)
|
||||
self.assertIn("command_transport=disabled", rendered)
|
||||
self.assertIn("gelios=untouched", rendered)
|
||||
|
||||
@unittest.skip("accepted tracker-ingress builder generation is frozen")
|
||||
def test_tracker_ingress_plan_is_single_process_bounded_and_command_free(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-ingress-plan-") as directory:
|
||||
inbox = Path(directory) / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"tracker-ingress",
|
||||
"device-edge-vps-tracker-ingress-plan-001",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
with patch.object(RUNNER, "assert_root"), patch.object(
|
||||
RUNNER,
|
||||
"preflight",
|
||||
return_value={"predecessor": "accepted-tailscale-retirement-011"},
|
||||
), patch("builtins.print") as output:
|
||||
RUNNER.plan_artifact(str(artifact))
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
rendered = "\n".join(
|
||||
" ".join(str(arg) for arg in call.args)
|
||||
for call in output.call_args_list
|
||||
)
|
||||
self.assertIn("phase=tracker-ingress", rendered)
|
||||
self.assertIn("predecessor=accepted-tailscale-retirement-011", rendered)
|
||||
self.assertIn(
|
||||
"public_b2_ingress=155.212.211.15:9921/tcp:telemetry-only",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn("tracker_adapter=arusnavi-b2", rendered)
|
||||
self.assertIn("tracker_ack=after-core-durable-acceptance-only", rendered)
|
||||
self.assertIn("runtime_composition=single-non-root-process", rendered)
|
||||
self.assertIn("tailscale=preserved:absent", rendered)
|
||||
self.assertIn("command_transport=disabled", rendered)
|
||||
self.assertIn("gelios=untouched", rendered)
|
||||
|
||||
def test_command_transport_plan_is_typed_single_process_and_bounded(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-command-plan-") as directory:
|
||||
inbox = Path(directory) / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"command-transport",
|
||||
"device-edge-vps-command-transport-plan-001",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
with patch.object(RUNNER, "assert_root"), patch.object(
|
||||
RUNNER,
|
||||
"preflight",
|
||||
return_value={"predecessor": "accepted-tracker-ingress-012"},
|
||||
), patch("builtins.print") as output:
|
||||
RUNNER.plan_artifact(str(artifact))
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
rendered = "\n".join(
|
||||
" ".join(str(arg) for arg in call.args)
|
||||
for call in output.call_args_list
|
||||
)
|
||||
self.assertIn("phase=command-transport", rendered)
|
||||
self.assertIn("predecessor=accepted-tracker-ingress-012", rendered)
|
||||
self.assertIn("command_transport=typed-service-ping-v1", rendered)
|
||||
self.assertIn("command_catalog=allowlisted-adapter-typed-commands-only", rendered)
|
||||
self.assertIn("runtime_composition=single-non-root-process", rendered)
|
||||
self.assertIn("public_b2_ingress=155.212.211.15:9921/tcp:bidirectional-session", rendered)
|
||||
self.assertIn("gelios=untouched-legacy-only", rendered)
|
||||
|
||||
def test_publish_payload_preserves_unselected_executable_modes(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-publish-scope-") as directory:
|
||||
root = Path(directory)
|
||||
live = root / "live"
|
||||
payload = root / "payload"
|
||||
runtime = live / "runtime/node/bin/node"
|
||||
marker = payload / "deployment/reconciliation.json"
|
||||
runtime.parent.mkdir(parents=True)
|
||||
marker.parent.mkdir(parents=True)
|
||||
runtime.write_bytes(b"runtime-binary")
|
||||
runtime.chmod(0o755)
|
||||
marker.write_text("{}\n", encoding="utf-8")
|
||||
old_live = RUNNER.LIVE_ROOT
|
||||
RUNNER.LIVE_ROOT = live
|
||||
try:
|
||||
with patch.object(RUNNER.os, "chown"):
|
||||
RUNNER.publish_payload(payload, ("deployment/reconciliation.json",))
|
||||
finally:
|
||||
RUNNER.LIVE_ROOT = old_live
|
||||
self.assertEqual(runtime.stat().st_mode & 0o777, 0o755)
|
||||
self.assertEqual(
|
||||
(live / "deployment/reconciliation.json").stat().st_mode & 0o777,
|
||||
0o644,
|
||||
)
|
||||
|
||||
def test_source_baseline_is_pinned_to_the_exact_accepted_predecessor(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-baseline-") as directory:
|
||||
journal = Path(directory) / "applied.jsonl"
|
||||
old_journal = RUNNER.APPLIED_JOURNAL
|
||||
RUNNER.APPLIED_JOURNAL = journal
|
||||
try:
|
||||
journal.write_text(json.dumps({
|
||||
"patch": "device-edge-vps-foundation-20260806-003",
|
||||
"phase": "foundation",
|
||||
"sha256": (
|
||||
"1be852f144e9f0fea32af70bebd07a2607b6a1818825094bd4c1b4062064716a"
|
||||
),
|
||||
"status": "ok",
|
||||
}) + "\n", encoding="utf-8")
|
||||
expected = RUNNER.phase_file_sha256("foundation")
|
||||
self.assertEqual(
|
||||
expected["vps/config/00-nodedc-b2-vps.conf"],
|
||||
"cc94d0579f85d0af9746b9ce760bc72980f4a22fb59027e1f5f9c7bf3aaebd64",
|
||||
)
|
||||
self.assertEqual(
|
||||
expected["vps/config/nftables-foundation.conf"],
|
||||
"4d44f902d8d98d1aa8506fca9d9582e700f6424def2b1d667ab2cd5a5ee84934",
|
||||
)
|
||||
self.assertEqual(
|
||||
expected["deployment/device-edge-vps-foundation-v1.json"],
|
||||
"317c98b42520fff3238275908482de7aa611b4ee41c6b1f8062abd2730ab072a",
|
||||
)
|
||||
|
||||
journal.write_text(json.dumps({
|
||||
"patch": "device-edge-vps-foundation-20260806-003",
|
||||
"phase": "foundation",
|
||||
"sha256": "0" * 64,
|
||||
"status": "ok",
|
||||
}) + "\n", encoding="utf-8")
|
||||
unexpected = RUNNER.phase_file_sha256("foundation")
|
||||
self.assertEqual(
|
||||
unexpected["vps/config/00-nodedc-b2-vps.conf"],
|
||||
RUNNER.PHASE_FILE_SHA256[
|
||||
"foundation"
|
||||
]["vps/config/00-nodedc-b2-vps.conf"],
|
||||
)
|
||||
finally:
|
||||
RUNNER.APPLIED_JOURNAL = old_journal
|
||||
|
||||
def test_units_and_firewalls_keep_the_required_boundaries(self):
|
||||
source_root = SCRIPT_DIR.parents[1]
|
||||
foundation = (source_root / "vps/config/nftables-foundation.conf").read_text()
|
||||
relay = (source_root / "vps/config/nftables-relay.conf").read_text()
|
||||
channel = (source_root / "vps/config/nftables-core-channel.conf").read_text()
|
||||
tracker = (source_root / "vps/config/nftables-tracker-ingress.conf").read_text()
|
||||
sshd = (source_root / "vps/config/00-nodedc-b2-vps.conf").read_text()
|
||||
backhaul = (source_root / "vps/config/backhaul_ssh_config").read_text()
|
||||
tailscale_unit = (
|
||||
source_root / "vps/systemd/nodedc-b2-tailscaled.service"
|
||||
).read_text()
|
||||
relay_unit = (source_root / "vps/systemd/nodedc-b2-relay.service").read_text()
|
||||
backhaul_unit = (
|
||||
source_root / "vps/systemd/nodedc-b2-backhaul.service"
|
||||
).read_text()
|
||||
channel_unit = (
|
||||
source_root / "vps/systemd/nodedc-device-edge-channel.service"
|
||||
).read_text()
|
||||
tracker_unit = (
|
||||
source_root / "vps/systemd/nodedc-device-edge-runtime.service"
|
||||
).read_text()
|
||||
|
||||
self.assertIn("policy drop", foundation)
|
||||
self.assertIn("tcp dport 22", foundation)
|
||||
self.assertNotIn("tcp dport 9921", foundation)
|
||||
self.assertIn("tcp dport 9921", relay)
|
||||
self.assertIn("tcp dport 443", channel)
|
||||
self.assertNotIn("tcp dport 9921", channel)
|
||||
self.assertIn("tcp dport 443", tracker)
|
||||
self.assertIn("tcp dport 9921", tracker)
|
||||
self.assertIn("PasswordAuthentication no", sshd)
|
||||
self.assertIn("AllowTcpForwarding no", sshd)
|
||||
self.assertIn("StrictHostKeyChecking yes", backhaul)
|
||||
self.assertIn("ProxyCommand /usr/bin/nc -X 5 -x 127.0.0.1:1055", backhaul)
|
||||
self.assertIn("AF_NETLINK", tailscale_unit)
|
||||
self.assertIn("User=nodedc-edge", tailscale_unit)
|
||||
self.assertIn("StateDirectoryMode=0700", tailscale_unit)
|
||||
self.assertIn("User=nodedc-backhaul", backhaul_unit)
|
||||
self.assertNotIn("User=nodedc-edge", backhaul_unit)
|
||||
self.assertIn("User=nodedc-relay", relay_unit)
|
||||
self.assertNotIn("User=nodedc-edge", relay_unit)
|
||||
self.assertIn("DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only", relay_unit)
|
||||
self.assertIn("MemoryMax=192M", relay_unit)
|
||||
self.assertIn("User=nodedc-channel", channel_unit)
|
||||
self.assertIn(
|
||||
"ExecStart=/opt/nodedc-b2-vps/runtime/node/bin/node "
|
||||
"/opt/nodedc-b2-vps/services/device-edge-channel/src/server.mjs",
|
||||
channel_unit,
|
||||
)
|
||||
self.assertIn("MemoryDenyWriteExecute=no", channel_unit)
|
||||
self.assertIn(
|
||||
"CapabilityBoundingSet=CAP_NET_BIND_SERVICE",
|
||||
channel_unit,
|
||||
)
|
||||
self.assertIn(
|
||||
"AmbientCapabilities=CAP_NET_BIND_SERVICE",
|
||||
channel_unit,
|
||||
)
|
||||
self.assertNotIn("--jitless", channel_unit)
|
||||
self.assertIn("MemoryMax=128M", channel_unit)
|
||||
self.assertIn("MemorySwapMax=0", channel_unit)
|
||||
self.assertIn("CPUQuota=50%", channel_unit)
|
||||
self.assertIn("TasksMax=64", channel_unit)
|
||||
self.assertIn("LimitNOFILE=1024", channel_unit)
|
||||
self.assertNotIn("LocalForward", channel_unit)
|
||||
self.assertNotIn("DEVICE_EDGE_RELAY_UPSTREAM", channel_unit)
|
||||
self.assertIn("User=nodedc-channel", tracker_unit)
|
||||
self.assertIn("vps/edge-process/device-edge-runtime.mjs", tracker_unit)
|
||||
self.assertIn("DEVICE_GATEWAY_TCP_PORT=9921", tracker_unit)
|
||||
self.assertIn("DEVICE_GATEWAY_MAX_SESSIONS=128", tracker_unit)
|
||||
self.assertIn("MemoryMax=192M", tracker_unit)
|
||||
self.assertIn("MemorySwapMax=0", tracker_unit)
|
||||
self.assertIn("CPUQuota=75%", tracker_unit)
|
||||
self.assertIn("TasksMax=128", tracker_unit)
|
||||
self.assertIn("LimitNOFILE=1024", tracker_unit)
|
||||
self.assertNotIn("LocalForward", tracker_unit)
|
||||
self.assertNotIn("DEVICE_EDGE_RELAY_UPSTREAM", tracker_unit)
|
||||
|
||||
def test_runner_has_registered_rollback_and_no_generic_latest(self):
|
||||
source = RUNNER_PATH.read_text(encoding="utf-8")
|
||||
self.assertIn("def rollback(", source)
|
||||
self.assertIn('TAILSCALE_REQUIRED_TAG = "tag:device-edge-vps"', source)
|
||||
self.assertIn("assign_backhaul_trust", source)
|
||||
self.assertIn("deploy-ok patch=", source)
|
||||
self.assertNotIn("apply-latest", source)
|
||||
self.assertNotIn("compose down", source)
|
||||
self.assertNotIn("docker system prune", source)
|
||||
|
||||
def test_executable_preflight_accepts_a_valid_alternatives_symlink(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-tool-") as directory:
|
||||
root = Path(directory)
|
||||
target = root / "netcat.openbsd"
|
||||
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
target.chmod(0o755)
|
||||
command = root / "nc"
|
||||
command.symlink_to(target.name)
|
||||
|
||||
self.assertEqual(
|
||||
RUNNER.assert_executable_command_path(command, "test command"),
|
||||
target.resolve(),
|
||||
)
|
||||
|
||||
def test_executable_preflight_rejects_a_broken_symlink(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-tool-") as directory:
|
||||
command = Path(directory) / "nc"
|
||||
command.symlink_to("missing-netcat")
|
||||
with self.assertRaises(RUNNER.DeployError):
|
||||
RUNNER.assert_executable_command_path(command, "test command")
|
||||
|
||||
def test_backup_restore_preserves_the_exact_relay_partition(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-backup-") as directory:
|
||||
root = Path(directory)
|
||||
live = root / "live"
|
||||
backups = root / "backups"
|
||||
nft = root / "etc/nftables.conf"
|
||||
relay_unit = root / "etc/nodedc-b2-relay.service"
|
||||
backups.mkdir()
|
||||
nft.parent.mkdir(parents=True)
|
||||
nft.write_text("foundation-firewall\n", encoding="utf-8")
|
||||
relay_unit.write_text("old-unit\n", encoding="utf-8")
|
||||
for relative in RUNNER.RELAY_ENTRIES:
|
||||
target = live / relative
|
||||
if relative.endswith("/src"):
|
||||
target.mkdir(parents=True)
|
||||
(target / "server.mjs").write_text("old-source\n", encoding="utf-8")
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(f"old:{relative}\n", encoding="utf-8")
|
||||
|
||||
old_live = RUNNER.LIVE_ROOT
|
||||
old_backups = RUNNER.BACKUP_ROOT
|
||||
old_nft = RUNNER.NFTABLES_CONFIG
|
||||
old_relay_unit = RUNNER.RELAY_UNIT
|
||||
RUNNER.LIVE_ROOT = live
|
||||
RUNNER.BACKUP_ROOT = backups
|
||||
RUNNER.NFTABLES_CONFIG = nft
|
||||
RUNNER.RELAY_UNIT = relay_unit
|
||||
completed = subprocess.CompletedProcess([], 0, "table inet old {}\n", "")
|
||||
try:
|
||||
with patch.object(RUNNER, "run", return_value=completed), patch.object(
|
||||
RUNNER,
|
||||
"service_active",
|
||||
return_value=False,
|
||||
), patch.object(
|
||||
RUNNER,
|
||||
"systemctl",
|
||||
return_value=completed,
|
||||
), patch.object(
|
||||
RUNNER,
|
||||
"user_exists",
|
||||
return_value=False,
|
||||
):
|
||||
_backup_id, backup = RUNNER.create_backup("relay-unit", "relay")
|
||||
nft.write_text("candidate-firewall\n", encoding="utf-8")
|
||||
relay_unit.write_text("candidate-unit\n", encoding="utf-8")
|
||||
(live / "services/device-edge-relay/src/server.mjs").write_text(
|
||||
"candidate-source\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
RUNNER.restore_backup(backup, "relay")
|
||||
finally:
|
||||
RUNNER.LIVE_ROOT = old_live
|
||||
RUNNER.BACKUP_ROOT = old_backups
|
||||
RUNNER.NFTABLES_CONFIG = old_nft
|
||||
RUNNER.RELAY_UNIT = old_relay_unit
|
||||
|
||||
self.assertEqual(nft.read_text(), "foundation-firewall\n")
|
||||
self.assertEqual(relay_unit.read_text(), "old-unit\n")
|
||||
self.assertEqual(
|
||||
(live / "services/device-edge-relay/src/server.mjs").read_text(),
|
||||
"old-source\n",
|
||||
)
|
||||
|
||||
def test_backup_restore_preserves_core_channel_source_trust_and_firewall(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-channel-backup-") as directory:
|
||||
root = Path(directory)
|
||||
live = root / "live"
|
||||
backups = root / "backups"
|
||||
nft = root / "etc/nftables.conf"
|
||||
channel_unit = root / "etc/nodedc-device-edge-channel.service"
|
||||
trust = root / "state/channel-trust"
|
||||
backups.mkdir()
|
||||
nft.parent.mkdir(parents=True)
|
||||
trust.mkdir(parents=True)
|
||||
nft.write_text("foundation-firewall\n", encoding="utf-8")
|
||||
channel_unit.parent.mkdir(parents=True, exist_ok=True)
|
||||
channel_unit.write_text("old-channel-unit\n", encoding="utf-8")
|
||||
(trust / "runtime.json").write_text("old-runtime\n", encoding="utf-8")
|
||||
for relative in RUNNER.CORE_CHANNEL_ENTRIES:
|
||||
target = live / relative
|
||||
if relative.endswith("/src"):
|
||||
target.mkdir(parents=True)
|
||||
(target / "server.mjs").write_text("old-channel-source\n", encoding="utf-8")
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(f"old:{relative}\n", encoding="utf-8")
|
||||
|
||||
old_live = RUNNER.LIVE_ROOT
|
||||
old_backups = RUNNER.BACKUP_ROOT
|
||||
old_nft = RUNNER.NFTABLES_CONFIG
|
||||
old_unit = RUNNER.CHANNEL_UNIT
|
||||
old_trust = RUNNER.CHANNEL_TRUST_ROOT
|
||||
RUNNER.LIVE_ROOT = live
|
||||
RUNNER.BACKUP_ROOT = backups
|
||||
RUNNER.NFTABLES_CONFIG = nft
|
||||
RUNNER.CHANNEL_UNIT = channel_unit
|
||||
RUNNER.CHANNEL_TRUST_ROOT = trust
|
||||
completed = subprocess.CompletedProcess([], 0, "table inet old {}\n", "")
|
||||
try:
|
||||
with patch.object(RUNNER, "run", return_value=completed), patch.object(
|
||||
RUNNER,
|
||||
"service_active",
|
||||
return_value=False,
|
||||
), patch.object(
|
||||
RUNNER,
|
||||
"systemctl",
|
||||
return_value=completed,
|
||||
), patch.object(
|
||||
RUNNER,
|
||||
"user_exists",
|
||||
return_value=False,
|
||||
):
|
||||
_backup_id, backup = RUNNER.create_backup(
|
||||
"channel-unit",
|
||||
"core-channel",
|
||||
)
|
||||
nft.write_text("candidate-firewall\n", encoding="utf-8")
|
||||
channel_unit.write_text("candidate-channel-unit\n", encoding="utf-8")
|
||||
(trust / "runtime.json").write_text("candidate-runtime\n", encoding="utf-8")
|
||||
(live / "services/device-edge-channel/src/server.mjs").write_text(
|
||||
"candidate-channel-source\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
RUNNER.restore_backup(backup, "core-channel")
|
||||
finally:
|
||||
RUNNER.LIVE_ROOT = old_live
|
||||
RUNNER.BACKUP_ROOT = old_backups
|
||||
RUNNER.NFTABLES_CONFIG = old_nft
|
||||
RUNNER.CHANNEL_UNIT = old_unit
|
||||
RUNNER.CHANNEL_TRUST_ROOT = old_trust
|
||||
|
||||
self.assertEqual(nft.read_text(), "foundation-firewall\n")
|
||||
self.assertEqual(channel_unit.read_text(), "old-channel-unit\n")
|
||||
self.assertEqual((trust / "runtime.json").read_text(), "old-runtime\n")
|
||||
self.assertEqual(
|
||||
(live / "services/device-edge-channel/src/server.mjs").read_text(),
|
||||
"old-channel-source\n",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
RUNNER_PATH = Path(os.environ.get("NODEDC_DEPLOY_RUNNER", SCRIPT_DIR.parents[2] / "platform" / "infra" / "deploy-runner" / "nodedc-deploy"))
|
||||
BUILDER = (
|
||||
SCRIPT_DIR
|
||||
/ "build-device-manager-control-plane-reconciliation-artifact.mjs"
|
||||
)
|
||||
V2_BUILDER = (
|
||||
SCRIPT_DIR
|
||||
/ "build-device-manager-control-plane-v2-reconciliation-artifact.mjs"
|
||||
)
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_manager_reconciliation_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DeviceManagerControlPlaneReconciliationArtifactTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, patch_id, builder=BUILDER):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(builder), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_artifact_is_marker_only_exact_and_deterministic(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-manager-reconciliation-artifact-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
first = self.build(
|
||||
artifact_dir,
|
||||
"device-manager-control-plane-reconciliation-unit-002",
|
||||
)
|
||||
first_bytes = Path(first["artifact"]).read_bytes()
|
||||
second = self.build(
|
||||
artifact_dir,
|
||||
"device-manager-control-plane-reconciliation-unit-002",
|
||||
)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(first["component"], "device-plane")
|
||||
self.assertEqual(
|
||||
first["entries"],
|
||||
list(RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_ENTRIES),
|
||||
)
|
||||
self.assertEqual(first["build"], [])
|
||||
self.assertEqual(first["services"], [])
|
||||
self.assertEqual(
|
||||
first["runtimeAction"],
|
||||
"read-only-acceptance",
|
||||
)
|
||||
|
||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||
names = [member.name for member in archive.getmembers()]
|
||||
descriptor = json.loads(
|
||||
archive.extractfile(
|
||||
"payload/"
|
||||
+ RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_REL
|
||||
).read().decode("utf-8")
|
||||
)
|
||||
self.assertEqual(
|
||||
descriptor,
|
||||
RUNNER.expected_device_plane_manager_reconciliation_descriptor(),
|
||||
)
|
||||
self.assertEqual(
|
||||
names,
|
||||
[
|
||||
"manifest.env",
|
||||
"files.txt",
|
||||
"payload",
|
||||
"payload/deployment",
|
||||
"payload/"
|
||||
+ RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_REL,
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_services(
|
||||
"device-plane",
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_ENTRIES,
|
||||
),
|
||||
(),
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_builds(
|
||||
"device-plane",
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_ENTRIES,
|
||||
),
|
||||
(),
|
||||
)
|
||||
|
||||
def test_failed_control_plane_is_terminal(self):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"exact v2 activation successor",
|
||||
):
|
||||
RUNNER.reject_terminal_device_plane_manager_artifact(
|
||||
{"id": RUNNER.DEVICE_PLANE_MANAGER_FAILED_PATCH_ID},
|
||||
"0" * 64,
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"exact v2 activation successor",
|
||||
):
|
||||
RUNNER.reject_terminal_device_plane_manager_artifact(
|
||||
{"id": "different"},
|
||||
RUNNER.DEVICE_PLANE_MANAGER_FAILED_ARTIFACT_SHA256,
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"exact v2 activation successor",
|
||||
):
|
||||
RUNNER.reject_terminal_device_plane_manager_artifact(
|
||||
{"id": "different", "component": "device-plane"},
|
||||
"1" * 64,
|
||||
RUNNER.DEVICE_PLANE_MANAGER_FAILED_CONTROL_PLANE_ENTRIES,
|
||||
)
|
||||
|
||||
def test_v2_reconciliation_is_marker_only_exact_and_deterministic(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-manager-v2-reconciliation-artifact-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
first = self.build(
|
||||
artifact_dir,
|
||||
"device-manager-control-plane-v2-reconciliation-unit-004",
|
||||
V2_BUILDER,
|
||||
)
|
||||
first_bytes = Path(first["artifact"]).read_bytes()
|
||||
second = self.build(
|
||||
artifact_dir,
|
||||
"device-manager-control-plane-v2-reconciliation-unit-004",
|
||||
V2_BUILDER,
|
||||
)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(
|
||||
first["entries"],
|
||||
list(RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_ENTRIES),
|
||||
)
|
||||
self.assertEqual(first["build"], [])
|
||||
self.assertEqual(first["services"], [])
|
||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||
descriptor = json.loads(
|
||||
archive.extractfile(
|
||||
"payload/"
|
||||
+ RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_REL
|
||||
).read().decode("utf-8")
|
||||
)
|
||||
self.assertEqual(
|
||||
descriptor,
|
||||
RUNNER.expected_device_plane_manager_v2_reconciliation_descriptor(),
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_services(
|
||||
"device-plane",
|
||||
RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_ENTRIES,
|
||||
),
|
||||
(),
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_builds(
|
||||
"device-plane",
|
||||
RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_ENTRIES,
|
||||
),
|
||||
(),
|
||||
)
|
||||
|
||||
def test_failed_v2_control_plane_is_terminal(self):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"exact v2 reconciliation successor",
|
||||
):
|
||||
RUNNER.reject_terminal_device_plane_manager_artifact(
|
||||
{"id": RUNNER.DEVICE_PLANE_MANAGER_V2_FAILED_PATCH_ID},
|
||||
"0" * 64,
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"exact v2 reconciliation successor",
|
||||
):
|
||||
RUNNER.reject_terminal_device_plane_manager_artifact(
|
||||
{"id": "different"},
|
||||
RUNNER.DEVICE_PLANE_MANAGER_V2_FAILED_ARTIFACT_SHA256,
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"exact v2 reconciliation successor",
|
||||
):
|
||||
RUNNER.reject_terminal_device_plane_manager_artifact(
|
||||
{"id": "different", "component": "device-plane"},
|
||||
"1" * 64,
|
||||
RUNNER.DEVICE_PLANE_MANAGER_V2_CONTROL_PLANE_ENTRIES,
|
||||
)
|
||||
|
||||
def test_health_grace_waits_through_unhealthy_and_exited(self):
|
||||
results = [
|
||||
mock.Mock(stdout="unhealthy\n", stderr="", returncode=0),
|
||||
mock.Mock(stdout="exited\n", stderr="", returncode=0),
|
||||
mock.Mock(stdout="starting\n", stderr="", returncode=0),
|
||||
mock.Mock(stdout="healthy\n", stderr="", returncode=0),
|
||||
]
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER.subprocess,
|
||||
"run",
|
||||
side_effect=results,
|
||||
) as inspect,
|
||||
mock.patch.object(RUNNER.time, "sleep") as sleep,
|
||||
):
|
||||
RUNNER.healthcheck_container_with_grace("container-id")
|
||||
self.assertEqual(inspect.call_count, 4)
|
||||
self.assertEqual(sleep.call_count, 3)
|
||||
|
||||
def test_manager_rollback_uses_bounded_grace_for_restored_core(self):
|
||||
entries = RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES
|
||||
runtime_before = {
|
||||
"schemaVersion": "nodedc.device-plane.runtime-inventory.v1",
|
||||
"composeProject": "nodedc-device-plane",
|
||||
"services": [
|
||||
{
|
||||
"service": "device-control-core",
|
||||
"containerId": "a" * 64,
|
||||
"imageId": "sha256:" + "b" * 64,
|
||||
"status": "running",
|
||||
"running": True,
|
||||
"health": "healthy",
|
||||
"restartCount": 0,
|
||||
},
|
||||
{
|
||||
"service": "device-gateway",
|
||||
"containerId": "c" * 64,
|
||||
"imageId": "sha256:" + "d" * 64,
|
||||
"status": "running",
|
||||
"running": True,
|
||||
"health": "healthy",
|
||||
"restartCount": 0,
|
||||
},
|
||||
{
|
||||
"service": "device-postgres",
|
||||
"containerId": "e" * 64,
|
||||
"imageId": "sha256:" + "f" * 64,
|
||||
"status": "running",
|
||||
"running": True,
|
||||
"health": "healthy",
|
||||
"restartCount": 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
missing_set = {
|
||||
RUNNER.DEVICE_PLANE_MANAGER_COMPOSE_REL,
|
||||
"services/device-manager",
|
||||
RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL,
|
||||
}
|
||||
existing = [entry for entry in entries if entry not in missing_set]
|
||||
missing = [entry for entry in entries if entry in missing_set]
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"read_backup_path_list",
|
||||
side_effect=[existing, missing],
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"read_strict_json",
|
||||
return_value=runtime_before,
|
||||
),
|
||||
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 runtime,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service_with_grace",
|
||||
) as health,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"component_healthchecks",
|
||||
return_value=("core",),
|
||||
),
|
||||
mock.patch.object(RUNNER, "healthcheck_url") as url_health,
|
||||
mock.patch.object(RUNNER, "run_healthchecks") as generic_health,
|
||||
):
|
||||
restored = RUNNER.rollback_device_plane_apply(
|
||||
Path("/live"),
|
||||
Path("/backup"),
|
||||
entries,
|
||||
"stamp",
|
||||
True,
|
||||
("device-control-core", "device-manager"),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
restored,
|
||||
f"source+runtime-restored:{len(entries)}",
|
||||
)
|
||||
stop.assert_called_once_with("device-plane", ("device-manager",))
|
||||
runtime.assert_called_once_with(
|
||||
"device-plane",
|
||||
existing,
|
||||
("device-control-core",),
|
||||
)
|
||||
health.assert_called_once_with(
|
||||
"device-plane",
|
||||
"device-control-core",
|
||||
)
|
||||
url_health.assert_called_once_with("core")
|
||||
generic_health.assert_not_called()
|
||||
|
||||
def test_reconciliation_runtime_phase_is_read_only(self):
|
||||
for entries in (
|
||||
RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_ENTRIES,
|
||||
RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_ENTRIES,
|
||||
):
|
||||
with (
|
||||
mock.patch.object(RUNNER, "run_build") as build,
|
||||
mock.patch.object(RUNNER, "prepare_component_runtime") as prepare,
|
||||
mock.patch.object(RUNNER, "run_compose") as compose,
|
||||
):
|
||||
RUNNER.run_component_runtime("device-plane", entries, ())
|
||||
RUNNER.run_device_plane_runtime_for_apply(
|
||||
entries,
|
||||
(),
|
||||
mock.Mock(),
|
||||
)
|
||||
build.assert_not_called()
|
||||
prepare.assert_not_called()
|
||||
compose.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = SCRIPT_DIR / "build-device-plane-artifact.mjs"
|
||||
RUNNER_PATH = Path(os.environ.get("NODEDC_DEPLOY_RUNNER", SCRIPT_DIR.parents[2] / "platform" / "infra" / "deploy-runner" / "nodedc-deploy"))
|
||||
EXPECTED_ENTRIES = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
]
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_artifact_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlaneArtifactTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_artifact_is_narrow_safe_and_deterministic(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-artifact-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
first = self.build(
|
||||
artifact_dir,
|
||||
"device-plane-foundation-unit-001",
|
||||
)
|
||||
artifact = Path(first["artifact"])
|
||||
first_bytes = artifact.read_bytes()
|
||||
second = self.build(
|
||||
artifact_dir,
|
||||
"device-plane-foundation-unit-001",
|
||||
)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first["component"], "device-plane")
|
||||
self.assertEqual(first["entries"], EXPECTED_ENTRIES)
|
||||
self.assertEqual(
|
||||
first["services"],
|
||||
["device-control-core", "device-gateway"],
|
||||
)
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(first["sha256"], second["sha256"])
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
|
||||
with tarfile.open(artifact, "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
names = {member.name for member in members}
|
||||
files = (
|
||||
archive.extractfile("files.txt")
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
.splitlines()
|
||||
)
|
||||
manifest = (
|
||||
archive.extractfile("manifest.env")
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
compose = (
|
||||
archive.extractfile(
|
||||
"payload/docker-compose.device-plane.yml",
|
||||
)
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
regular_payloads = [
|
||||
archive.extractfile(member).read()
|
||||
for member in members
|
||||
if member.isfile()
|
||||
]
|
||||
|
||||
self.assertEqual(files, EXPECTED_ENTRIES)
|
||||
self.assertEqual(
|
||||
manifest,
|
||||
"id=device-plane-foundation-unit-001\n"
|
||||
"component=device-plane\n"
|
||||
"type=app-overlay\n",
|
||||
)
|
||||
self.assertIn(
|
||||
"payload/services/device-control-core/Dockerfile",
|
||||
names,
|
||||
)
|
||||
self.assertIn(
|
||||
"payload/services/device-gateway/Dockerfile",
|
||||
names,
|
||||
)
|
||||
self.assertFalse(any(
|
||||
"/test/" in name
|
||||
or "/node_modules/" in name
|
||||
or Path(name).name.startswith(".env")
|
||||
or name.startswith("payload/docs/")
|
||||
or name.startswith("payload/runtime/")
|
||||
or name.startswith("payload/secrets/")
|
||||
for name in names
|
||||
))
|
||||
self.assertNotIn("9921:9921", compose)
|
||||
self.assertIn('DEVICE_GATEWAY_LISTEN_ENABLED: "false"', compose)
|
||||
self.assertIn('DEVICE_DISCOVERY_INGEST_ENABLED: "false"', compose)
|
||||
self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", b"\n".join(
|
||||
regular_payloads,
|
||||
))
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-runner-load-",
|
||||
) as work_directory:
|
||||
manifest_loaded, entries_loaded, payload_loaded = (
|
||||
RUNNER.load_artifact(artifact, Path(work_directory))
|
||||
)
|
||||
self.assertEqual(manifest_loaded["component"], "device-plane")
|
||||
self.assertEqual(entries_loaded, EXPECTED_ENTRIES)
|
||||
self.assertEqual(payload_loaded.name, "payload")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,481 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = (
|
||||
SCRIPT_DIR / "build-device-plane-b2-discovery-ingress-artifact.mjs"
|
||||
)
|
||||
RUNNER_PATH = Path(os.environ.get("NODEDC_DEPLOY_RUNNER", SCRIPT_DIR.parents[2] / "platform" / "infra" / "deploy-runner" / "nodedc-deploy"))
|
||||
COMPOSE = (
|
||||
SCRIPT_DIR
|
||||
/ "fixtures/device-plane-b2-discovery-ingress-v1.yml"
|
||||
)
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_b2_ingress_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlaneB2DiscoveryIngressArtifactTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_artifact_is_exact_deterministic_and_database_free(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-b2-ingress-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
patch_id = "device-plane-b2-discovery-ingress-unit-001"
|
||||
first = self.build(artifact_dir, patch_id)
|
||||
first_bytes = Path(first["artifact"]).read_bytes()
|
||||
second = self.build(artifact_dir, patch_id)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(
|
||||
first["transition"],
|
||||
"verified-b2-loopback-discovery-only",
|
||||
)
|
||||
self.assertEqual(
|
||||
first["services"],
|
||||
["device-control-core", "device-gateway"],
|
||||
)
|
||||
self.assertNotIn("device-postgres", first["services"])
|
||||
self.assertEqual(
|
||||
first["entries"],
|
||||
list(RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES),
|
||||
)
|
||||
|
||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||
files = (
|
||||
archive.extractfile("files.txt")
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
.splitlines()
|
||||
)
|
||||
compose = archive.extractfile(
|
||||
"payload/docker-compose.device-plane.yml"
|
||||
).read()
|
||||
descriptor = json.loads(
|
||||
archive.extractfile(
|
||||
"payload/deployment/"
|
||||
"device-plane-b2-discovery-ingress-v1.json"
|
||||
)
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
edge_manifest = json.loads(
|
||||
archive.extractfile(
|
||||
"payload/services/device-edge-relay/package.json"
|
||||
)
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
dockerignore = archive.extractfile(
|
||||
"payload/.dockerignore"
|
||||
).read().decode("utf-8")
|
||||
core_dockerfile = archive.extractfile(
|
||||
"payload/services/device-control-core/Dockerfile"
|
||||
).read().decode("utf-8")
|
||||
gateway_dockerfile = archive.extractfile(
|
||||
"payload/services/device-gateway/Dockerfile"
|
||||
).read().decode("utf-8")
|
||||
|
||||
self.assertEqual(files, first["entries"])
|
||||
self.assertEqual(
|
||||
hashlib.sha256(compose).hexdigest(),
|
||||
RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_COMPOSE_SHA256,
|
||||
)
|
||||
self.assertEqual(
|
||||
descriptor,
|
||||
RUNNER.expected_device_plane_b2_discovery_ingress_descriptor(),
|
||||
)
|
||||
self.assertEqual(
|
||||
edge_manifest["name"],
|
||||
"@nodedc/device-edge-relay",
|
||||
)
|
||||
self.assertIn("**/*.prev-*", dockerignore.splitlines())
|
||||
self.assertIn("**/*.next-*", dockerignore.splitlines())
|
||||
for dockerfile in (core_dockerfile, gateway_dockerfile):
|
||||
self.assertNotIn("COPY packages ./packages", dockerfile)
|
||||
self.assertIn(
|
||||
"COPY packages/device-protocol-contract "
|
||||
"./packages/device-protocol-contract",
|
||||
dockerfile,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"COPY packages/arusnavi-b2-adapter",
|
||||
core_dockerfile,
|
||||
)
|
||||
self.assertIn(
|
||||
"COPY packages/arusnavi-b2-adapter "
|
||||
"./packages/arusnavi-b2-adapter",
|
||||
gateway_dockerfile,
|
||||
)
|
||||
|
||||
def test_compose_opens_only_discovery_tcp_and_preserves_database(self):
|
||||
compose = COMPOSE.read_text(encoding="utf-8")
|
||||
postgres, stateless = compose.split(" device-control-core:", 1)
|
||||
self.assertNotIn("9921", postgres)
|
||||
self.assertNotIn("device-plane-control", postgres)
|
||||
self.assertIn(
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
|
||||
stateless,
|
||||
)
|
||||
self.assertIn(
|
||||
'DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"',
|
||||
stateless,
|
||||
)
|
||||
self.assertIn('"127.0.0.1:9921:9921"', stateless)
|
||||
self.assertNotIn('"0.0.0.0:9921:9921"', stateless)
|
||||
self.assertNotIn("DEVICE_GATEWAY_COMMAND", compose)
|
||||
self.assertNotIn("POSTGRES_PASSWORD:", compose)
|
||||
|
||||
def test_runner_builds_only_stateless_services_and_accepts_new_health(self):
|
||||
entries = RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES
|
||||
self.assertEqual(
|
||||
RUNNER.component_services("device-plane", entries),
|
||||
("device-control-core", "device-gateway"),
|
||||
)
|
||||
builds = RUNNER.component_builds("device-plane", entries)
|
||||
self.assertEqual(len(builds), 2)
|
||||
checks = RUNNER.component_healthchecks(
|
||||
"device-plane",
|
||||
entries,
|
||||
("device-control-core", "device-gateway"),
|
||||
)
|
||||
self.assertEqual(
|
||||
checks[0]["expected_json"]["discoveryIngest"],
|
||||
"enabled",
|
||||
)
|
||||
self.assertEqual(
|
||||
checks[1]["expected_json"]["publicIngress"],
|
||||
"disabled",
|
||||
)
|
||||
self.assertEqual(
|
||||
checks[1]["expected_json"]["commandTransport"],
|
||||
"disabled",
|
||||
)
|
||||
|
||||
def test_ingress_preflight_requires_terminal_recovery_marker(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-b2-recovery-barrier-",
|
||||
) as directory:
|
||||
workspace = Path(directory)
|
||||
built = self.build(
|
||||
workspace,
|
||||
"device-plane-b2-discovery-ingress-unit-recovery-barrier",
|
||||
)
|
||||
extracted = workspace / "extracted"
|
||||
extracted.mkdir()
|
||||
_manifest, _entries, payload = RUNNER.load_artifact(
|
||||
Path(built["artifact"]),
|
||||
extracted,
|
||||
)
|
||||
|
||||
def has_patch(patch_id):
|
||||
return (
|
||||
patch_id
|
||||
== RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_PATCH_ID
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"state_has_patch_id",
|
||||
side_effect=has_patch,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"state_has_sha",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"rollback recovery patch is not applied",
|
||||
):
|
||||
RUNNER.validate_device_plane_b2_discovery_ingress_evidence(
|
||||
payload
|
||||
)
|
||||
|
||||
def test_runtime_acceptance_preserves_postgres_and_replaces_stateless(self):
|
||||
before = {
|
||||
"schemaVersion": "nodedc.device-plane.runtime-inventory.v1",
|
||||
"composeProject": "nodedc-device-plane",
|
||||
"services": [
|
||||
runtime_item("device-control-core", "1", "a"),
|
||||
runtime_item("device-gateway", "2", "b"),
|
||||
runtime_item("device-postgres", "3", "c"),
|
||||
],
|
||||
}
|
||||
containers = {
|
||||
"core": stateless_container(
|
||||
service="device-control-core",
|
||||
container_id="4" * 64,
|
||||
image_id="sha256:" + "d" * 64,
|
||||
image=RUNNER.DEVICE_PLANE_CONTROL_CORE_IMAGE,
|
||||
ports={
|
||||
"18120/tcp": [{
|
||||
"HostIp": "127.0.0.1",
|
||||
"HostPort": "18120",
|
||||
}],
|
||||
},
|
||||
environment={
|
||||
"DEVICE_DISCOVERY_INGEST_ENABLED": "true",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN_FILE":
|
||||
"/run/nodedc-secrets/gateway-core-token",
|
||||
"DEVICE_IDENTIFIER_PEPPER_FILE":
|
||||
"/run/nodedc-secrets/identifier-pepper",
|
||||
},
|
||||
mounts=[
|
||||
secret_mount(
|
||||
RUNNER.DEVICE_PLANE_POSTGRES_PASSWORD_FILE,
|
||||
"/run/nodedc-secrets/postgres-password",
|
||||
),
|
||||
secret_mount(
|
||||
RUNNER.DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE,
|
||||
"/run/nodedc-secrets/gateway-core-token",
|
||||
),
|
||||
secret_mount(
|
||||
RUNNER.DEVICE_PLANE_IDENTIFIER_PEPPER_FILE,
|
||||
"/run/nodedc-secrets/identifier-pepper",
|
||||
),
|
||||
],
|
||||
),
|
||||
"gateway": stateless_container(
|
||||
service="device-gateway",
|
||||
container_id="5" * 64,
|
||||
image_id="sha256:" + "e" * 64,
|
||||
image=RUNNER.DEVICE_PLANE_GATEWAY_IMAGE,
|
||||
ports={
|
||||
"18121/tcp": [{
|
||||
"HostIp": "127.0.0.1",
|
||||
"HostPort": "18121",
|
||||
}],
|
||||
"9921/tcp": [{
|
||||
"HostIp": "127.0.0.1",
|
||||
"HostPort": "9921",
|
||||
}],
|
||||
},
|
||||
environment={
|
||||
"DEVICE_GATEWAY_LISTEN_ENABLED": "true",
|
||||
"DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED": "false",
|
||||
"DEVICE_GATEWAY_TCP_HOST": "127.0.0.1",
|
||||
"DEVICE_GATEWAY_TCP_PORT": "9921",
|
||||
"DEVICE_GATEWAY_CORE_URL":
|
||||
"http://device-control-core:18120",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN_FILE":
|
||||
"/run/nodedc-secrets/gateway-core-token",
|
||||
"DEVICE_GATEWAY_CORE_TIMEOUT_MS": "5000",
|
||||
"DEVICE_GATEWAY_MAX_BUFFERED_BYTES": "65536",
|
||||
"DEVICE_GATEWAY_MAX_SESSIONS": "100",
|
||||
"DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS": "10",
|
||||
"DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS":
|
||||
"30",
|
||||
"DEVICE_GATEWAY_SESSION_TIMEOUT_MS": "10000",
|
||||
},
|
||||
mounts=[
|
||||
secret_mount(
|
||||
RUNNER.DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE,
|
||||
"/run/nodedc-secrets/gateway-core-token",
|
||||
),
|
||||
],
|
||||
),
|
||||
"postgres": postgres_container(),
|
||||
}
|
||||
service_ids = {
|
||||
"device-control-core": ("core",),
|
||||
"device-gateway": ("gateway",),
|
||||
"device-postgres": ("postgres",),
|
||||
}
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_runtime_secret_metadata",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_plane_service_container_ids",
|
||||
side_effect=lambda service: service_ids[service],
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"inspect_device_plane_container",
|
||||
side_effect=lambda container_id: containers[container_id],
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_network_contract",
|
||||
) as network,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"assert_loopback_tcp_port_open",
|
||||
) as port_open,
|
||||
):
|
||||
accepted = (
|
||||
RUNNER.validate_device_plane_b2_discovery_ingress_runtime(
|
||||
before
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
accepted["device-postgres"]["containerId"],
|
||||
"3" * 64,
|
||||
)
|
||||
self.assertEqual(network.call_count, 2)
|
||||
port_open.assert_called_once_with(9921)
|
||||
|
||||
|
||||
def runtime_item(service, container_digit, image_digit):
|
||||
return {
|
||||
"service": service,
|
||||
"containerId": container_digit * 64,
|
||||
"imageId": "sha256:" + image_digit * 64,
|
||||
"status": "running",
|
||||
"running": True,
|
||||
"health": "healthy",
|
||||
"restartCount": 0,
|
||||
}
|
||||
|
||||
|
||||
def secret_mount(source, destination):
|
||||
return {
|
||||
"Type": "bind",
|
||||
"Source": str(source),
|
||||
"Destination": destination,
|
||||
"RW": False,
|
||||
}
|
||||
|
||||
|
||||
def stateless_container(
|
||||
*,
|
||||
service,
|
||||
container_id,
|
||||
image_id,
|
||||
image,
|
||||
ports,
|
||||
environment,
|
||||
mounts,
|
||||
):
|
||||
return {
|
||||
"Id": container_id,
|
||||
"Image": image_id,
|
||||
"RestartCount": 0,
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": True,
|
||||
"Restarting": False,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"Health": {"Status": "healthy"},
|
||||
},
|
||||
"Config": {
|
||||
"Image": image,
|
||||
"User": "1000:1000",
|
||||
"Labels": {
|
||||
"com.docker.compose.project": "nodedc-device-plane",
|
||||
"com.docker.compose.service": service,
|
||||
},
|
||||
"Env": [f"{key}={value}" for key, value in environment.items()],
|
||||
},
|
||||
"HostConfig": {
|
||||
"PortBindings": ports,
|
||||
"RestartPolicy": {"Name": "unless-stopped"},
|
||||
"ReadonlyRootfs": True,
|
||||
"CapDrop": ["ALL"],
|
||||
"SecurityOpt": ["no-new-privileges:true"],
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Ports": ports,
|
||||
"Networks": {
|
||||
RUNNER.DEVICE_PLANE_PRIVATE_NETWORK: {},
|
||||
RUNNER.DEVICE_PLANE_CONTROL_NETWORK: {},
|
||||
},
|
||||
},
|
||||
"Mounts": mounts,
|
||||
}
|
||||
|
||||
|
||||
def postgres_container():
|
||||
return {
|
||||
"Id": "3" * 64,
|
||||
"Image": "sha256:" + "c" * 64,
|
||||
"RestartCount": 0,
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": True,
|
||||
"Restarting": False,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"Health": {"Status": "healthy"},
|
||||
},
|
||||
"Config": {
|
||||
"Image": "postgres:16-alpine",
|
||||
"User": "",
|
||||
"Labels": {
|
||||
"com.docker.compose.project": "nodedc-device-plane",
|
||||
"com.docker.compose.service": "device-postgres",
|
||||
},
|
||||
"Env": [],
|
||||
},
|
||||
"HostConfig": {
|
||||
"PortBindings": {},
|
||||
"RestartPolicy": {"Name": "unless-stopped"},
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Ports": {},
|
||||
"Networks": {
|
||||
RUNNER.DEVICE_PLANE_PRIVATE_NETWORK: {},
|
||||
},
|
||||
},
|
||||
"Mounts": [
|
||||
secret_mount(
|
||||
RUNNER.DEVICE_PLANE_POSTGRES_PASSWORD_FILE,
|
||||
"/run/nodedc-secrets/postgres-password",
|
||||
),
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": RUNNER.DEVICE_PLANE_POSTGRES_VOLUME,
|
||||
"Destination": "/var/lib/postgresql/data",
|
||||
"RW": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = (
|
||||
SCRIPT_DIR
|
||||
/ "build-device-plane-b2-discovery-loopback-recovery-artifact.mjs"
|
||||
)
|
||||
RUNNER_PATH = Path(os.environ.get("NODEDC_DEPLOY_RUNNER", SCRIPT_DIR.parents[2] / "platform" / "infra" / "deploy-runner" / "nodedc-deploy"))
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_b2_recovery_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlaneB2DiscoveryLoopbackRecoveryArtifactTest(
|
||||
unittest.TestCase,
|
||||
):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_artifact_is_marker_only_exact_and_deterministic(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-b2-recovery-artifact-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
patch_id = RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_PATCH_ID
|
||||
first = self.build(artifact_dir, patch_id)
|
||||
first_bytes = Path(first["artifact"]).read_bytes()
|
||||
second = self.build(artifact_dir, patch_id)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(first["build"], [])
|
||||
self.assertEqual(first["services"], [])
|
||||
self.assertEqual(
|
||||
first["entries"],
|
||||
list(
|
||||
RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_ENTRIES
|
||||
),
|
||||
)
|
||||
|
||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||
names = {
|
||||
member.name
|
||||
for member in archive.getmembers()
|
||||
if member.isfile()
|
||||
}
|
||||
descriptor = json.loads(
|
||||
archive.extractfile(
|
||||
"payload/"
|
||||
+ RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_REL
|
||||
)
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
self.assertEqual(
|
||||
names,
|
||||
{
|
||||
"manifest.env",
|
||||
"files.txt",
|
||||
"payload/"
|
||||
+ RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_REL,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
descriptor,
|
||||
RUNNER.expected_device_plane_b2_discovery_rollback_recovery_descriptor(),
|
||||
)
|
||||
entries = (
|
||||
RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_ENTRIES
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_services("device-plane", entries),
|
||||
(),
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_builds("device-plane", entries),
|
||||
(),
|
||||
)
|
||||
|
||||
def test_recovery_preflight_requires_exact_failed_evidence(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-b2-recovery-preflight-",
|
||||
) as directory:
|
||||
workspace = Path(directory)
|
||||
artifacts = workspace / "artifacts"
|
||||
failed_root = workspace / "failed"
|
||||
backups_root = workspace / "backups"
|
||||
state_root = workspace / "state"
|
||||
temp_root = workspace / "tmp"
|
||||
live_root = workspace / "live"
|
||||
for path in (
|
||||
artifacts,
|
||||
failed_root,
|
||||
backups_root,
|
||||
state_root,
|
||||
temp_root,
|
||||
live_root,
|
||||
):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recovery = self.build(
|
||||
artifacts,
|
||||
RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_PATCH_ID,
|
||||
)
|
||||
extracted = workspace / "extracted"
|
||||
extracted.mkdir()
|
||||
_manifest, entries, payload = RUNNER.load_artifact(
|
||||
Path(recovery["artifact"]),
|
||||
extracted,
|
||||
)
|
||||
self.assertEqual(
|
||||
tuple(entries),
|
||||
RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_ENTRIES,
|
||||
)
|
||||
|
||||
failed_artifact = (
|
||||
failed_root / RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ARTIFACT
|
||||
)
|
||||
failed_artifact.write_bytes(b"failed-b2-artifact-fixture\n")
|
||||
failed_sha = RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ARTIFACT_SHA256
|
||||
|
||||
backup = (
|
||||
backups_root
|
||||
/ RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_BACKUP_ID
|
||||
)
|
||||
backup.mkdir()
|
||||
backup_names = (
|
||||
"manifest.env",
|
||||
"files.txt",
|
||||
"existing-files.txt",
|
||||
"missing-files.txt",
|
||||
"runtime-before.json",
|
||||
"source-before.tgz",
|
||||
)
|
||||
for name in backup_names:
|
||||
(backup / name).write_text(
|
||||
f"fixture:{name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
backup_hashes = {
|
||||
name: hashlib.sha256((backup / name).read_bytes()).hexdigest()
|
||||
for name in backup_names
|
||||
}
|
||||
|
||||
(state_root / "failed.jsonl").write_text(
|
||||
json.dumps({
|
||||
"artifact": RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ARTIFACT,
|
||||
"backup_id": (
|
||||
RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_BACKUP_ID
|
||||
),
|
||||
"component": "device-plane",
|
||||
"id": RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_PATCH_ID,
|
||||
"message": (
|
||||
"Command '['/usr/local/bin/docker', 'build', "
|
||||
"'--no-cache', '--network=host', '-f', "
|
||||
"'services/device-control-core/Dockerfile', '-t', "
|
||||
"'nodedc/device-control-core:local', '.']' returned "
|
||||
"non-zero exit status 1."
|
||||
),
|
||||
"rollback_status": "failed:CalledProcessError",
|
||||
"sha256": failed_sha,
|
||||
"started_apply": True,
|
||||
"status": "failed",
|
||||
})
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runtime = {"accepted": True}
|
||||
failed_manifest = {
|
||||
"id": RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_PATCH_ID,
|
||||
"component": "device-plane",
|
||||
"type": "app-overlay",
|
||||
}
|
||||
with (
|
||||
mock.patch.object(RUNNER, "BACKUPS_DIR", backups_root),
|
||||
mock.patch.object(RUNNER, "FAILED_DIR", failed_root),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"FAILED_STATE_FILE",
|
||||
state_root / "failed.jsonl",
|
||||
),
|
||||
mock.patch.object(RUNNER, "TMP_DIR", temp_root),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"component_root",
|
||||
return_value=live_root,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_BACKUP_SHA256",
|
||||
backup_hashes,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"sha256_file",
|
||||
side_effect=lambda path: (
|
||||
failed_sha
|
||||
if Path(path) == failed_artifact
|
||||
else hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"load_artifact",
|
||||
return_value=(
|
||||
failed_manifest,
|
||||
list(RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ENTRIES),
|
||||
workspace / "unused-payload",
|
||||
),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_foundation_network_publication_installed_source",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_foundation_runtime",
|
||||
return_value=runtime,
|
||||
),
|
||||
mock.patch.object(RUNNER, "assert_loopback_tcp_port_closed"),
|
||||
):
|
||||
evidence = (
|
||||
RUNNER.validate_device_plane_b2_discovery_rollback_recovery_evidence(
|
||||
payload
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
evidence["mode"],
|
||||
"failed-b2-loopback-build-reconciliation",
|
||||
)
|
||||
self.assertEqual(evidence["runtime"], runtime)
|
||||
|
||||
def test_build_failure_does_not_mark_runtime_started(self):
|
||||
marker = mock.Mock()
|
||||
entries = RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES
|
||||
services = ("device-control-core", "device-gateway")
|
||||
failure = subprocess.CalledProcessError(1, ["docker", "build"])
|
||||
with (
|
||||
mock.patch.object(RUNNER, "run_build", side_effect=failure),
|
||||
mock.patch.object(RUNNER, "prepare_component_runtime") as prepare,
|
||||
mock.patch.object(RUNNER, "run_compose") as compose,
|
||||
):
|
||||
with self.assertRaises(subprocess.CalledProcessError):
|
||||
RUNNER.run_device_plane_runtime_for_apply(
|
||||
entries,
|
||||
services,
|
||||
marker,
|
||||
)
|
||||
marker.assert_not_called()
|
||||
prepare.assert_not_called()
|
||||
compose.assert_not_called()
|
||||
|
||||
def test_compose_failure_is_marked_after_build_and_prepare(self):
|
||||
events = []
|
||||
|
||||
def mark():
|
||||
events.append("mark")
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"run_build",
|
||||
side_effect=lambda *_args: events.append("build"),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"prepare_component_runtime",
|
||||
side_effect=lambda *_args: events.append("prepare"),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"run_compose",
|
||||
side_effect=RuntimeError("compose failed"),
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "compose failed"):
|
||||
RUNNER.run_device_plane_runtime_for_apply(
|
||||
RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES,
|
||||
("device-control-core", "device-gateway"),
|
||||
mark,
|
||||
)
|
||||
self.assertEqual(events, ["build", "prepare", "mark"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,650 @@
|
||||
#!/usr/bin/env python3
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = SCRIPT_DIR / "build-device-plane-backhaul-target-artifact.mjs"
|
||||
RUNNER_PATH = Path(os.environ.get("NODEDC_DEPLOY_RUNNER", SCRIPT_DIR.parents[2] / "platform" / "infra" / "deploy-runner" / "nodedc-deploy"))
|
||||
COMPOSE = (
|
||||
SCRIPT_DIR.parent.parent
|
||||
/ "docker-compose.device-plane.backhaul-target.yml"
|
||||
)
|
||||
PREDECESSOR_COMPOSE = (
|
||||
SCRIPT_DIR
|
||||
/ "fixtures/device-plane-b2-discovery-ingress-v1.yml"
|
||||
)
|
||||
SSHD_CONFIG = (
|
||||
SCRIPT_DIR.parent.parent
|
||||
/ "services/device-backhaul-target/sshd_config"
|
||||
)
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_backhaul_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
def valid_public_key(comment="nodedc-device-edge-backhaul"):
|
||||
blob = b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20" + bytes(range(32))
|
||||
return f"ssh-ed25519 {base64.b64encode(blob).decode()} {comment}\n"
|
||||
|
||||
|
||||
def healthy_inventory():
|
||||
return {
|
||||
"schemaVersion": "nodedc.device-plane.runtime-inventory.v1",
|
||||
"composeProject": "nodedc-device-plane",
|
||||
"services": [
|
||||
{
|
||||
"service": service,
|
||||
"containerId": character * 64,
|
||||
"imageId": f"sha256:{character * 64}",
|
||||
"status": "running",
|
||||
"running": True,
|
||||
"health": "healthy",
|
||||
"restartCount": 0,
|
||||
}
|
||||
for service, character in (
|
||||
("device-control-core", "a"),
|
||||
("device-gateway", "b"),
|
||||
("device-postgres", "c"),
|
||||
)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class DevicePlaneBackhaulTargetArtifactTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_artifact_is_exact_deterministic_and_contains_no_keys(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-backhaul-artifact-",
|
||||
) as directory:
|
||||
target = Path(directory)
|
||||
first = self.build(target, "device-plane-backhaul-target-unit-001")
|
||||
first_bytes = Path(first["artifact"]).read_bytes()
|
||||
second = self.build(target, "device-plane-backhaul-target-unit-001")
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(first["sha256"], hashlib.sha256(first_bytes).hexdigest())
|
||||
self.assertEqual(
|
||||
first["entries"],
|
||||
list(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES),
|
||||
)
|
||||
self.assertEqual(first["services"], ["device-backhaul-target"])
|
||||
|
||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||
names = archive.getnames()
|
||||
files = archive.extractfile("files.txt").read().decode().splitlines()
|
||||
descriptor = json.loads(
|
||||
archive.extractfile(
|
||||
"payload/deployment/"
|
||||
"device-plane-backhaul-target-tailnet-serve-v1.json"
|
||||
).read()
|
||||
)
|
||||
self.assertEqual(files, first["entries"])
|
||||
self.assertEqual(
|
||||
descriptor,
|
||||
RUNNER.expected_device_plane_backhaul_target_descriptor(),
|
||||
)
|
||||
self.assertFalse(any(
|
||||
name.endswith((".key", ".pem", "authorized_keys"))
|
||||
for name in names
|
||||
))
|
||||
|
||||
def test_registry_selects_only_target_and_preserves_red_boundaries(self):
|
||||
entries = RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES
|
||||
self.assertEqual(
|
||||
RUNNER.component_services("device-plane", entries),
|
||||
("device-backhaul-target",),
|
||||
)
|
||||
builds = RUNNER.component_builds("device-plane", entries)
|
||||
self.assertEqual(len(builds), 1)
|
||||
self.assertIn("services/device-backhaul-target/Dockerfile", builds[0][1])
|
||||
self.assertIn(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_IMAGE, builds[0][1])
|
||||
for path in (
|
||||
RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL,
|
||||
"services/device-backhaul-target/Dockerfile",
|
||||
"services/device-backhaul-target/sshd_config",
|
||||
RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_REL,
|
||||
):
|
||||
self.assertTrue(RUNNER.allowed_payload_path("device-plane", path))
|
||||
|
||||
compose = COMPOSE.read_text(encoding="utf-8")
|
||||
sshd = SSHD_CONFIG.read_text(encoding="utf-8")
|
||||
self.assertIn("network_mode: host", compose)
|
||||
self.assertNotIn("0.0.0.0:2222", compose)
|
||||
self.assertIn('"127.0.0.1", "2222"', compose)
|
||||
self.assertIn("ListenAddress 127.0.0.1", sshd)
|
||||
self.assertIn("AllowTcpForwarding local", sshd)
|
||||
self.assertIn("PermitOpen 127.0.0.1:9921", sshd)
|
||||
self.assertIn("ForceCommand /bin/false", sshd)
|
||||
self.assertIn("PasswordAuthentication no", sshd)
|
||||
|
||||
def test_preflight_requires_exact_applied_006_and_enrollment_key(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-backhaul-preflight-",
|
||||
) as directory:
|
||||
work = Path(directory)
|
||||
built = self.build(work, "device-plane-backhaul-target-unit-002")
|
||||
extracted = work / "extracted"
|
||||
extracted.mkdir()
|
||||
_manifest, _entries, payload = RUNNER.load_artifact(
|
||||
Path(built["artifact"]),
|
||||
extracted,
|
||||
)
|
||||
live = work / "live"
|
||||
live.mkdir()
|
||||
(live / "docker-compose.device-plane.yml").write_bytes(
|
||||
PREDECESSOR_COMPOSE.read_bytes()
|
||||
)
|
||||
descriptor = live / RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL
|
||||
descriptor.parent.mkdir(parents=True)
|
||||
descriptor.write_text(
|
||||
json.dumps(RUNNER.expected_device_plane_b2_discovery_ingress_descriptor()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
enrollment = work / "device-edge-backhaul.pub"
|
||||
enrollment.write_text(valid_public_key(), encoding="ascii")
|
||||
|
||||
def has_patch(value):
|
||||
return value == RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_PREDECESSOR_PATCH_ID
|
||||
|
||||
def has_sha(value):
|
||||
return value == RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_PREDECESSOR_ARTIFACT_SHA256
|
||||
|
||||
with (
|
||||
mock.patch.object(RUNNER, "component_root", return_value=live),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_BACKHAUL_ENROLLMENT_PUBLIC_KEY_FILE",
|
||||
enrollment,
|
||||
),
|
||||
mock.patch.object(RUNNER, "state_has_patch_id", side_effect=has_patch),
|
||||
mock.patch.object(RUNNER, "state_has_sha", side_effect=has_sha),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_plane_service_container_ids",
|
||||
return_value=[],
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_plane_runtime_inventory",
|
||||
return_value=healthy_inventory(),
|
||||
),
|
||||
mock.patch.object(RUNNER, "assert_loopback_tcp_port_open"),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_backhaul_failed_evidence",
|
||||
return_value={
|
||||
"backup": work / "failed-backup",
|
||||
"failedArtifact": work / "failed-artifact.tgz",
|
||||
},
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_tailscale_cli",
|
||||
return_value={
|
||||
"binary": str(RUNNER.DEVICE_PLANE_TAILSCALE),
|
||||
"uid": 1024,
|
||||
"gid": 1024,
|
||||
"binarySha256": "d" * 64,
|
||||
},
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_tailscale_runtime",
|
||||
return_value={
|
||||
"self": {
|
||||
"Online": True,
|
||||
"TailscaleIPs": ["100.109.216.21"],
|
||||
},
|
||||
"serve": {},
|
||||
},
|
||||
),
|
||||
):
|
||||
accepted = RUNNER.validate_device_plane_backhaul_target_evidence(payload)
|
||||
self.assertEqual(
|
||||
accepted["mode"],
|
||||
"failed-backhaul-target-to-loopback-tailnet-serve",
|
||||
)
|
||||
self.assertRegex(accepted["enrollmentPublicKeySha256"], r"^[a-f0-9]{64}$")
|
||||
self.assertEqual(accepted["tailscaleServeBefore"], {})
|
||||
self.assertEqual(accepted["tailscaleCli"]["uid"], 1024)
|
||||
|
||||
def test_registered_health_gate_checks_preserved_and_target_services(self):
|
||||
with mock.patch.object(RUNNER, "healthcheck_compose_service") as health:
|
||||
RUNNER.run_healthchecks(
|
||||
"device-plane",
|
||||
RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES,
|
||||
(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,),
|
||||
)
|
||||
self.assertEqual(
|
||||
[call.args[1] for call in health.call_args_list],
|
||||
[
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target",
|
||||
],
|
||||
)
|
||||
|
||||
def test_candidate_rollback_removes_only_target_and_preserves_runtime(self):
|
||||
runtime = healthy_inventory()
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"read_backup_path_list",
|
||||
side_effect=[[], list(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES)],
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_backup_partition",
|
||||
return_value=(set(), set(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES)),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"read_strict_json",
|
||||
side_effect=[runtime, {}],
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"disable_device_plane_tailscale_serve",
|
||||
) as disable_serve,
|
||||
mock.patch.object(RUNNER, "stop_and_remove_compose_services") as stop,
|
||||
mock.patch.object(RUNNER, "restore_platform_overlay", return_value=3),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_plane_service_container_ids",
|
||||
return_value=[],
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_plane_runtime_inventory",
|
||||
return_value=runtime,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_tailscale_runtime",
|
||||
return_value={"serve": {}},
|
||||
),
|
||||
mock.patch.object(RUNNER, "assert_loopback_tcp_port_open") as port,
|
||||
):
|
||||
result = RUNNER.rollback_device_plane_apply(
|
||||
Path("/unused/live"),
|
||||
Path("/unused/backup"),
|
||||
RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES,
|
||||
"20260803-000000",
|
||||
True,
|
||||
(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,),
|
||||
)
|
||||
self.assertEqual(
|
||||
result,
|
||||
"tailscale-serve-restored-source-restored-target-removed-"
|
||||
"preserved-runtime-unchanged:3",
|
||||
)
|
||||
stop.assert_called_once_with(
|
||||
"device-plane",
|
||||
(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,),
|
||||
)
|
||||
disable_serve.assert_called_once_with({})
|
||||
port.assert_called_once_with(9921)
|
||||
|
||||
def test_tailscale_serve_port_parser_rejects_funnel_and_nested_collision(self):
|
||||
clean = {"TCP": {"443": {"HTTPS": True}}}
|
||||
self.assertEqual(
|
||||
RUNNER.device_plane_tailscale_handlers_for_port(clean, 2222),
|
||||
[],
|
||||
)
|
||||
active = {
|
||||
"TCP": {"2222": {"TCPForward": "127.0.0.1:2222"}},
|
||||
"Foreground": {
|
||||
"session": {
|
||||
"TCP": {"443": {"HTTPS": True}},
|
||||
},
|
||||
},
|
||||
}
|
||||
self.assertEqual(
|
||||
RUNNER.device_plane_tailscale_handlers_for_port(active, 2222),
|
||||
[((), {"TCPForward": "127.0.0.1:2222"})],
|
||||
)
|
||||
self.assertFalse(
|
||||
RUNNER.device_plane_tailscale_funnel_uses_port(active, 2222)
|
||||
)
|
||||
active["AllowFunnel"] = {"edge.example.ts.net:2222": True}
|
||||
self.assertTrue(
|
||||
RUNNER.device_plane_tailscale_funnel_uses_port(active, 2222)
|
||||
)
|
||||
|
||||
def test_runtime_activation_enables_private_tailscale_serve_after_health(self):
|
||||
calls = []
|
||||
with (
|
||||
mock.patch.object(RUNNER, "run_build", side_effect=lambda *a: calls.append("build")),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"prepare_component_runtime",
|
||||
side_effect=lambda *a: calls.append("prepare"),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"run_compose",
|
||||
side_effect=lambda *a: calls.append("compose"),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service",
|
||||
side_effect=lambda *a: calls.append("health"),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"enable_device_plane_tailscale_serve",
|
||||
side_effect=lambda *a: calls.append("serve"),
|
||||
) as enable,
|
||||
):
|
||||
RUNNER.run_device_plane_runtime_for_apply(
|
||||
RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES,
|
||||
(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,),
|
||||
lambda: calls.append("started"),
|
||||
backhaul_serve_before={"TCP": {"443": {"HTTPS": True}}},
|
||||
)
|
||||
self.assertEqual(
|
||||
calls,
|
||||
["build", "prepare", "started", "compose", "health", "serve"],
|
||||
)
|
||||
enable.assert_called_once_with({"TCP": {"443": {"HTTPS": True}}})
|
||||
|
||||
def test_tailscale_serve_enable_and_disable_preserve_unrelated_routes(self):
|
||||
before = {"TCP": {"443": {"HTTPS": True}}}
|
||||
active = {
|
||||
"TCP": {
|
||||
"443": {"HTTPS": True},
|
||||
"2222": {"TCPForward": "127.0.0.1:2222"},
|
||||
},
|
||||
}
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_tailscale_runtime",
|
||||
side_effect=[{"serve": before}, {"serve": active}],
|
||||
),
|
||||
mock.patch.object(RUNNER, "run_device_plane_tailscale") as run,
|
||||
):
|
||||
result = RUNNER.enable_device_plane_tailscale_serve(before)
|
||||
self.assertEqual(result, active)
|
||||
self.assertEqual(
|
||||
run.call_args.args[0],
|
||||
[
|
||||
"serve",
|
||||
"--bg",
|
||||
"--yes",
|
||||
"--tcp=2222",
|
||||
"tcp://127.0.0.1:2222",
|
||||
],
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"read_device_plane_tailscale_json",
|
||||
return_value=active,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_tailscale_runtime",
|
||||
return_value={"serve": before},
|
||||
),
|
||||
mock.patch.object(RUNNER, "run_device_plane_tailscale") as run,
|
||||
):
|
||||
changed = RUNNER.disable_device_plane_tailscale_serve(before)
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(
|
||||
run.call_args.args[0],
|
||||
[
|
||||
"serve",
|
||||
"--tcp=2222",
|
||||
"off",
|
||||
],
|
||||
)
|
||||
|
||||
def test_tailscale_cli_runs_as_official_package_account(self):
|
||||
context = {
|
||||
"binary": "/var/packages/Tailscale/target/bin/tailscale",
|
||||
"uid": 1051,
|
||||
"gid": 1051,
|
||||
"binarySha256": "e" * 64,
|
||||
}
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_tailscale_cli",
|
||||
return_value=context,
|
||||
),
|
||||
mock.patch.object(RUNNER.subprocess, "run") as run,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_plane_tailscale_drop_privileges",
|
||||
return_value="drop-to-package-account",
|
||||
) as drop,
|
||||
):
|
||||
RUNNER.run_device_plane_tailscale(
|
||||
["status", "--json"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
run.call_args.args[0],
|
||||
[context["binary"], "status", "--json"],
|
||||
)
|
||||
self.assertEqual(
|
||||
run.call_args.kwargs["preexec_fn"],
|
||||
"drop-to-package-account",
|
||||
)
|
||||
drop.assert_called_once_with(1051, 1051)
|
||||
|
||||
def test_tailscale_cli_accepts_package_owned_binary_without_root_execution(self):
|
||||
privilege_path = mock.MagicMock()
|
||||
privilege_path.__str__.return_value = (
|
||||
"/var/packages/Tailscale/conf/privilege"
|
||||
)
|
||||
privilege_path.lstat.return_value = SimpleNamespace(
|
||||
st_mode=RUNNER.stat.S_IFREG | 0o644,
|
||||
st_uid=0,
|
||||
)
|
||||
binary_path = mock.MagicMock()
|
||||
binary_path.__str__.return_value = (
|
||||
"/var/packages/Tailscale/target/bin/tailscale"
|
||||
)
|
||||
binary_path.lstat.return_value = SimpleNamespace(
|
||||
st_mode=RUNNER.stat.S_IFREG | 0o755,
|
||||
st_uid=1051,
|
||||
st_gid=1051,
|
||||
st_size=32 * 1024 * 1024,
|
||||
)
|
||||
account = SimpleNamespace(pw_uid=1051, pw_gid=1051)
|
||||
group = SimpleNamespace(gr_gid=1051)
|
||||
help_result = SimpleNamespace(
|
||||
stdout="--tcp --bg --yes",
|
||||
stderr="",
|
||||
)
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_TAILSCALE_PRIVILEGE",
|
||||
privilege_path,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_TAILSCALE",
|
||||
binary_path,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"read_strict_json",
|
||||
return_value={
|
||||
"defaults": {"run-as": "package"},
|
||||
"username": "tailscale",
|
||||
"groupname": "tailscale",
|
||||
},
|
||||
),
|
||||
mock.patch.object(RUNNER.pwd, "getpwnam", return_value=account),
|
||||
mock.patch.object(RUNNER.grp, "getgrnam", return_value=group),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"sha256_file",
|
||||
return_value="f" * 64,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER.subprocess,
|
||||
"run",
|
||||
return_value=help_result,
|
||||
) as run,
|
||||
):
|
||||
context = RUNNER.validate_device_plane_tailscale_cli()
|
||||
self.assertEqual(context["uid"], 1051)
|
||||
self.assertEqual(context["gid"], 1051)
|
||||
self.assertEqual(context["binarySha256"], "f" * 64)
|
||||
self.assertTrue(callable(run.call_args.kwargs["preexec_fn"]))
|
||||
|
||||
def test_failed_001_evidence_is_exact_and_terminal(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-backhaul-failed-evidence-",
|
||||
) as directory:
|
||||
root = Path(directory)
|
||||
backups = root / "backups"
|
||||
failed = root / "failed"
|
||||
state = root / "state"
|
||||
tmp = root / "tmp"
|
||||
for path in (backups, failed, state, tmp):
|
||||
path.mkdir()
|
||||
|
||||
backup = backups / RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_BACKUP_ID
|
||||
backup.mkdir()
|
||||
backup_hashes = {}
|
||||
for name in (
|
||||
"manifest.env",
|
||||
"files.txt",
|
||||
"source-before.tgz",
|
||||
"existing-files.txt",
|
||||
"missing-files.txt",
|
||||
"runtime-before.json",
|
||||
):
|
||||
payload = f"fixture:{name}\n".encode()
|
||||
(backup / name).write_bytes(payload)
|
||||
backup_hashes[name] = hashlib.sha256(payload).hexdigest()
|
||||
|
||||
stage = root / "failed-stage"
|
||||
payload = stage / "payload"
|
||||
service = payload / "services/device-backhaul-target"
|
||||
deployment = payload / "deployment"
|
||||
service.mkdir(parents=True)
|
||||
deployment.mkdir(parents=True)
|
||||
(stage / "manifest.env").write_text(
|
||||
"id=device-plane-backhaul-target-20260803-001\n"
|
||||
"component=device-plane\n"
|
||||
"type=app-overlay\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(stage / "files.txt").write_text(
|
||||
"\n".join(RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_TARGET_ENTRIES)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(payload / RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL).write_text(
|
||||
"services: {}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(service / "Dockerfile").write_text(
|
||||
"FROM scratch\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(service / "sshd_config").write_text(
|
||||
"PasswordAuthentication no\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(payload / RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_TARGET_REL).write_text(
|
||||
json.dumps(
|
||||
RUNNER.expected_failed_device_plane_backhaul_target_descriptor()
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
failed_artifact = failed / RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_ARTIFACT
|
||||
with tarfile.open(failed_artifact, "w:gz") as archive:
|
||||
for name in ("manifest.env", "files.txt", "payload"):
|
||||
archive.add(stage / name, arcname=name)
|
||||
failed_sha = hashlib.sha256(failed_artifact.read_bytes()).hexdigest()
|
||||
|
||||
failed_state = state / "failed.jsonl"
|
||||
failed_state.write_text(
|
||||
json.dumps({
|
||||
"artifact": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_ARTIFACT,
|
||||
"backup_id": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_BACKUP_ID,
|
||||
"component": "device-plane",
|
||||
"id": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_PATCH_ID,
|
||||
"message": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_MESSAGE,
|
||||
"rollback_status": (
|
||||
"ok:device-plane-overlay:source-restored-target-removed-"
|
||||
"preserved-runtime-unchanged:3"
|
||||
),
|
||||
"sha256": failed_sha,
|
||||
"started_apply": True,
|
||||
"status": "failed",
|
||||
})
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(RUNNER, "BACKUPS_DIR", backups),
|
||||
mock.patch.object(RUNNER, "FAILED_DIR", failed),
|
||||
mock.patch.object(RUNNER, "FAILED_STATE_FILE", failed_state),
|
||||
mock.patch.object(RUNNER, "TMP_DIR", tmp),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_BACKHAUL_FAILED_BACKUP_SHA256",
|
||||
backup_hashes,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_BACKHAUL_FAILED_ARTIFACT_SHA256",
|
||||
failed_sha,
|
||||
),
|
||||
):
|
||||
evidence = RUNNER.validate_device_plane_backhaul_failed_evidence()
|
||||
self.assertEqual(evidence["backup"], backup)
|
||||
self.assertEqual(evidence["failedArtifact"], failed_artifact)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = (
|
||||
SCRIPT_DIR / "build-device-plane-backhaul-vps-enrollment-artifact.mjs"
|
||||
)
|
||||
RUNNER_PATH = Path(os.environ.get("NODEDC_DEPLOY_RUNNER", SCRIPT_DIR.parents[2] / "platform" / "infra" / "deploy-runner" / "nodedc-deploy"))
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_vps_enrollment_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlaneBackhaulVpsEnrollmentArtifactTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
environment["NODEDC_ALLOW_SUPERSEDED_TRANSPORT"] = "test-only"
|
||||
return subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
|
||||
def test_builder_fails_closed_without_test_only_reconstruction(self):
|
||||
environment = os.environ.copy()
|
||||
environment.pop("NODEDC_ALLOW_SUPERSEDED_TRANSPORT", None)
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-vps-enrollment-frozen-",
|
||||
) as directory:
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = directory
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER), "device-plane-vps-enrollment-frozen-001"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn(
|
||||
"vps_initiated_transport_frozen:ADR-0001",
|
||||
result.stderr,
|
||||
)
|
||||
|
||||
def test_runner_rejects_a_prebuilt_superseded_enrollment_artifact(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-vps-enrollment-frozen-runner-",
|
||||
) as directory:
|
||||
root = Path(directory)
|
||||
result = self.build(
|
||||
root,
|
||||
"device-plane-vps-enrollment-frozen-runner-001",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
extracted = root / "extracted"
|
||||
extracted.mkdir()
|
||||
with self.assertRaises(RUNNER.DeployError) as raised:
|
||||
RUNNER.load_artifact(artifact, extracted)
|
||||
self.assertIn(
|
||||
"vps_initiated_transport_frozen:ADR-0001",
|
||||
str(raised.exception),
|
||||
)
|
||||
|
||||
def test_artifact_is_deterministic_marker_only_and_secret_free(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-vps-enrollment-artifact-",
|
||||
) as directory:
|
||||
root = Path(directory)
|
||||
first = self.build(root, "device-plane-vps-enrollment-unit-001")
|
||||
self.assertEqual(first.returncode, 0, first.stderr)
|
||||
first_result = json.loads(first.stdout)
|
||||
first_bytes = Path(first_result["artifact"]).read_bytes()
|
||||
second = self.build(root, "device-plane-vps-enrollment-unit-001")
|
||||
self.assertEqual(second.returncode, 0, second.stderr)
|
||||
second_result = json.loads(second.stdout)
|
||||
second_bytes = Path(second_result["artifact"]).read_bytes()
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(
|
||||
first_result["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(
|
||||
first_result["entries"],
|
||||
list(RUNNER.DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_ENTRIES),
|
||||
)
|
||||
with tarfile.open(first_result["artifact"], "r:gz") as archive:
|
||||
names = archive.getnames()
|
||||
payload = b"\n".join(
|
||||
archive.extractfile(member).read()
|
||||
for member in archive.getmembers()
|
||||
if member.isfile()
|
||||
)
|
||||
self.assertEqual(
|
||||
set(names),
|
||||
{
|
||||
"manifest.env",
|
||||
"files.txt",
|
||||
"payload",
|
||||
"payload/deployment",
|
||||
"payload/deployment/device-plane-backhaul-vps-enrollment-v1.json",
|
||||
},
|
||||
)
|
||||
self.assertNotIn(b"PRIVATE KEY", payload)
|
||||
self.assertNotIn(b"authorized_keys", payload)
|
||||
|
||||
def test_registry_selects_only_existing_target_without_build(self):
|
||||
entries = RUNNER.DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_ENTRIES
|
||||
self.assertTrue(
|
||||
RUNNER.is_device_plane_backhaul_vps_enrollment_slice(
|
||||
"device-plane",
|
||||
entries,
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_services("device-plane", entries),
|
||||
(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,),
|
||||
)
|
||||
self.assertEqual(RUNNER.component_builds("device-plane", entries), ())
|
||||
|
||||
def test_vps_public_key_is_pinned_by_computed_fingerprint(self):
|
||||
blob = (
|
||||
b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20"
|
||||
+ bytes(range(32))
|
||||
)
|
||||
key = (
|
||||
"ssh-ed25519 "
|
||||
+ base64.b64encode(blob).decode("ascii")
|
||||
+ " source-comment\n"
|
||||
)
|
||||
fingerprint = (
|
||||
"SHA256:"
|
||||
+ base64.b64encode(hashlib.sha256(blob).digest())
|
||||
.decode("ascii")
|
||||
.rstrip("=")
|
||||
)
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-vps-enrollment-key-",
|
||||
) as directory:
|
||||
path = Path(directory) / "device-edge-vps-backhaul.pub"
|
||||
path.write_text(key, encoding="ascii")
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PUBLIC_KEY_FILE",
|
||||
path,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_FINGERPRINT",
|
||||
fingerprint,
|
||||
),
|
||||
):
|
||||
enrollment = (
|
||||
RUNNER.read_device_plane_backhaul_vps_enrollment_public_key()
|
||||
)
|
||||
self.assertEqual(enrollment["fingerprint"], fingerprint)
|
||||
self.assertTrue(
|
||||
enrollment["line"].endswith(
|
||||
" nodedc-device-edge-vps-backhaul"
|
||||
)
|
||||
)
|
||||
|
||||
def test_runtime_authorized_key_is_external_and_atomic(self):
|
||||
blob = (
|
||||
b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20"
|
||||
+ bytes(reversed(range(32)))
|
||||
)
|
||||
enrollment = {
|
||||
"line": (
|
||||
"ssh-ed25519 "
|
||||
+ base64.b64encode(blob).decode("ascii")
|
||||
+ " nodedc-device-edge-vps-backhaul"
|
||||
)
|
||||
}
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-vps-enrollment-runtime-",
|
||||
) as directory:
|
||||
secret_dir = Path(directory) / "secret"
|
||||
authorized = secret_dir / "authorized_keys"
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_BACKHAUL_SECRET_DIR",
|
||||
secret_dir,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE",
|
||||
authorized,
|
||||
),
|
||||
mock.patch.object(RUNNER.os, "chown"),
|
||||
):
|
||||
digest = RUNNER.install_device_plane_backhaul_authorized_key(
|
||||
enrollment
|
||||
)
|
||||
expected = (
|
||||
'restrict,port-forwarding,permitopen="127.0.0.1:9921" '
|
||||
+ enrollment["line"]
|
||||
+ "\n"
|
||||
)
|
||||
self.assertEqual(authorized.read_text(encoding="ascii"), expected)
|
||||
self.assertEqual(
|
||||
digest,
|
||||
hashlib.sha256(expected.encode("ascii")).hexdigest(),
|
||||
)
|
||||
self.assertEqual(authorized.stat().st_mode & 0o777, 0o444)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = (
|
||||
SCRIPT_DIR
|
||||
/ "build-device-plane-foundation-network-publication-artifact.mjs"
|
||||
)
|
||||
RUNNER_PATH = Path(os.environ.get("NODEDC_DEPLOY_RUNNER", SCRIPT_DIR.parents[2] / "platform" / "infra" / "deploy-runner" / "nodedc-deploy"))
|
||||
COMPOSE = (
|
||||
SCRIPT_DIR
|
||||
/ "fixtures/device-plane-foundation-network-publication-v1.yml"
|
||||
)
|
||||
PREDECESSOR_COMPOSE = (
|
||||
SCRIPT_DIR
|
||||
/ "fixtures/device-plane-foundation-internal-only-v1.yml"
|
||||
)
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_network_publication_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlaneFoundationNetworkPublicationArtifactTest(
|
||||
unittest.TestCase
|
||||
):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_artifact_is_exact_deterministic_and_database_free(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-network-publication-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
patch_id = (
|
||||
"device-plane-foundation-network-publication-unit-003"
|
||||
)
|
||||
first = self.build(artifact_dir, patch_id)
|
||||
first_bytes = Path(first["artifact"]).read_bytes()
|
||||
second = self.build(artifact_dir, patch_id)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(
|
||||
first["transition"],
|
||||
"failed-foundation-network-publication-correction",
|
||||
)
|
||||
self.assertEqual(first["build"], [])
|
||||
self.assertEqual(
|
||||
first["services"],
|
||||
["device-control-core", "device-gateway"],
|
||||
)
|
||||
self.assertNotIn("device-postgres", first["services"])
|
||||
self.assertEqual(
|
||||
first["entries"],
|
||||
list(
|
||||
RUNNER
|
||||
.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES
|
||||
),
|
||||
)
|
||||
|
||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||
files = (
|
||||
archive.extractfile("files.txt")
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
.splitlines()
|
||||
)
|
||||
compose = (
|
||||
archive.extractfile(
|
||||
"payload/docker-compose.device-plane.yml"
|
||||
)
|
||||
.read()
|
||||
)
|
||||
descriptor = json.loads(
|
||||
archive.extractfile(
|
||||
"payload/deployment/"
|
||||
"device-plane-foundation-network-publication-v1.json"
|
||||
)
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
|
||||
self.assertEqual(files, first["entries"])
|
||||
self.assertEqual(
|
||||
hashlib.sha256(compose).hexdigest(),
|
||||
RUNNER
|
||||
.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_COMPOSE_SHA256,
|
||||
)
|
||||
self.assertEqual(
|
||||
descriptor,
|
||||
RUNNER
|
||||
.expected_device_plane_foundation_network_publication_descriptor(),
|
||||
)
|
||||
|
||||
def test_compose_preserves_database_and_adds_control_network_only_to_stateless_services(
|
||||
self,
|
||||
):
|
||||
compose = COMPOSE.read_text(encoding="utf-8")
|
||||
predecessor = PREDECESSOR_COMPOSE.read_text(encoding="utf-8")
|
||||
self.assertEqual(
|
||||
hashlib.sha256(PREDECESSOR_COMPOSE.read_bytes()).hexdigest(),
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_PREDECESSOR_COMPOSE_SHA256,
|
||||
)
|
||||
self.assertEqual(
|
||||
hashlib.sha256(COMPOSE.read_bytes()).hexdigest(),
|
||||
RUNNER
|
||||
.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_COMPOSE_SHA256,
|
||||
)
|
||||
self.assertIn("name: nodedc-device-plane-control", compose)
|
||||
self.assertIn(
|
||||
'com.docker.network.bridge.enable_ip_masquerade: "false"',
|
||||
compose,
|
||||
)
|
||||
self.assertNotIn("nodedc-device-plane-control", predecessor)
|
||||
postgres_section, stateless = compose.split(
|
||||
" device-control-core:",
|
||||
1,
|
||||
)
|
||||
self.assertNotIn("device-plane-control", postgres_section)
|
||||
self.assertEqual(stateless.count(" - device-plane-control"), 2)
|
||||
self.assertNotIn("9921:9921", compose)
|
||||
|
||||
def test_runner_selects_exact_services_no_build_and_no_deps(self):
|
||||
entries = (
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES
|
||||
)
|
||||
services = RUNNER.component_services("device-plane", entries)
|
||||
self.assertEqual(
|
||||
services,
|
||||
("device-control-core", "device-gateway"),
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_builds("device-plane", entries),
|
||||
(),
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(RUNNER, "run_build") as run_build,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"prepare_component_runtime",
|
||||
) as prepare,
|
||||
mock.patch.object(RUNNER, "run_compose") as run_compose,
|
||||
):
|
||||
RUNNER.run_component_runtime(
|
||||
"device-plane",
|
||||
entries,
|
||||
services,
|
||||
)
|
||||
run_build.assert_not_called()
|
||||
prepare.assert_called_once_with("device-plane", entries)
|
||||
run_compose.assert_called_once_with(
|
||||
"device-plane",
|
||||
services,
|
||||
entries,
|
||||
)
|
||||
|
||||
with mock.patch.object(RUNNER.subprocess, "run") as run:
|
||||
RUNNER.run_compose("device-plane", services, entries)
|
||||
command = run.call_args_list[0].args[0]
|
||||
self.assertIn("--force-recreate", command)
|
||||
self.assertIn("--no-deps", command)
|
||||
self.assertEqual(
|
||||
command[-2:],
|
||||
["device-control-core", "device-gateway"],
|
||||
)
|
||||
|
||||
def test_terminal_failed_recovery_is_rejected(self):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"recovery 002 is terminal failed",
|
||||
):
|
||||
RUNNER.reject_terminal_device_plane_foundation_artifact(
|
||||
{
|
||||
"id":
|
||||
RUNNER
|
||||
.DEVICE_PLANE_FOUNDATION_RECOVERY_FAILED_PATCH_ID,
|
||||
},
|
||||
"0" * 64,
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"recovery 002 is terminal failed",
|
||||
):
|
||||
RUNNER.reject_terminal_device_plane_foundation_artifact(
|
||||
{"id": "unrelated"},
|
||||
RUNNER
|
||||
.DEVICE_PLANE_FOUNDATION_RECOVERY_FAILED_ARTIFACT_SHA256,
|
||||
)
|
||||
|
||||
def test_rollback_is_stateless_and_removes_only_control_network(self):
|
||||
entries = (
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES
|
||||
)
|
||||
services = ("device-control-core", "device-gateway")
|
||||
runtime = {
|
||||
"device-control-core": {"containerId": "1" * 64},
|
||||
"device-gateway": {"containerId": "2" * 64},
|
||||
"device-postgres": {
|
||||
"containerId":
|
||||
RUNNER
|
||||
.DEVICE_PLANE_FOUNDATION_PREDECESSOR_CONTAINER_IDS[
|
||||
"device-postgres"
|
||||
],
|
||||
},
|
||||
}
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"stop_and_remove_compose_services",
|
||||
) as stop,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"restore_platform_overlay",
|
||||
return_value=9,
|
||||
) as restore,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"remove_device_plane_control_network_if_unused",
|
||||
return_value="removed",
|
||||
) as remove_network,
|
||||
mock.patch.object(RUNNER, "run_compose") as compose,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service",
|
||||
) as health,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_foundation_runtime",
|
||||
return_value=runtime,
|
||||
) as validate_runtime,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"assert_loopback_tcp_port_closed",
|
||||
) as closed,
|
||||
):
|
||||
result = (
|
||||
RUNNER
|
||||
.rollback_device_plane_network_publication_apply(
|
||||
Path("/live"),
|
||||
Path("/backup"),
|
||||
entries,
|
||||
"stamp",
|
||||
True,
|
||||
services,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(result, "source+internal-runtime-restored:9")
|
||||
stop.assert_called_once_with("device-plane", services)
|
||||
restore.assert_called_once()
|
||||
remove_network.assert_called_once_with()
|
||||
compose.assert_called_once_with(
|
||||
"device-plane",
|
||||
services,
|
||||
("docker-compose.device-plane.yml",),
|
||||
)
|
||||
self.assertEqual(health.call_count, 3)
|
||||
validate_runtime.assert_called_once_with()
|
||||
self.assertEqual(
|
||||
[call.args[0] for call in closed.call_args_list],
|
||||
[18120, 18121, 9921],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,386 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
RECOVERY_BUILDER = (
|
||||
SCRIPT_DIR / "build-device-plane-foundation-recovery-artifact.mjs"
|
||||
)
|
||||
FOUNDATION_BUILDER = SCRIPT_DIR / "build-device-plane-artifact.mjs"
|
||||
PREDECESSOR_COMPOSE = (
|
||||
SCRIPT_DIR
|
||||
/ "fixtures/device-plane-foundation-internal-only-v1.yml"
|
||||
)
|
||||
RUNNER_PATH = Path(os.environ.get("NODEDC_DEPLOY_RUNNER", SCRIPT_DIR.parents[2] / "platform" / "infra" / "deploy-runner" / "nodedc-deploy"))
|
||||
RECOVERY_ENTRIES = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
"deployment/device-plane-foundation-recovery-v1.json",
|
||||
]
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_recovery_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlaneFoundationRecoveryArtifactTest(unittest.TestCase):
|
||||
def build(self, builder, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(builder), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_recovery_artifact_is_source_only_exact_and_deterministic(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-recovery-artifact-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
first = self.build(
|
||||
RECOVERY_BUILDER,
|
||||
artifact_dir,
|
||||
"device-plane-foundation-recovery-unit-002",
|
||||
)
|
||||
artifact = Path(first["artifact"])
|
||||
first_bytes = artifact.read_bytes()
|
||||
second = self.build(
|
||||
RECOVERY_BUILDER,
|
||||
artifact_dir,
|
||||
"device-plane-foundation-recovery-unit-002",
|
||||
)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first["component"], "device-plane")
|
||||
self.assertEqual(first["entries"], RECOVERY_ENTRIES)
|
||||
self.assertEqual(first["build"], [])
|
||||
self.assertEqual(first["services"], [])
|
||||
self.assertEqual(
|
||||
first["transition"],
|
||||
"failed-foundation-live-runtime-adoption",
|
||||
)
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
|
||||
with tarfile.open(artifact, "r:gz") as archive:
|
||||
names = {member.name for member in archive.getmembers()}
|
||||
files = (
|
||||
archive.extractfile("files.txt")
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
.splitlines()
|
||||
)
|
||||
descriptor = json.loads(
|
||||
archive.extractfile(
|
||||
"payload/deployment/"
|
||||
"device-plane-foundation-recovery-v1.json"
|
||||
)
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
|
||||
self.assertEqual(files, RECOVERY_ENTRIES)
|
||||
self.assertIn(
|
||||
"payload/services/device-control-core/src/server.mjs",
|
||||
names,
|
||||
)
|
||||
self.assertIn(
|
||||
"payload/services/device-gateway/src/server.mjs",
|
||||
names,
|
||||
)
|
||||
self.assertFalse(any(
|
||||
"/test/" in name
|
||||
or "/node_modules/" in name
|
||||
or Path(name).name.startswith(".env")
|
||||
or name.startswith("payload/runtime/")
|
||||
or name.startswith("payload/secrets/")
|
||||
for name in names
|
||||
))
|
||||
self.assertEqual(
|
||||
descriptor,
|
||||
RUNNER.expected_device_plane_foundation_recovery_descriptor(),
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_services(
|
||||
"device-plane",
|
||||
tuple(RECOVERY_ENTRIES),
|
||||
),
|
||||
(),
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_builds(
|
||||
"device-plane",
|
||||
tuple(RECOVERY_ENTRIES),
|
||||
),
|
||||
(),
|
||||
)
|
||||
|
||||
def test_preflight_requires_exact_failed_evidence_partial_source_and_runtime(
|
||||
self,
|
||||
):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-recovery-preflight-",
|
||||
) as directory:
|
||||
workspace = Path(directory)
|
||||
artifacts = workspace / "artifacts"
|
||||
failed_root = workspace / "failed"
|
||||
backups_root = workspace / "backups"
|
||||
state_root = workspace / "state"
|
||||
temp_root = workspace / "tmp"
|
||||
live_root = workspace / "live"
|
||||
for path in (
|
||||
artifacts,
|
||||
failed_root,
|
||||
backups_root,
|
||||
state_root,
|
||||
temp_root,
|
||||
live_root / "deployment",
|
||||
):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
failed_build = self.build(
|
||||
FOUNDATION_BUILDER,
|
||||
artifacts,
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID,
|
||||
)
|
||||
self.assertEqual(
|
||||
failed_build["sha256"],
|
||||
hashlib.sha256(
|
||||
Path(failed_build["artifact"]).read_bytes()
|
||||
).hexdigest(),
|
||||
)
|
||||
failed_artifact = (
|
||||
failed_root / RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT
|
||||
)
|
||||
shutil.copy2(failed_build["artifact"], failed_artifact)
|
||||
|
||||
recovery_build = self.build(
|
||||
RECOVERY_BUILDER,
|
||||
artifacts,
|
||||
"device-plane-foundation-recovery-unit-002",
|
||||
)
|
||||
recovery_extract = workspace / "recovery-extract"
|
||||
recovery_extract.mkdir()
|
||||
_manifest, entries, payload = RUNNER.load_artifact(
|
||||
Path(recovery_build["artifact"]),
|
||||
recovery_extract,
|
||||
)
|
||||
self.assertEqual(entries, RECOVERY_ENTRIES)
|
||||
|
||||
backup = (
|
||||
backups_root
|
||||
/ RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_ID
|
||||
)
|
||||
backup.mkdir()
|
||||
for name in (
|
||||
"manifest.env",
|
||||
"files.txt",
|
||||
"existing-files.txt",
|
||||
"missing-files.txt",
|
||||
"source-before.tgz",
|
||||
):
|
||||
(backup / name).write_text(
|
||||
f"fixture:{name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
backup_hashes = {
|
||||
name: hashlib.sha256((backup / name).read_bytes()).hexdigest()
|
||||
for name in (
|
||||
"manifest.env",
|
||||
"files.txt",
|
||||
"existing-files.txt",
|
||||
"missing-files.txt",
|
||||
"source-before.tgz",
|
||||
)
|
||||
}
|
||||
|
||||
(state_root / "failed.jsonl").write_text(
|
||||
json.dumps({
|
||||
"artifact":
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT,
|
||||
"backup_id":
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_ID,
|
||||
"component": "device-plane",
|
||||
"id": RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID,
|
||||
"message":
|
||||
"healthcheck failed for "
|
||||
"http://127.0.0.1:18120/healthz: "
|
||||
"<urlopen error [Errno 111] Connection refused>",
|
||||
"rollback_status": "failed:DeployError",
|
||||
"sha256":
|
||||
failed_build["sha256"],
|
||||
"started_apply": True,
|
||||
"status": "failed",
|
||||
})
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
source_root = SCRIPT_DIR.parents[1]
|
||||
shutil.copy2(
|
||||
PREDECESSOR_COMPOSE,
|
||||
live_root / "docker-compose.device-plane.yml",
|
||||
)
|
||||
shutil.copy2(
|
||||
source_root
|
||||
/ "deployment/device-postgres-bootstrap-v1.json",
|
||||
live_root
|
||||
/ "deployment/device-postgres-bootstrap-v1.json",
|
||||
)
|
||||
runtime = {
|
||||
service: {
|
||||
"containerId": service,
|
||||
"imageId":
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_IMAGE_IDS[
|
||||
service
|
||||
],
|
||||
"health": "healthy",
|
||||
"restartCount": 0,
|
||||
}
|
||||
for service in (
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
)
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch.object(RUNNER, "BACKUPS_DIR", backups_root),
|
||||
mock.patch.object(RUNNER, "FAILED_DIR", failed_root),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"FAILED_STATE_FILE",
|
||||
state_root / "failed.jsonl",
|
||||
),
|
||||
mock.patch.object(RUNNER, "TMP_DIR", temp_root),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"component_root",
|
||||
return_value=live_root,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256",
|
||||
failed_build["sha256"],
|
||||
),
|
||||
mock.patch.dict(
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_SHA256,
|
||||
backup_hashes,
|
||||
clear=True,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_foundation_runtime",
|
||||
return_value=runtime,
|
||||
),
|
||||
):
|
||||
result = (
|
||||
RUNNER
|
||||
.validate_device_plane_foundation_recovery_evidence(
|
||||
payload
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
result["mode"],
|
||||
"failed-foundation-live-runtime-adoption",
|
||||
)
|
||||
self.assertEqual(result["runtime"], runtime)
|
||||
|
||||
def test_recovery_health_acceptance_never_mutates_runtime(self):
|
||||
entries = tuple(RECOVERY_ENTRIES)
|
||||
checks = ("core-health", "gateway-health")
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service",
|
||||
) as compose_health,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"component_healthchecks",
|
||||
return_value=checks,
|
||||
),
|
||||
mock.patch.object(RUNNER, "healthcheck_url") as url_health,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_foundation_installed_source",
|
||||
) as source_acceptance,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_foundation_runtime",
|
||||
) as runtime_acceptance,
|
||||
mock.patch.object(RUNNER, "run_compose") as compose_mutation,
|
||||
mock.patch.object(RUNNER, "run_build") as build_mutation,
|
||||
):
|
||||
RUNNER.run_healthchecks("device-plane", entries, ())
|
||||
|
||||
self.assertEqual(
|
||||
[call.args for call in compose_health.call_args_list],
|
||||
[
|
||||
("device-plane", "device-control-core"),
|
||||
("device-plane", "device-gateway"),
|
||||
("device-plane", "device-postgres"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
[call.args[0] for call in url_health.call_args_list],
|
||||
list(checks),
|
||||
)
|
||||
source_acceptance.assert_called_once_with()
|
||||
runtime_acceptance.assert_called_once_with()
|
||||
compose_mutation.assert_not_called()
|
||||
build_mutation.assert_not_called()
|
||||
|
||||
def test_recovery_runtime_phase_has_no_runtime_mutation(self):
|
||||
entries = tuple(RECOVERY_ENTRIES)
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"prepare_component_runtime",
|
||||
) as prepare,
|
||||
mock.patch.object(RUNNER, "run_compose") as compose_mutation,
|
||||
mock.patch.object(RUNNER, "run_build") as build_mutation,
|
||||
):
|
||||
RUNNER.run_component_runtime("device-plane", entries, ())
|
||||
|
||||
prepare.assert_not_called()
|
||||
compose_mutation.assert_not_called()
|
||||
build_mutation.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
RUNNER_PATH = Path(os.environ.get("NODEDC_DEPLOY_RUNNER", SCRIPT_DIR.parents[2] / "platform" / "infra" / "deploy-runner" / "nodedc-deploy"))
|
||||
BUILDER = (
|
||||
SCRIPT_DIR
|
||||
/ "build-device-plane-postgres-bootstrap-artifact.mjs"
|
||||
)
|
||||
EXPECTED_ENTRIES = [
|
||||
"docker-compose.device-plane.yml",
|
||||
"deployment/device-postgres-bootstrap-v1.json",
|
||||
]
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_postgres_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlanePostgresBootstrapTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_bootstrap_artifact_is_exact_deterministic_and_runner_accepted(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-postgres-artifact-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
first = self.build(
|
||||
artifact_dir,
|
||||
"device-plane-postgres-bootstrap-unit-001",
|
||||
)
|
||||
artifact = Path(first["artifact"])
|
||||
first_bytes = artifact.read_bytes()
|
||||
second = self.build(
|
||||
artifact_dir,
|
||||
"device-plane-postgres-bootstrap-unit-001",
|
||||
)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first["entries"], EXPECTED_ENTRIES)
|
||||
self.assertEqual(first["services"], ["device-postgres"])
|
||||
self.assertEqual(first["mode"], "create-if-absent")
|
||||
self.assertEqual(first["rollbackVolumePolicy"], "preserve")
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
|
||||
with tarfile.open(artifact, "r:gz") as archive:
|
||||
names = {member.name for member in archive.getmembers()}
|
||||
self.assertEqual(
|
||||
archive.extractfile("files.txt")
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
.splitlines(),
|
||||
EXPECTED_ENTRIES,
|
||||
)
|
||||
self.assertEqual(
|
||||
names,
|
||||
{
|
||||
"manifest.env",
|
||||
"files.txt",
|
||||
"payload",
|
||||
"payload/docker-compose.device-plane.yml",
|
||||
"payload/deployment",
|
||||
"payload/deployment/device-postgres-bootstrap-v1.json",
|
||||
},
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-postgres-load-",
|
||||
) as work_directory:
|
||||
manifest, entries, _payload = RUNNER.load_artifact(
|
||||
artifact,
|
||||
Path(work_directory),
|
||||
)
|
||||
self.assertEqual(manifest["component"], "device-plane")
|
||||
self.assertEqual(entries, EXPECTED_ENTRIES)
|
||||
self.assertTrue(
|
||||
RUNNER.is_device_plane_postgres_bootstrap_slice(
|
||||
manifest["component"],
|
||||
entries,
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_services("device-plane", entries),
|
||||
("device-postgres",),
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_builds("device-plane", entries),
|
||||
(),
|
||||
)
|
||||
|
||||
def test_preflight_accepts_only_absent_container_and_volume(self):
|
||||
absent_container = mock.Mock(returncode=0, stdout="")
|
||||
absent_volume = mock.Mock(returncode=1)
|
||||
with mock.patch.object(
|
||||
RUNNER.subprocess,
|
||||
"run",
|
||||
side_effect=[absent_container, absent_volume],
|
||||
):
|
||||
self.assertEqual(
|
||||
RUNNER.preflight_device_plane_postgres_bootstrap(),
|
||||
"absent",
|
||||
)
|
||||
|
||||
existing_container = mock.Mock(
|
||||
returncode=0,
|
||||
stdout="abc123def456\n",
|
||||
)
|
||||
with mock.patch.object(
|
||||
RUNNER.subprocess,
|
||||
"run",
|
||||
return_value=existing_container,
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"container already exists",
|
||||
):
|
||||
RUNNER.preflight_device_plane_postgres_bootstrap()
|
||||
|
||||
with mock.patch.object(
|
||||
RUNNER.subprocess,
|
||||
"run",
|
||||
side_effect=[
|
||||
absent_container,
|
||||
mock.Mock(returncode=0),
|
||||
],
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"volume already exists",
|
||||
):
|
||||
RUNNER.preflight_device_plane_postgres_bootstrap()
|
||||
|
||||
def test_plan_selection_is_unambiguous_for_bootstrap_and_application(self):
|
||||
self.assertEqual(
|
||||
RUNNER.device_plane_postgres_plan_selection(None),
|
||||
"preserved-prerequisite:not-selected",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.device_plane_postgres_plan_selection("absent"),
|
||||
"bootstrap-selected:create-if-absent",
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"plan preflight state is invalid",
|
||||
):
|
||||
RUNNER.device_plane_postgres_plan_selection("unknown")
|
||||
|
||||
def test_bootstrap_health_acceptance_is_database_service_only(self):
|
||||
with mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service",
|
||||
) as health:
|
||||
RUNNER.run_healthchecks(
|
||||
"device-plane",
|
||||
EXPECTED_ENTRIES,
|
||||
("device-postgres",),
|
||||
)
|
||||
health.assert_called_once_with(
|
||||
"device-plane",
|
||||
"device-postgres",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"service set mismatch",
|
||||
):
|
||||
RUNNER.run_healthchecks(
|
||||
"device-plane",
|
||||
EXPECTED_ENTRIES,
|
||||
("device-control-core",),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user