feat(device-plane): accept core edge transport ADR
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
#!/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 = resolve(platformRoot, "device-plane");
|
||||
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", "backhaul", "relay"].includes(phase)
|
||||
|| !/^[A-Za-z0-9._-]{1,96}$/.test(patchId || "")
|
||||
) {
|
||||
throw new Error(
|
||||
"usage: build-device-edge-vps-artifact.mjs <foundation|backhaul|relay> <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 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}`,
|
||||
],
|
||||
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",
|
||||
],
|
||||
};
|
||||
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" : "disabled",
|
||||
commandTransport: "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 !== "disabled"
|
||||
|| descriptor.gelios !== "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 === "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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,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 = resolve(platformRoot, "device-plane");
|
||||
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");
|
||||
}
|
||||
Executable
+1181
File diff suppressed because it is too large
Load Diff
@@ -132,6 +132,27 @@ DEVICE_PLANE_BACKHAUL_ENROLLMENT_DIR = DEVICE_PLANE_ROOT / "enrollment"
|
||||
DEVICE_PLANE_BACKHAUL_ENROLLMENT_PUBLIC_KEY_FILE = (
|
||||
DEVICE_PLANE_BACKHAUL_ENROLLMENT_DIR / "device-edge-backhaul.pub"
|
||||
)
|
||||
DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PUBLIC_KEY_FILE = (
|
||||
DEVICE_PLANE_BACKHAUL_ENROLLMENT_DIR / "device-edge-vps-backhaul.pub"
|
||||
)
|
||||
DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_REL = (
|
||||
"deployment/device-plane-backhaul-vps-enrollment-v1.json"
|
||||
)
|
||||
DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_ENTRIES = (
|
||||
DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_REL,
|
||||
)
|
||||
DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_PATCH_ID = (
|
||||
"device-plane-backhaul-target-tailnet-serve-20260804-002"
|
||||
)
|
||||
DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_ARTIFACT_SHA256 = (
|
||||
"219408705dd4d80a962ed00eeb53a69df0b9ab6458443734d5c9cd1d1f795eba"
|
||||
)
|
||||
DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_FINGERPRINT = (
|
||||
"SHA256:HHTiDYiCRxSiKjBLCip6JMSzGfLGrDz5g8SIkosJcVw"
|
||||
)
|
||||
DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_BACKUP = (
|
||||
"device-plane-backhaul-authorized-keys-before"
|
||||
)
|
||||
DEVICE_PLANE_BACKHAUL_SECRET_DIR = DEVICE_PLANE_SECRET_DIR / "backhaul-target"
|
||||
DEVICE_PLANE_BACKHAUL_HOST_KEY_FILE = (
|
||||
DEVICE_PLANE_BACKHAUL_SECRET_DIR / "ssh_host_ed25519_key"
|
||||
@@ -8053,6 +8074,12 @@ def load_artifact(artifact, work_dir):
|
||||
manifest = parse_manifest(manifest_path)
|
||||
entries = parse_files_list(files_path)
|
||||
|
||||
if is_device_plane_backhaul_vps_enrollment_slice(
|
||||
manifest["component"],
|
||||
entries,
|
||||
):
|
||||
die("vps_initiated_transport_frozen:ADR-0001")
|
||||
|
||||
for rel in entries:
|
||||
allowed_payload_path(manifest["component"], rel)
|
||||
if not (payload_dir / rel).exists():
|
||||
@@ -8093,6 +8120,11 @@ def load_artifact(artifact, work_dir):
|
||||
entries,
|
||||
):
|
||||
validate_device_plane_backhaul_target_payload(payload_dir)
|
||||
if is_device_plane_backhaul_vps_enrollment_slice(
|
||||
manifest["component"],
|
||||
entries,
|
||||
):
|
||||
validate_device_plane_backhaul_vps_enrollment_payload(payload_dir)
|
||||
if manifest["component"] == "n8n-private-extension":
|
||||
validate_n8n_private_extension_release(payload_dir, entries)
|
||||
if manifest["component"] == "engine":
|
||||
@@ -8470,6 +8502,63 @@ def is_device_plane_backhaul_target_slice(component, entries):
|
||||
)
|
||||
|
||||
|
||||
def is_device_plane_backhaul_vps_enrollment_slice(component, entries):
|
||||
return (
|
||||
component == "device-plane"
|
||||
and entries is not None
|
||||
and tuple(entries)
|
||||
== DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_ENTRIES
|
||||
)
|
||||
|
||||
|
||||
def expected_device_plane_backhaul_vps_enrollment_descriptor():
|
||||
return {
|
||||
"schemaVersion": (
|
||||
"nodedc.device-plane.backhaul-vps-enrollment.v1"
|
||||
),
|
||||
"mode": "rotate-backhaul-client-mini-to-vps",
|
||||
"predecessorPatchId": (
|
||||
DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_PATCH_ID
|
||||
),
|
||||
"predecessorArtifactSha256": (
|
||||
DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_ARTIFACT_SHA256
|
||||
),
|
||||
"sourceAction": "publish-vps-enrollment-marker-only",
|
||||
"runtimeAction": (
|
||||
"rotate-authorized-key-and-recreate-backhaul-target"
|
||||
),
|
||||
"selectedServices": [DEVICE_PLANE_BACKHAUL_TARGET_SERVICE],
|
||||
"preservedServices": list(DEVICE_PLANE_RUNTIME_SERVICES),
|
||||
"previousEnrollment": "device-edge-backhaul.pub",
|
||||
"nextEnrollment": "device-edge-vps-backhaul.pub",
|
||||
"nextKeyFingerprint": (
|
||||
DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_FINGERPRINT
|
||||
),
|
||||
"permittedTarget": DEVICE_PLANE_BACKHAUL_PERMITTED_TARGET,
|
||||
"tailnetAddress": DEVICE_PLANE_BACKHAUL_TAILNET_ADDRESS,
|
||||
"dockerPortPublication": "disabled",
|
||||
"routerNatFirewall": "unchanged",
|
||||
"edgePublicIngress": "disabled",
|
||||
"funnel": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"rollback": (
|
||||
"restore-previous-authorized-key-and-recreate-target"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def validate_device_plane_backhaul_vps_enrollment_payload(payload_dir):
|
||||
descriptor = read_strict_json(
|
||||
payload_dir / DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_REL,
|
||||
"Device Plane VPS backhaul enrollment descriptor",
|
||||
max_bytes=16 * 1024,
|
||||
)
|
||||
if descriptor != expected_device_plane_backhaul_vps_enrollment_descriptor():
|
||||
die("Device Plane VPS backhaul enrollment descriptor mismatch")
|
||||
return descriptor
|
||||
|
||||
|
||||
def expected_device_plane_backhaul_target_descriptor():
|
||||
return {
|
||||
"schemaVersion": (
|
||||
@@ -8534,14 +8623,13 @@ def validate_device_plane_backhaul_target_payload(payload_dir):
|
||||
return descriptor
|
||||
|
||||
|
||||
def read_device_plane_backhaul_enrollment_public_key():
|
||||
path = DEVICE_PLANE_BACKHAUL_ENROLLMENT_PUBLIC_KEY_FILE
|
||||
def read_device_plane_ed25519_enrollment_public_key(path, comment, label):
|
||||
try:
|
||||
path_stat = path.lstat()
|
||||
text = path.read_text(encoding="ascii")
|
||||
except (FileNotFoundError, OSError, UnicodeDecodeError):
|
||||
die(
|
||||
"Device Plane Edge enrollment public key is missing or unreadable: "
|
||||
f"{label} is missing or unreadable: "
|
||||
f"{path}"
|
||||
)
|
||||
if (
|
||||
@@ -8549,28 +8637,56 @@ def read_device_plane_backhaul_enrollment_public_key():
|
||||
or not stat.S_ISREG(path_stat.st_mode)
|
||||
or path_stat.st_size > 1024
|
||||
):
|
||||
die("Device Plane Edge enrollment public key is unsafe")
|
||||
die(f"{label} is unsafe")
|
||||
if text != text.strip() + "\n" or "\n" in text.strip():
|
||||
die("Device Plane Edge enrollment public key must be one line")
|
||||
die(f"{label} must be one line")
|
||||
parts = text.strip().split()
|
||||
if len(parts) not in (2, 3) or parts[0] != "ssh-ed25519":
|
||||
die("Device Plane Edge enrollment public key type mismatch")
|
||||
die(f"{label} type mismatch")
|
||||
try:
|
||||
blob = base64.b64decode(parts[1], validate=True)
|
||||
except Exception:
|
||||
die("Device Plane Edge enrollment public key encoding mismatch")
|
||||
die(f"{label} encoding mismatch")
|
||||
expected_prefix = b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20"
|
||||
if len(blob) != len(expected_prefix) + 32 or not blob.startswith(
|
||||
expected_prefix
|
||||
):
|
||||
die("Device Plane Edge enrollment public key shape mismatch")
|
||||
normalized = f"ssh-ed25519 {parts[1]} nodedc-device-edge-backhaul"
|
||||
die(f"{label} shape mismatch")
|
||||
normalized = f"ssh-ed25519 {parts[1]} {comment}"
|
||||
return {
|
||||
"line": normalized,
|
||||
"sha256": hashlib.sha256((normalized + "\n").encode("ascii")).hexdigest(),
|
||||
"fingerprint": (
|
||||
"SHA256:"
|
||||
+ base64.b64encode(hashlib.sha256(blob).digest())
|
||||
.decode("ascii")
|
||||
.rstrip("=")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def read_device_plane_backhaul_enrollment_public_key():
|
||||
return read_device_plane_ed25519_enrollment_public_key(
|
||||
DEVICE_PLANE_BACKHAUL_ENROLLMENT_PUBLIC_KEY_FILE,
|
||||
"nodedc-device-edge-backhaul",
|
||||
"Device Plane Edge enrollment public key",
|
||||
)
|
||||
|
||||
|
||||
def read_device_plane_backhaul_vps_enrollment_public_key():
|
||||
enrollment = read_device_plane_ed25519_enrollment_public_key(
|
||||
DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PUBLIC_KEY_FILE,
|
||||
"nodedc-device-edge-vps-backhaul",
|
||||
"Device Plane VPS Edge enrollment public key",
|
||||
)
|
||||
if (
|
||||
enrollment["fingerprint"]
|
||||
!= DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_FINGERPRINT
|
||||
):
|
||||
die("Device Plane VPS Edge enrollment fingerprint mismatch")
|
||||
return enrollment
|
||||
|
||||
|
||||
def device_plane_tailscale_drop_privileges(uid, gid):
|
||||
def demote():
|
||||
os.setgroups([])
|
||||
@@ -9136,6 +9252,37 @@ def validate_device_plane_backhaul_target_evidence(payload_dir):
|
||||
}
|
||||
|
||||
|
||||
def validate_device_plane_backhaul_vps_enrollment_evidence(payload_dir):
|
||||
descriptor = validate_device_plane_backhaul_vps_enrollment_payload(
|
||||
payload_dir
|
||||
)
|
||||
if not state_has_patch_id(
|
||||
DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_PATCH_ID
|
||||
):
|
||||
die("Device Plane VPS enrollment predecessor patch is not applied")
|
||||
if not state_has_sha(
|
||||
DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_ARTIFACT_SHA256
|
||||
):
|
||||
die("Device Plane VPS enrollment predecessor artifact is not applied")
|
||||
root = component_root("device-plane")
|
||||
marker = root / DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_REL
|
||||
if marker.exists() or marker.is_symlink():
|
||||
die("Device Plane VPS enrollment marker already exists")
|
||||
runtime = device_plane_runtime_inventory(DEVICE_PLANE_RUNTIME_SERVICES)
|
||||
validate_device_plane_backhaul_target_runtime(runtime)
|
||||
previous = read_device_plane_backhaul_enrollment_public_key()
|
||||
next_enrollment = read_device_plane_backhaul_vps_enrollment_public_key()
|
||||
if previous["line"] == next_enrollment["line"]:
|
||||
die("Device Plane VPS enrollment key is not a new identity")
|
||||
return {
|
||||
"mode": descriptor["mode"],
|
||||
"runtime": runtime,
|
||||
"previousEnrollmentPublicKeySha256": previous["sha256"],
|
||||
"nextEnrollmentPublicKeySha256": next_enrollment["sha256"],
|
||||
"nextKeyFingerprint": next_enrollment["fingerprint"],
|
||||
}
|
||||
|
||||
|
||||
def expected_device_plane_foundation_recovery_descriptor():
|
||||
return {
|
||||
"schemaVersion": "nodedc.device-plane.foundation-recovery.v1",
|
||||
@@ -10713,7 +10860,10 @@ def validate_device_plane_preserved_runtime_unchanged(runtime_before, label):
|
||||
return current
|
||||
|
||||
|
||||
def validate_device_plane_backhaul_target_runtime(runtime_before):
|
||||
def validate_device_plane_backhaul_target_runtime(
|
||||
runtime_before,
|
||||
expected_enrollment=None,
|
||||
):
|
||||
current = validate_device_plane_preserved_runtime_unchanged(
|
||||
runtime_before,
|
||||
"Device Plane backhaul",
|
||||
@@ -10840,7 +10990,11 @@ def validate_device_plane_backhaul_target_runtime(runtime_before):
|
||||
f"{required}"
|
||||
)
|
||||
|
||||
enrollment = read_device_plane_backhaul_enrollment_public_key()
|
||||
enrollment = (
|
||||
expected_enrollment
|
||||
if expected_enrollment is not None
|
||||
else read_device_plane_backhaul_enrollment_public_key()
|
||||
)
|
||||
authorized = (
|
||||
'restrict,port-forwarding,permitopen="127.0.0.1:9921" '
|
||||
f"{enrollment['line']}\n"
|
||||
@@ -12557,6 +12711,9 @@ def is_platform_provider_catalog_only(entries):
|
||||
|
||||
|
||||
def component_services(component, entries=None):
|
||||
if is_device_plane_backhaul_vps_enrollment_slice(component, entries):
|
||||
return (DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,)
|
||||
|
||||
if is_device_plane_backhaul_target_slice(component, entries):
|
||||
return (DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,)
|
||||
|
||||
@@ -12931,6 +13088,9 @@ def component_build_args(component, entries=None):
|
||||
|
||||
|
||||
def component_builds(component, entries=None):
|
||||
if is_device_plane_backhaul_vps_enrollment_slice(component, entries):
|
||||
return ()
|
||||
|
||||
if is_device_plane_backhaul_target_slice(component, entries):
|
||||
return ((
|
||||
DEVICE_PLANE_ROOT,
|
||||
@@ -14940,6 +15100,7 @@ def plan_artifact(artifact):
|
||||
device_plane_b2_ingress_preflight = None
|
||||
device_plane_b2_recovery_preflight = None
|
||||
device_plane_backhaul_preflight = None
|
||||
device_plane_backhaul_vps_enrollment_preflight = None
|
||||
device_plane_runtime_before = None
|
||||
composite_provider_v4_preflight = None
|
||||
provider_rotating_slot_preflight = None
|
||||
@@ -15149,6 +15310,15 @@ def plan_artifact(artifact):
|
||||
device_plane_backhaul_preflight = (
|
||||
validate_device_plane_backhaul_target_evidence(payload_dir)
|
||||
)
|
||||
if is_device_plane_backhaul_vps_enrollment_slice(
|
||||
manifest["component"],
|
||||
entries,
|
||||
):
|
||||
device_plane_backhaul_vps_enrollment_preflight = (
|
||||
validate_device_plane_backhaul_vps_enrollment_evidence(
|
||||
payload_dir
|
||||
)
|
||||
)
|
||||
|
||||
component = manifest["component"]
|
||||
root = component_root(component)
|
||||
@@ -16677,6 +16847,55 @@ def plan_artifact(artifact):
|
||||
)
|
||||
print("device_gateway_tcp_9921=disabled:unpublished")
|
||||
print("device_plane_rollback=marker-only-runtime-unchanged")
|
||||
if device_plane_backhaul_vps_enrollment_preflight is not None:
|
||||
print(
|
||||
"device_plane_transition="
|
||||
f"{device_plane_backhaul_vps_enrollment_preflight['mode']}"
|
||||
)
|
||||
print(
|
||||
"device_plane_predecessor_patch="
|
||||
f"{DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_PATCH_ID}"
|
||||
)
|
||||
print(
|
||||
"device_plane_predecessor_artifact_sha256="
|
||||
f"{DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_ARTIFACT_SHA256}"
|
||||
)
|
||||
print("device_plane_build=none")
|
||||
print(
|
||||
"device_plane_runtime_mutation="
|
||||
"rotate-authorized-key+recreate:device-backhaul-target"
|
||||
)
|
||||
print(
|
||||
"device_plane_runtime_services="
|
||||
"preserved:device-control-core,device-gateway,device-postgres"
|
||||
)
|
||||
print(
|
||||
"device_backhaul_previous_enrollment_public_key_sha256="
|
||||
f"{device_plane_backhaul_vps_enrollment_preflight['previousEnrollmentPublicKeySha256']}"
|
||||
)
|
||||
print(
|
||||
"device_backhaul_next_enrollment_public_key_sha256="
|
||||
f"{device_plane_backhaul_vps_enrollment_preflight['nextEnrollmentPublicKeySha256']}"
|
||||
)
|
||||
print(
|
||||
"device_backhaul_next_key_fingerprint="
|
||||
f"{device_plane_backhaul_vps_enrollment_preflight['nextKeyFingerprint']}"
|
||||
)
|
||||
print(
|
||||
"device_backhaul_permitopen="
|
||||
f"{DEVICE_PLANE_BACKHAUL_PERMITTED_TARGET}"
|
||||
)
|
||||
print("device_backhaul_docker_port_publication=disabled")
|
||||
print("device_backhaul_tailscale_serve=unchanged")
|
||||
print("device_backhaul_tailscale_funnel=disabled")
|
||||
print("device_backhaul_router_nat_firewall=unchanged")
|
||||
print("device_edge_public_ingress=disabled")
|
||||
print("device_command_transport=disabled")
|
||||
print("gelios=untouched")
|
||||
print(
|
||||
"device_plane_rollback="
|
||||
"restore-previous-authorized-key+recreate-target"
|
||||
)
|
||||
if device_plane_backhaul_preflight is not None:
|
||||
print(
|
||||
"device_plane_transition="
|
||||
@@ -16849,6 +17068,25 @@ def create_backup(root, backup_dir, entries, include_nginx_html):
|
||||
(backup_dir / "missing-files.txt").write_text("\n".join(missing) + ("\n" if missing else ""), encoding="utf-8")
|
||||
|
||||
|
||||
def backup_device_plane_backhaul_authorized_keys(backup_dir):
|
||||
source_stat = DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE.lstat()
|
||||
if (
|
||||
stat.S_ISLNK(source_stat.st_mode)
|
||||
or not stat.S_ISREG(source_stat.st_mode)
|
||||
or source_stat.st_uid != 0
|
||||
or stat.S_IMODE(source_stat.st_mode) != 0o444
|
||||
or source_stat.st_size > 2048
|
||||
):
|
||||
die("Device Plane backhaul authorized_keys backup source is unsafe")
|
||||
destination = backup_dir / DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_BACKUP
|
||||
if destination.exists() or destination.is_symlink():
|
||||
die("Device Plane backhaul authorized_keys backup collision")
|
||||
shutil.copy2(DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE, destination)
|
||||
os.chown(destination, 0, 0)
|
||||
destination.chmod(0o600)
|
||||
return sha256_file(destination)
|
||||
|
||||
|
||||
def read_backup_path_list(path):
|
||||
if not path.is_file():
|
||||
die(f"deploy backup path list missing: {path}")
|
||||
@@ -17216,6 +17454,53 @@ def rollback_device_plane_apply(
|
||||
return f"source+runtime-restored:{restored_count}"
|
||||
|
||||
|
||||
def rollback_device_plane_backhaul_vps_enrollment(
|
||||
root,
|
||||
backup_dir,
|
||||
entries,
|
||||
current_stamp,
|
||||
runtime_before,
|
||||
):
|
||||
restored_count = restore_platform_overlay(
|
||||
root,
|
||||
backup_dir,
|
||||
entries,
|
||||
current_stamp,
|
||||
)
|
||||
backup = backup_dir / DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_BACKUP
|
||||
backup_stat = backup.lstat()
|
||||
previous = read_device_plane_backhaul_enrollment_public_key()
|
||||
expected = (
|
||||
'restrict,port-forwarding,permitopen="127.0.0.1:9921" '
|
||||
f"{previous['line']}\n"
|
||||
)
|
||||
if (
|
||||
stat.S_ISLNK(backup_stat.st_mode)
|
||||
or not stat.S_ISREG(backup_stat.st_mode)
|
||||
or backup_stat.st_uid != 0
|
||||
or stat.S_IMODE(backup_stat.st_mode) != 0o600
|
||||
or backup_stat.st_size > 2048
|
||||
or backup.read_text(encoding="ascii") != expected
|
||||
):
|
||||
die("Device Plane VPS enrollment rollback backup mismatch")
|
||||
install_device_plane_backhaul_authorized_key(previous)
|
||||
run_compose(
|
||||
"device-plane",
|
||||
(DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,),
|
||||
entries,
|
||||
)
|
||||
run_healthchecks(
|
||||
"device-plane",
|
||||
entries,
|
||||
(DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,),
|
||||
)
|
||||
validate_device_plane_backhaul_target_runtime(
|
||||
runtime_before,
|
||||
expected_enrollment=previous,
|
||||
)
|
||||
return f"previous-key+target+source-restored:{restored_count}"
|
||||
|
||||
|
||||
def rollback_engine_apply(root, backup_dir, entries, current_stamp, runtime_started, applied_services):
|
||||
existing = read_backup_path_list(backup_dir / "existing-files.txt")
|
||||
missing = read_backup_path_list(backup_dir / "missing-files.txt")
|
||||
@@ -17764,6 +18049,32 @@ def run_engine_node_intelligence_compose(services, entries):
|
||||
)
|
||||
|
||||
|
||||
def install_device_plane_backhaul_authorized_key(enrollment):
|
||||
DEVICE_PLANE_BACKHAUL_SECRET_DIR.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
os.chown(DEVICE_PLANE_BACKHAUL_SECRET_DIR, 0, 0)
|
||||
DEVICE_PLANE_BACKHAUL_SECRET_DIR.chmod(0o700)
|
||||
authorized = (
|
||||
'restrict,port-forwarding,permitopen="127.0.0.1:9921" '
|
||||
f"{enrollment['line']}\n"
|
||||
)
|
||||
temporary = DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE.with_suffix(
|
||||
".installing"
|
||||
)
|
||||
if temporary.exists() or temporary.is_symlink():
|
||||
die("Device Plane backhaul authorized_keys staging path exists")
|
||||
temporary.write_text(authorized, encoding="ascii")
|
||||
os.chown(temporary, 0, 0)
|
||||
temporary.chmod(0o444)
|
||||
os.replace(temporary, DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE)
|
||||
expected_sha256 = hashlib.sha256(authorized.encode("ascii")).hexdigest()
|
||||
if sha256_file(DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE) != expected_sha256:
|
||||
die("Device Plane backhaul authorized key verification failed")
|
||||
return expected_sha256
|
||||
|
||||
|
||||
def ensure_device_plane_backhaul_target_state():
|
||||
enrollment = read_device_plane_backhaul_enrollment_public_key()
|
||||
DEVICE_PLANE_BACKHAUL_SECRET_DIR.mkdir(
|
||||
@@ -17818,19 +18129,9 @@ def ensure_device_plane_backhaul_target_state():
|
||||
os.chown(path, 0, 0)
|
||||
path.chmod(expected_mode)
|
||||
|
||||
authorized = (
|
||||
'restrict,port-forwarding,permitopen="127.0.0.1:9921" '
|
||||
f"{enrollment['line']}\n"
|
||||
expected_authorized_sha256 = install_device_plane_backhaul_authorized_key(
|
||||
enrollment
|
||||
)
|
||||
temporary = DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE.with_suffix(
|
||||
".installing"
|
||||
)
|
||||
if temporary.exists() or temporary.is_symlink():
|
||||
die("Device Plane backhaul authorized_keys staging path exists")
|
||||
temporary.write_text(authorized, encoding="ascii")
|
||||
os.chown(temporary, 0, 0)
|
||||
temporary.chmod(0o444)
|
||||
os.replace(temporary, DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE)
|
||||
|
||||
DEVICE_PLANE_BACKHAUL_TRUST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
os.chown(DEVICE_PLANE_BACKHAUL_TRUST_DIR, 0, 0)
|
||||
@@ -17846,9 +18147,6 @@ def ensure_device_plane_backhaul_target_state():
|
||||
public_temporary.chmod(0o444)
|
||||
os.replace(public_temporary, DEVICE_PLANE_BACKHAUL_HOST_PUBLIC_KEY_FILE)
|
||||
|
||||
expected_authorized_sha256 = hashlib.sha256(
|
||||
authorized.encode("ascii")
|
||||
).hexdigest()
|
||||
if (
|
||||
sha256_file(DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE)
|
||||
!= expected_authorized_sha256
|
||||
@@ -17929,6 +18227,14 @@ def prepare_component_runtime(component, entries=None):
|
||||
MAP_GATEWAY_SECRET_RE,
|
||||
"device plane identifier pepper",
|
||||
)
|
||||
if is_device_plane_backhaul_vps_enrollment_slice(
|
||||
component,
|
||||
entries,
|
||||
):
|
||||
install_device_plane_backhaul_authorized_key(
|
||||
read_device_plane_backhaul_vps_enrollment_public_key()
|
||||
)
|
||||
return
|
||||
if is_device_plane_backhaul_target_slice(component, entries):
|
||||
ensure_device_plane_backhaul_target_state()
|
||||
return
|
||||
@@ -18685,6 +18991,17 @@ process.stdout.write('engine-l2-closed-loop:0.7.0:cas+safe-profile+external-plan
|
||||
|
||||
|
||||
def run_healthchecks(component, entries=None, services=None):
|
||||
if is_device_plane_backhaul_vps_enrollment_slice(component, entries):
|
||||
if tuple(services or ()) != (DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,):
|
||||
die("Device Plane VPS enrollment service set mismatch")
|
||||
for service in DEVICE_PLANE_RUNTIME_SERVICES:
|
||||
healthcheck_compose_service("device-plane", service)
|
||||
healthcheck_compose_service(
|
||||
"device-plane",
|
||||
DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,
|
||||
)
|
||||
return
|
||||
|
||||
if is_device_plane_backhaul_target_slice(component, entries):
|
||||
if tuple(services or ()) != (DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,):
|
||||
die("Device Plane backhaul target service set mismatch")
|
||||
@@ -19551,6 +19868,7 @@ def apply_artifact(artifact):
|
||||
node_intelligence_descriptor = None
|
||||
l2_closed_loop_preflight = None
|
||||
device_plane_backhaul_preflight = None
|
||||
device_plane_backhaul_vps_enrollment_preflight = None
|
||||
node_intelligence_service_stopped = False
|
||||
apply_started = False
|
||||
engine_backend_recreated = False
|
||||
@@ -19628,6 +19946,15 @@ def apply_artifact(artifact):
|
||||
payload_dir
|
||||
)
|
||||
)
|
||||
if is_device_plane_backhaul_vps_enrollment_slice(
|
||||
component,
|
||||
entries,
|
||||
):
|
||||
device_plane_backhaul_vps_enrollment_preflight = (
|
||||
validate_device_plane_backhaul_vps_enrollment_evidence(
|
||||
payload_dir
|
||||
)
|
||||
)
|
||||
if not root.is_dir():
|
||||
if bootstrap_root:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
@@ -19955,6 +20282,13 @@ def apply_artifact(artifact):
|
||||
encoding="utf-8",
|
||||
)
|
||||
runtime_inventory_path.chmod(0o600)
|
||||
if (
|
||||
device_plane_backhaul_vps_enrollment_preflight
|
||||
is not None
|
||||
):
|
||||
backup_device_plane_backhaul_authorized_keys(
|
||||
backup_dir
|
||||
)
|
||||
if device_plane_backhaul_preflight is not None:
|
||||
tailscale_before_path = (
|
||||
backup_dir / "tailscale-serve-before.json"
|
||||
@@ -20067,6 +20401,21 @@ def apply_artifact(artifact):
|
||||
validate_device_plane_backhaul_target_runtime(
|
||||
device_plane_runtime_before
|
||||
)
|
||||
if is_device_plane_backhaul_vps_enrollment_slice(
|
||||
component,
|
||||
entries,
|
||||
):
|
||||
if device_plane_runtime_before is None:
|
||||
die(
|
||||
"Device Plane VPS enrollment predecessor runtime "
|
||||
"inventory is missing"
|
||||
)
|
||||
validate_device_plane_backhaul_target_runtime(
|
||||
device_plane_runtime_before,
|
||||
expected_enrollment=(
|
||||
read_device_plane_backhaul_vps_enrollment_public_key()
|
||||
),
|
||||
)
|
||||
|
||||
applied_path = move_artifact(artifact, APPLIED_DIR)
|
||||
append_jsonl(STATE_FILE, {
|
||||
@@ -20305,6 +20654,41 @@ def apply_artifact(artifact):
|
||||
"automatic-rollback=failed",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif (
|
||||
is_device_plane_backhaul_vps_enrollment_slice(
|
||||
component,
|
||||
entries,
|
||||
)
|
||||
and device_plane_runtime_before is not None
|
||||
):
|
||||
try:
|
||||
restored_state = (
|
||||
rollback_device_plane_backhaul_vps_enrollment(
|
||||
root,
|
||||
backup_dir,
|
||||
entries,
|
||||
current_stamp,
|
||||
device_plane_runtime_before,
|
||||
)
|
||||
)
|
||||
rollback_status = (
|
||||
"ok:device-plane-vps-enrollment:"
|
||||
f"{restored_state}"
|
||||
)
|
||||
print(
|
||||
"device-plane-vps-enrollment-"
|
||||
f"automatic-rollback={rollback_status}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
except Exception as rollback_exc:
|
||||
rollback_status = (
|
||||
f"failed:{type(rollback_exc).__name__}"
|
||||
)
|
||||
print(
|
||||
"device-plane-vps-enrollment-"
|
||||
"automatic-rollback=failed",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif (
|
||||
component == "device-plane"
|
||||
and entries is not None
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
#!/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 require_runtime_cache(self):
|
||||
for name, digest in (
|
||||
(RUNNER.NODE_ARCHIVE, RUNNER.NODE_ARCHIVE_SHA256),
|
||||
(RUNNER.TAILSCALE_ARCHIVE, RUNNER.TAILSCALE_ARCHIVE_SHA256),
|
||||
):
|
||||
path = DEFAULT_RUNTIME_CACHE / name
|
||||
self.assertTrue(path.is_file(), f"missing runtime fixture: {path}")
|
||||
self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), digest)
|
||||
|
||||
def test_builders_are_deterministic_narrow_and_secret_free(self):
|
||||
self.require_runtime_cache()
|
||||
for phase in ("foundation", "backhaul", "relay"):
|
||||
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 ("foundation", "backhaul", "relay"):
|
||||
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)
|
||||
|
||||
def test_units_and_firewalls_keep_the_required_boundaries(self):
|
||||
source_root = SCRIPT_DIR.parent.parent / "device-plane"
|
||||
foundation = (source_root / "vps/config/nftables-foundation.conf").read_text()
|
||||
relay = (source_root / "vps/config/nftables-relay.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()
|
||||
|
||||
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("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)
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -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 = SCRIPT_DIR / "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)
|
||||
Reference in New Issue
Block a user