Files
NODEDC_DEVICE_CORE/infra/deploy-runner/build-device-plane-backhaul-target-artifact.mjs

257 lines
8.1 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const sourceRoot = platformRoot;
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-plane-backhaul-target-tailnet-serve-20260804-002",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-plane-backhaul-target-artifact.mjs [patch-id]",
);
}
const files = [
"docker-compose.device-plane.backhaul-target.yml",
"services/device-backhaul-target",
"deployment/device-plane-backhaul-target-tailnet-serve-v1.json",
];
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
const stage = await mkdtemp(
join(tmpdir(), "nodedc-device-plane-backhaul-target-"),
);
const payload = join(stage, "payload");
const target = join(
artifactDir,
`nodedc-device-plane-${patchId}.tgz`,
);
await assertBoundary();
try {
await mkdir(payload, { recursive: true });
for (const sourceRelative of files) {
await copySafe(
resolve(sourceRoot, sourceRelative),
join(payload, sourceRelative),
);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{ encoding: "utf8", maxBuffer: 128 * 1024 * 1024 },
);
if (tar.status !== 0) {
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
}
const digest = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256: digest,
component: "device-plane",
transition: "failed-backhaul-target-to-loopback-tailnet-serve",
entries: files,
services: ["device-backhaul-target"],
preservedRuntime: [
"device-control-core",
"device-gateway",
"device-postgres",
"nodedc-device-plane-postgres-data",
"Gelios",
],
ingress: {
loopbackListen: "127.0.0.1:2222/tcp",
tailnetListen: "100.109.216.21:2222/tcp",
transport: "tailscale-serve-private-ssh",
serveTarget: "tcp://127.0.0.1:2222",
permittedTarget: "127.0.0.1:9921",
dockerPortPublication: "disabled",
routerNatFirewall: "unchanged",
edgePublicIngress: "disabled",
funnel: "disabled",
commandTransport: "disabled",
},
runtimeTrust: "runner-managed-not-in-artifact",
rollback: "remove-tailnet-serve-target-and-restore-source",
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertBoundary() {
const compose = await readFile(
resolve(sourceRoot, "docker-compose.device-plane.backhaul-target.yml"),
"utf8",
);
for (const fragment of [
"device-backhaul-target:",
"image: nodedc/device-backhaul-target:local",
"network_mode: host",
'"127.0.0.1", "2222"',
"/secrets/backhaul-target/ssh_host_ed25519_key",
"/secrets/backhaul-target/authorized_keys",
"no-new-privileges:true",
]) {
if (!compose.includes(fragment)) {
throw new Error(`device_plane_backhaul_boundary_missing:${fragment}`);
}
}
for (const forbidden of [
"PasswordAuthentication yes",
"0.0.0.0:2222",
"9921:9921/udp",
"DEVICE_GATEWAY_COMMAND",
]) {
if (compose.includes(forbidden)) {
throw new Error(`device_plane_backhaul_boundary_violation:${forbidden}`);
}
}
const sshd = await readFile(
resolve(sourceRoot, "services/device-backhaul-target/sshd_config"),
"utf8",
);
for (const fragment of [
"ListenAddress 127.0.0.1",
"PasswordAuthentication no",
"KbdInteractiveAuthentication no",
"AllowTcpForwarding local",
"PermitOpen 127.0.0.1:9921",
"GatewayPorts no",
"PermitTunnel no",
"AllowAgentForwarding no",
"PermitTTY no",
"ForceCommand /bin/false",
]) {
if (!sshd.includes(fragment)) {
throw new Error(`device_plane_backhaul_sshd_boundary_missing:${fragment}`);
}
}
const descriptor = JSON.parse(await readFile(
resolve(
sourceRoot,
"deployment/device-plane-backhaul-target-tailnet-serve-v1.json",
),
"utf8",
));
const expected = {
schemaVersion: "nodedc.device-plane.backhaul-target-tailnet-serve.v1",
mode: "failed-backhaul-target-to-loopback-tailnet-serve",
failedPatchId: "device-plane-backhaul-target-20260803-001",
failedArtifactSha256:
"ed0bda4110a756c32be68990e2e0f647409d5a77eec7e26c18502bafbdc1bb76",
failedBackupId:
"device-plane-device-plane-backhaul-target-20260803-001-20260804-035519",
predecessorPatchId:
"device-plane-b2-discovery-loopback-20260803-006",
predecessorArtifactSha256:
"25f9e9e55e283e9b7bb5e128ff14a244f848b1c063acca9724a23206131c9adf",
sourceAction: "publish-loopback-backhaul-target-source",
runtimeAction: "build-create-target-and-register-private-tailnet-serve",
composeOverlay: "docker-compose.device-plane.backhaul-target.yml",
selectedServices: ["device-backhaul-target"],
preservedServices: [
"device-control-core",
"device-gateway",
"device-postgres",
],
loopbackListenAddress: "127.0.0.1",
listenPort: 2222,
tailnetAddress: "100.109.216.21",
tailnetExposure: "tailscale-serve-private",
tailscaleServeTarget: "tcp://127.0.0.1:2222",
permittedTarget: "127.0.0.1:9921",
networkMode: "host",
dockerPortPublication: "disabled",
routerNatFirewall: "unchanged",
edgePublicIngress: "disabled",
funnel: "disabled",
commandTransport: "disabled",
gelios: "untouched",
databaseVolume: "nodedc-device-plane-postgres-data",
runtimeTrust: "runner-managed",
rollback: "remove-tailnet-serve-target-and-restore-source",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error("device_plane_backhaul_descriptor_mismatch");
}
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) {
throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`);
}
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
return;
}
if (!sourceStat.isDirectory()) {
throw new Error(`source_type_rejected:${source}`);
}
await mkdir(destination, { recursive: true });
for (const entry of await readdir(source, { withFileTypes: true })) {
if (
ignoredBasenames.has(entry.name)
|| entry.name.startsWith(".env")
|| entry.name.endsWith("~")
) {
continue;
}
await copySafe(join(source, entry.name), join(destination, entry.name));
}
}