feat(device-edge): add isolated B2 ingress domain
This commit is contained in:
@@ -20,6 +20,56 @@ The runner accepts data-only app-overlay artifacts from:
|
||||
/volume1/docker/nodedc-deploy/inbox
|
||||
```
|
||||
|
||||
## Dedicated Device Edge runner
|
||||
|
||||
The Debian Device Edge is a separate root-owned deployment domain. It does not
|
||||
use the Synology runner, inbox, state or backup tree. Its live runner and fixed
|
||||
roots are:
|
||||
|
||||
```text
|
||||
/usr/local/sbin/nodedc-edge-deploy
|
||||
/home/ndcsudo/nodedc-device-edge/deploy/inbox
|
||||
/home/ndcsudo/nodedc-device-edge/source
|
||||
/var/lib/nodedc-edge-deploy
|
||||
```
|
||||
|
||||
`nodedc-edge-deploy` accepts only `component=device-edge`, validates an exact
|
||||
five-entry payload and can build/recreate only `device-edge-relay`. The existing
|
||||
`device-edge-backhaul` and `tailnet` containers are identity-snapshotted before
|
||||
the transition and must remain byte-for-byte runtime-equivalent through apply
|
||||
or automatic rollback. It has no registry entry in the Synology runner.
|
||||
|
||||
The first ingress transition uses a Docker IPvlan L2 address on the Mini's
|
||||
single Ethernet parent. It publishes no Docker host port and preserves the
|
||||
Amnezia host full tunnel; only the relay container receives a LAN-routable
|
||||
address. The fixed IPv4 is a runner/Compose/descriptor constant:
|
||||
`192.168.71.253`. Router evidence on 2026-08-04 proves the Deco DHCP pool is
|
||||
`192.168.68.50` through `192.168.71.250`, so the address is explicitly outside
|
||||
the pool. Router port-forwarding/firewall remains a separate manual gate.
|
||||
|
||||
Build and test the transition source:
|
||||
|
||||
```bash
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
python3 infra/deploy-runner/test_device_edge_ingress_artifact.py
|
||||
npm test --prefix device-plane
|
||||
```
|
||||
|
||||
After the fixed address is approved, build the production artifact with a fresh
|
||||
transition id, stage it into the Edge inbox, then use only the canonical pair:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/nodedc-edge-deploy plan \
|
||||
/home/ndcsudo/nodedc-device-edge/deploy/inbox/<artifact>.tgz
|
||||
sudo /usr/local/sbin/nodedc-edge-deploy apply \
|
||||
/home/ndcsudo/nodedc-device-edge/deploy/inbox/<artifact>.tgz
|
||||
```
|
||||
|
||||
The apply acceptance checks the exact IPvlan parent/subnet/gateway/address,
|
||||
absence of host port publication, internal relay health, private backhaul
|
||||
reachability, preserved VPN routes and unchanged backhaul/tailnet container
|
||||
identity. Gelios and Device Plane command transport are outside this domain.
|
||||
|
||||
Supported components in this source:
|
||||
|
||||
- `engine`
|
||||
|
||||
@@ -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 = resolve(platformRoot, "device-plane");
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-edge-ingress-ipvlan-20260804-001",
|
||||
...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-ingress-ipvlan-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: "single-nic-ipvlan-b2-relay-only",
|
||||
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",
|
||||
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-ingress-ipvlan-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"',
|
||||
"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.ingress-ipvlan.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",
|
||||
protocolInspection: "gateway-owned",
|
||||
identityTrust: "claimed-not-ownership-proof",
|
||||
discoveryLifecycle: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
amneziaHostFullTunnel: "preserved",
|
||||
routerNatFirewall: "separate-manual-gate",
|
||||
rollback: "restore-predecessor-relay-remove-unused-ingress-network",
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ const files = [
|
||||
"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"]);
|
||||
|
||||
@@ -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 = resolve(platformRoot, "device-plane");
|
||||
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 = resolve(platformRoot, "device-plane");
|
||||
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));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,854 @@
|
||||
#!/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-ingress-ipvlan-v1.json",
|
||||
)
|
||||
|
||||
PAYLOAD_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_FILE_SHA256 = {
|
||||
"docker-compose.device-edge.yml":
|
||||
"50dc7366d7db451935dd4d67f76ec8b342b3a94be6d22176976db910981445d0",
|
||||
"services/device-edge-relay/Dockerfile":
|
||||
"f2f15b7618ac2ab3a1d4dd40041d06d1e9a695edcfc168d8835f9560b4897e70",
|
||||
"services/device-edge-relay/src/runtime.mjs":
|
||||
"ae8bf8b55603bab266b6fa6e9bc65c9f310a9d94a54db04e2130704e38622ffc",
|
||||
"services/device-edge-relay/src/server.mjs":
|
||||
"e4b051b74f934bd37322440e6a013fb6774a76607da08f9cc1e844fc109c83c1",
|
||||
}
|
||||
|
||||
PREDECESSOR_ABSENT = {
|
||||
"docker-compose.device-edge.ingress.yml",
|
||||
"deployment/device-edge-ingress-ipvlan-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.ingress-ipvlan.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",
|
||||
"protocolInspection": "gateway-owned",
|
||||
"identityTrust": "claimed-not-ownership-proof",
|
||||
"discoveryLifecycle": "quarantine",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"amneziaHostFullTunnel": "preserved",
|
||||
"routerNatFirewall": "separate-manual-gate",
|
||||
"rollback": "restore-predecessor-relay-remove-unused-ingress-network",
|
||||
}
|
||||
|
||||
|
||||
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-ingress-ipvlan-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 predecessor relay is not running")
|
||||
if container_health(relay) != "healthy":
|
||||
die("Device Edge predecessor relay is not healthy")
|
||||
environment = set(relay.get("Config", {}).get("Env") or [])
|
||||
if "DEVICE_EDGE_RELAY_INGRESS_ENABLED=false" not in environment:
|
||||
die("Device Edge predecessor ingress is not disabled")
|
||||
bindings = relay.get("HostConfig", {}).get("PortBindings") or {}
|
||||
expected = {"18221/tcp": [{"HostIp": "127.0.0.1", "HostPort": "18221"}]}
|
||||
if bindings != expected:
|
||||
die("Device Edge predecessor host publication mismatch")
|
||||
if INGRESS_NETWORK in (relay.get("NetworkSettings", {}).get("Networks") or {}):
|
||||
die("Device Edge predecessor unexpectedly uses ingress network")
|
||||
|
||||
|
||||
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()
|
||||
if arp_duplicate_detected(INGRESS_IPV4, INGRESS_PARENT):
|
||||
die("Device Edge ingress IPv4 duplicate detected")
|
||||
if run([DOCKER, "network", "inspect", INGRESS_NETWORK], check=False).returncode == 0:
|
||||
die("Device Edge ingress network already exists")
|
||||
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",
|
||||
}
|
||||
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",
|
||||
}
|
||||
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,
|
||||
baseline=True,
|
||||
), cwd=LIVE_ROOT, timeout=300, capture=False)
|
||||
wait_healthy(RELAY_CONTAINER)
|
||||
run([DOCKER, "network", "rm", INGRESS_NETWORK], check=False)
|
||||
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_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-predecessor-relay-remove-unused-ingress-network")
|
||||
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("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,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-ingress-ipvlan-20260804-001",
|
||||
)
|
||||
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-ingress-ipvlan-20260804-002",
|
||||
)
|
||||
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-ingress-ipvlan-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)
|
||||
@@ -98,6 +98,22 @@ class DevicePlaneB2DiscoveryIngressArtifactTest(unittest.TestCase):
|
||||
.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(
|
||||
@@ -108,6 +124,24 @@ class DevicePlaneB2DiscoveryIngressArtifactTest(unittest.TestCase):
|
||||
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.assertIn(
|
||||
"COPY packages/arusnavi-b2-adapter "
|
||||
"./packages/arusnavi-b2-adapter",
|
||||
dockerfile,
|
||||
)
|
||||
|
||||
def test_compose_opens_only_discovery_tcp_and_preserves_database(self):
|
||||
compose = COMPOSE.read_text(encoding="utf-8")
|
||||
@@ -153,6 +187,48 @@ class DevicePlaneB2DiscoveryIngressArtifactTest(unittest.TestCase):
|
||||
"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",
|
||||
|
||||
@@ -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 = SCRIPT_DIR / "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,649 @@
|
||||
#!/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 = SCRIPT_DIR / "nodedc-deploy"
|
||||
COMPOSE = (
|
||||
SCRIPT_DIR.parent.parent
|
||||
/ "device-plane/docker-compose.device-plane.backhaul-target.yml"
|
||||
)
|
||||
PREDECESSOR_COMPOSE = (
|
||||
SCRIPT_DIR.parent.parent / "device-plane/docker-compose.device-plane.yml"
|
||||
)
|
||||
SSHD_CONFIG = (
|
||||
SCRIPT_DIR.parent.parent
|
||||
/ "device-plane/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()
|
||||
Reference in New Issue
Block a user