#!/usr/bin/env python3 """Canonical data-only deploy runner for the dedicated NODE.DC Device Edge.""" from __future__ import annotations import hashlib import json import os import re import select import shutil import socket import struct import subprocess import sys import tarfile import tempfile import time from datetime import datetime, timezone from pathlib import Path, PurePosixPath RUNNER_PATH = Path("/usr/local/sbin/nodedc-edge-deploy") LIVE_ROOT = Path("/home/ndcsudo/nodedc-device-edge/source") INBOX_ROOT = Path("/home/ndcsudo/nodedc-device-edge/deploy/inbox") STATE_ROOT = Path("/var/lib/nodedc-edge-deploy") APPLIED_ROOT = STATE_ROOT / "applied" FAILED_ROOT = STATE_ROOT / "failed" BACKUP_ROOT = STATE_ROOT / "backups" APPLIED_JOURNAL = STATE_ROOT / "state/applied.jsonl" FAILED_JOURNAL = STATE_ROOT / "state/failed.jsonl" DEPLOY_LOCK = STATE_ROOT / "state/deploy.lock" DOCKER = "/usr/bin/docker" COMPONENT = "device-edge" ARTIFACT_TYPE = "app-overlay" PATCH_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,96}$") MAX_ARTIFACT_BYTES = 16 * 1024 * 1024 COMPOSE_PROJECT = "nodedc-device-edge" BASE_COMPOSE = LIVE_ROOT / "docker-compose.device-edge.yml" INGRESS_COMPOSE = LIVE_ROOT / "docker-compose.device-edge.ingress.yml" RELAY_SERVICE = "device-edge-relay" RELAY_CONTAINER = "nodedc-device-edge-device-edge-relay-1" BACKHAUL_CONTAINER = "nodedc-device-edge-device-edge-backhaul-1" TAILNET_CONTAINER = "nodedc-device-edge-tailnet-1" RELAY_IMAGE = "nodedc/device-edge-relay:local" INGRESS_PARENT = "enp1s0f0" INGRESS_SUBNET = "192.168.68.0/22" INGRESS_GATEWAY = "192.168.68.1" INGRESS_IPV4 = "192.168.71.253" INGRESS_PORT = 9921 INGRESS_NETWORK = "nodedc-device-edge-ingress" INGRESS_IPV4_APPROVED = True INGRESS_IPV4_APPROVAL = "approved-outside-dhcp-pool" ENTRIES = ( "docker-compose.device-edge.yml", "docker-compose.device-edge.ingress.yml", "services/device-edge-relay/Dockerfile", "services/device-edge-relay/src", "deployment/device-edge-admission-gate-v1.json", ) PAYLOAD_FILE_SHA256 = { "docker-compose.device-edge.yml": "666945ffd9512355e610ecd36a9df96936477315150555def93e0243e8ff1e22", "docker-compose.device-edge.ingress.yml": "11bedfd7fdea749ca1bdb3b35b9c136c86b330f51a9001f0b38c4618f6f96108", "services/device-edge-relay/Dockerfile": "f2f15b7618ac2ab3a1d4dd40041d06d1e9a695edcfc168d8835f9560b4897e70", "services/device-edge-relay/src/runtime.mjs": "21e83678980aa61127bf9f3d77982dd485c4aaae208c43818db7bb1cc150b83a", "services/device-edge-relay/src/server.mjs": "1b99ec944f1d3fbadded045b159f08624e2829620cec39a97f6b4b8cdcd2be22", "deployment/device-edge-admission-gate-v1.json": "e6c1f21ff297b451c42b6746bc2063484874435dfa9f1614410a7cbe84f0ce6f", } PREDECESSOR_FILE_SHA256 = { "docker-compose.device-edge.yml": "7f13c11d6d4d541964053c0a8cf791e401947d34c42e0f7c26f9f9df26fa00b5", "docker-compose.device-edge.ingress.yml": "a4afd04755530fc3b9be64d1a65f0f7282a9539bcc1985bfd880aa904e1c4d8f", "services/device-edge-relay/Dockerfile": "f2f15b7618ac2ab3a1d4dd40041d06d1e9a695edcfc168d8835f9560b4897e70", "services/device-edge-relay/src/runtime.mjs": "ae8bf8b55603bab266b6fa6e9bc65c9f310a9d94a54db04e2130704e38622ffc", "services/device-edge-relay/src/server.mjs": "e4b051b74f934bd37322440e6a013fb6774a76607da08f9cc1e844fc109c83c1", "deployment/device-edge-ingress-ipvlan-v1.json": "b9ce402db0c059a76f07a8d4a34297aff2250fd1c0d1aed9970b3a88f4e75d7f", } PREDECESSOR_ABSENT = { "deployment/device-edge-admission-gate-v1.json", } class DeployError(RuntimeError): pass def die(message: str) -> None: raise DeployError(message) def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def run(command, *, check=True, capture=True, cwd=None, timeout=180): result = subprocess.run( [str(value) for value in command], check=False, capture_output=capture, text=True, cwd=str(cwd) if cwd else None, timeout=timeout, ) if check and result.returncode != 0: detail = (result.stderr or result.stdout or "command failed").strip() die(f"command failed: {command[0]}: {detail}") return result def docker_json(*args): result = run([DOCKER, *args]) try: return json.loads(result.stdout) except json.JSONDecodeError as error: die(f"Docker JSON response invalid: {error}") def expected_descriptor(): return { "schemaVersion": "nodedc.device-edge.admission-gate.v1", "mode": "single-nic-ipvlan-b2-relay-only", "runtimeHost": "ndcmini12", "component": COMPONENT, "selectedServices": [RELAY_SERVICE], "preservedServices": ["device-edge-backhaul", "tailnet"], "composeProject": COMPOSE_PROJECT, "composeFiles": [ "docker-compose.device-edge.yml", "docker-compose.device-edge.ingress.yml", ], "parentInterface": INGRESS_PARENT, "lanSubnet": INGRESS_SUBNET, "lanGateway": INGRESS_GATEWAY, "ingressIpv4": INGRESS_IPV4, "ingressIpv4Approval": INGRESS_IPV4_APPROVAL, "ingressNetwork": INGRESS_NETWORK, "deviceTcpListen": f"{INGRESS_IPV4}:{INGRESS_PORT}", "hostPortPublication": "disabled", "healthPublication": "disabled", "privateUpstream": "device-edge-backhaul:19921", "sourceAdmission": "public-ipv4-only", "maxTrackedSourceAddresses": 2048, "maxBytesPerDirection": 262144, "protocolInspection": "gateway-owned", "identityTrust": "claimed-not-ownership-proof", "discoveryLifecycle": "quarantine", "commandTransport": "disabled", "gelios": "untouched", "amneziaHostFullTunnel": "preserved", "routerNatFirewall": "separate-manual-gate", "rollback": "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation", } def assert_root(): if os.geteuid() != 0: die("nodedc-edge-deploy must run as root") def assert_regular_nonsymlink(path: Path, label: str): if not path.exists() or path.is_symlink() or not path.is_file(): die(f"{label} must be a regular non-symlink file") def parse_manifest(raw: str): values = {} for line in raw.splitlines(): if not line or "=" not in line: die("artifact manifest is malformed") key, value = line.split("=", 1) if key in values or key not in {"id", "component", "type"}: die("artifact manifest key set is invalid") values[key] = value if set(values) != {"id", "component", "type"}: die("artifact manifest key set is incomplete") if not PATCH_ID_RE.fullmatch(values["id"]): die("artifact patch id is invalid") if values["component"] != COMPONENT or values["type"] != ARTIFACT_TYPE: die("artifact component/type mismatch") return values def safe_tar_member(member: tarfile.TarInfo): path = PurePosixPath(member.name) if path.is_absolute() or ".." in path.parts or not path.parts: die("artifact contains an unsafe path") if not (member.isfile() or member.isdir()): die("artifact contains a non-file/non-directory member") lowered = {part.lower() for part in path.parts} if any( part.startswith(".env") or part in { ".git", "node_modules", "secrets", "keys", "trust", "runtime", "logs", "uploads", } for part in lowered ): die("artifact contains a forbidden boundary") if any(part.startswith("._") for part in path.parts): die("artifact contains AppleDouble metadata") def load_artifact(artifact: Path, extraction_root: Path): artifact = artifact.resolve(strict=True) if artifact.parent != INBOX_ROOT.resolve(strict=True): die("artifact must be an explicit file in the Device Edge inbox") assert_regular_nonsymlink(artifact, "artifact") if artifact.suffix != ".tgz" or artifact.stat().st_size > MAX_ARTIFACT_BYTES: die("artifact extension/size rejected") seen = set() with tarfile.open(artifact, "r:gz") as archive: for member in archive.getmembers(): safe_tar_member(member) if member.name in seen: die("artifact contains duplicate members") seen.add(member.name) required = {"manifest.env", "files.txt", "payload"} if not required.issubset(seen): die("artifact top-level contract is incomplete") if any(name.split("/", 1)[0] not in required for name in seen): die("artifact contains an unexpected top-level member") archive.extractall(extraction_root, filter="data") manifest = parse_manifest( (extraction_root / "manifest.env").read_text(encoding="utf-8") ) entries = tuple( line for line in (extraction_root / "files.txt").read_text(encoding="utf-8").splitlines() if line ) if entries != ENTRIES or len(entries) != len(set(entries)): die("Device Edge artifact file selection mismatch") payload = extraction_root / "payload" validate_payload(payload) return manifest, entries, payload, sha256_file(artifact), artifact def validate_payload(payload: Path): actual_files = { path.relative_to(payload).as_posix(): sha256_file(path) for path in payload.rglob("*") if path.is_file() } if actual_files != PAYLOAD_FILE_SHA256: die("Device Edge artifact payload digest set mismatch") descriptor = json.loads( (payload / "deployment/device-edge-admission-gate-v1.json") .read_text(encoding="utf-8") ) if descriptor != expected_descriptor(): die("Device Edge ingress descriptor mismatch") def journal_records(path: Path): if not path.exists(): return [] records = [] for line in path.read_text(encoding="utf-8").splitlines(): if not line: continue try: records.append(json.loads(line)) except json.JSONDecodeError: die(f"journal is malformed: {path}") return records def assert_new_identity(patch_id: str, artifact_sha256: str): records = journal_records(APPLIED_JOURNAL) + journal_records(FAILED_JOURNAL) if any(record.get("patch") == patch_id for record in records): die("Device Edge patch id is terminally recorded") if any(record.get("sha256") == artifact_sha256 for record in records): die("Device Edge artifact digest is terminally recorded") def current_source_state(): state = {} for relative, expected in PREDECESSOR_FILE_SHA256.items(): path = LIVE_ROOT / relative assert_regular_nonsymlink(path, f"predecessor {relative}") state[relative] = sha256_file(path) if state[relative] != expected: die(f"Device Edge predecessor drift: {relative}") for relative in PREDECESSOR_ABSENT: if (LIVE_ROOT / relative).exists(): die(f"Device Edge predecessor unexpected path: {relative}") return state def inspect_container(name: str): response = docker_json("inspect", name) if len(response) != 1: die(f"container inspect cardinality mismatch: {name}") return response[0] def container_health(container): health = container.get("State", {}).get("Health") return health.get("Status") if health else None def preserved_runtime_snapshot(): snapshot = {} for name in (BACKHAUL_CONTAINER, TAILNET_CONTAINER): container = inspect_container(name) if container.get("State", {}).get("Status") != "running": die(f"preserved Device Edge service is not running: {name}") if name == BACKHAUL_CONTAINER and container_health(container) != "healthy": die("Device Edge backhaul is not healthy") snapshot[name] = { "Id": container.get("Id"), "Image": container.get("Image"), "StartedAt": container.get("State", {}).get("StartedAt"), "RestartCount": container.get("RestartCount"), "PortBindings": container.get("HostConfig", {}).get("PortBindings"), } return snapshot def assert_preserved_runtime(snapshot): current_snapshot = preserved_runtime_snapshot() for name, expected in snapshot.items(): current = current_snapshot[name] if current != expected: die(f"preserved Device Edge runtime changed: {name}") def validate_predecessor_runtime(): relay = inspect_container(RELAY_CONTAINER) if relay.get("State", {}).get("Status") != "running": die("Device Edge IPvlan predecessor relay is not running") if container_health(relay) != "healthy": die("Device Edge IPvlan predecessor relay is not healthy") environment = set(relay.get("Config", {}).get("Env") or []) required = { "DEVICE_EDGE_RELAY_HEALTH_HOST=127.0.0.1", "DEVICE_EDGE_RELAY_INGRESS_ENABLED=true", "DEVICE_EDGE_RELAY_TCP_HOST=0.0.0.0", "DEVICE_EDGE_RELAY_TCP_PORT=9921", "DEVICE_EDGE_RELAY_UPSTREAM_HOST=device-edge-backhaul", "DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921", } if not required.issubset(environment): die("Device Edge IPvlan predecessor environment mismatch") bindings = relay.get("HostConfig", {}).get("PortBindings") or {} if bindings not in ({}, None): die("Device Edge IPvlan predecessor host publication mismatch") validate_network_runtime(relay) def validate_host_network_boundary(): if socket.gethostname() != "ndcmini12": die("Device Edge runtime host mismatch") route = run(["/usr/sbin/ip", "-4", "route", "show"]).stdout for line in ( "0.0.0.0/1 dev amn0 metric 1", "128.0.0.0/1 dev amn0 metric 1", "default via 192.168.68.1 dev enp1s0f0", "192.168.68.0/22 dev enp1s0f0", ): if line not in route: die(f"Device Edge host route boundary mismatch: {line}") if run(["/usr/bin/systemctl", "is-active", "AmneziaVPN.service"]).stdout.strip() != "active": die("AmneziaVPN must remain active for this transition") interface = run([ "/usr/sbin/ip", "-4", "-brief", "address", "show", "dev", INGRESS_PARENT, ]).stdout if "192.168.68.54/22" not in interface or "UP" not in interface: die("Device Edge physical interface boundary mismatch") def arp_duplicate_detected(target_ip: str, interface: str, attempts=3): protocol = 0x0806 raw = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(protocol)) try: raw.bind((interface, 0)) source_mac = raw.getsockname()[4] target = socket.inet_aton(target_ip) ethernet = b"\xff" * 6 + source_mac + struct.pack("!H", protocol) arp = struct.pack( "!HHBBH6s4s6s4s", 1, 0x0800, 6, 4, 1, source_mac, b"\x00" * 4, b"\x00" * 6, target, ) raw.setblocking(False) for _ in range(attempts): raw.send(ethernet + arp) deadline = time.monotonic() + 0.7 while time.monotonic() < deadline: ready, _, _ = select.select([raw], [], [], deadline - time.monotonic()) if not ready: break packet = raw.recv(2048) if len(packet) < 42 or packet[12:14] != b"\x08\x06": continue if packet[28:32] == target and packet[22:28] != source_mac: return True return False finally: raw.close() def preflight(manifest, artifact_sha256): if not INGRESS_IPV4_APPROVED: die("Device Edge ingress IPv4 approval is not granted") if INGRESS_IPV4_APPROVAL != "approved-outside-dhcp-pool": die("Device Edge ingress IPv4 approval contract mismatch") assert_new_identity(manifest["id"], artifact_sha256) current_source_state() validate_predecessor_runtime() preserved = preserved_runtime_snapshot() validate_host_network_boundary() return preserved def compose_command(*args, baseline=False): command = [ DOCKER, "compose", "--project-name", COMPOSE_PROJECT, "--file", str(BASE_COMPOSE), ] if not baseline: command.extend(["--file", str(INGRESS_COMPOSE)]) command.extend(args) return command def ensure_state_directories(): for path in ( APPLIED_ROOT, FAILED_ROOT, BACKUP_ROOT, APPLIED_JOURNAL.parent, ): path.mkdir(parents=True, exist_ok=True, mode=0o750) os.chmod(path, 0o750) def acquire_lock(): ensure_state_directories() try: descriptor = os.open( DEPLOY_LOCK, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, ) except FileExistsError: die("Device Edge deploy lock is present") os.write(descriptor, f"pid={os.getpid()}\n".encode()) os.close(descriptor) def release_lock(): try: DEPLOY_LOCK.unlink() except FileNotFoundError: pass def create_backup(patch_id: str): timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") backup_id = f"{patch_id}-{timestamp}" backup = BACKUP_ROOT / backup_id backup.mkdir(parents=False, mode=0o750) present = [] absent = [] for relative in ENTRIES: source = LIVE_ROOT / relative target = backup / "payload" / relative if not source.exists(): absent.append(relative) continue present.append(relative) target.parent.mkdir(parents=True, exist_ok=True) if source.is_dir(): shutil.copytree(source, target, symlinks=False) else: shutil.copy2(source, target, follow_symlinks=False) (backup / "backup.json").write_text(json.dumps({ "schemaVersion": "nodedc.device-edge.backup.v1", "patch": patch_id, "present": present, "absent": absent, }, sort_keys=True, indent=2) + "\n", encoding="utf-8") return backup_id, backup def publish_payload(payload: Path): for relative in ENTRIES: source = payload / relative target = LIVE_ROOT / relative if target.exists(): if target.is_dir(): shutil.rmtree(target) else: target.unlink() target.parent.mkdir(parents=True, exist_ok=True) if source.is_dir(): shutil.copytree(source, target, symlinks=False) else: shutil.copy2(source, target, follow_symlinks=False) def restore_backup(backup: Path): descriptor = json.loads((backup / "backup.json").read_text(encoding="utf-8")) for relative in ENTRIES: target = LIVE_ROOT / relative if target.exists(): if target.is_dir(): shutil.rmtree(target) else: target.unlink() for relative in descriptor["present"]: source = backup / "payload" / relative target = LIVE_ROOT / relative target.parent.mkdir(parents=True, exist_ok=True) if source.is_dir(): shutil.copytree(source, target, symlinks=False) else: shutil.copy2(source, target, follow_symlinks=False) def build_relay(): run([ DOCKER, "build", "--no-cache", "--network=host", "--file", "services/device-edge-relay/Dockerfile", "--tag", RELAY_IMAGE, ".", ], cwd=LIVE_ROOT, timeout=900, capture=False) def wait_healthy(name: str, timeout_seconds=150): deadline = time.monotonic() + timeout_seconds while time.monotonic() < deadline: try: container = inspect_container(name) except DeployError: time.sleep(2) continue if ( container.get("State", {}).get("Status") == "running" and container_health(container) == "healthy" ): return container if container.get("State", {}).get("Status") in {"exited", "dead"}: die(f"container stopped before health acceptance: {name}") time.sleep(2) die(f"container health timeout: {name}") def validate_network_runtime(relay): networks = relay.get("NetworkSettings", {}).get("Networks") or {} if set(networks) != {"nodedc-device-edge-private", INGRESS_NETWORK}: die("Device Edge relay network set mismatch") if networks[INGRESS_NETWORK].get("IPAddress") != INGRESS_IPV4: die("Device Edge relay IPvlan address mismatch") response = docker_json("network", "inspect", INGRESS_NETWORK) if len(response) != 1: die("Device Edge ingress network cardinality mismatch") network = response[0] if network.get("Driver") != "ipvlan" or network.get("Internal") is True: die("Device Edge ingress network driver mismatch") options = network.get("Options") or {} if options.get("parent") != INGRESS_PARENT or options.get("ipvlan_mode") != "l2": die("Device Edge ingress network option mismatch") configs = network.get("IPAM", {}).get("Config") or [] if len(configs) != 1: die("Device Edge ingress IPAM cardinality mismatch") if configs[0].get("Subnet") != INGRESS_SUBNET or configs[0].get("Gateway") != INGRESS_GATEWAY: die("Device Edge ingress IPAM mismatch") def validate_relay_runtime(preserved): relay = wait_healthy(RELAY_CONTAINER) if relay.get("Config", {}).get("User") != "1000:1000": die("Device Edge relay user mismatch") host = relay.get("HostConfig", {}) if host.get("ReadonlyRootfs") is not True or host.get("Privileged") is not False: die("Device Edge relay filesystem/privilege mismatch") if set(host.get("CapDrop") or []) != {"ALL"}: die("Device Edge relay capability mismatch") if host.get("PortBindings") not in ({}, None): die("Device Edge relay host port publication detected") environment = set(relay.get("Config", {}).get("Env") or []) required = { "DEVICE_EDGE_RELAY_HEALTH_HOST=127.0.0.1", "DEVICE_EDGE_RELAY_INGRESS_ENABLED=true", "DEVICE_EDGE_RELAY_TCP_HOST=0.0.0.0", "DEVICE_EDGE_RELAY_TCP_PORT=9921", "DEVICE_EDGE_RELAY_UPSTREAM_HOST=device-edge-backhaul", "DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921", "DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only", "DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES=2048", "DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION=262144", } if not required.issubset(environment): die("Device Edge relay environment mismatch") validate_network_runtime(relay) health_result = run([ DOCKER, "exec", RELAY_CONTAINER, "node", "-e", "fetch('http://127.0.0.1:18221/healthz').then(async r=>{if(!r.ok)process.exit(2);console.log(await r.text())}).catch(()=>process.exit(3))", ]) try: health = json.loads(health_result.stdout) except json.JSONDecodeError: die("Device Edge relay health JSON invalid") expected_health = { "ok": True, "service": "nodedc-device-edge-relay", "ingress": "relay-only", "protocolInspection": "disabled", "commandTransport": "disabled", "sourceAdmission": "public-ipv4-only", } for key, expected in expected_health.items(): if health.get(key) != expected: die(f"Device Edge relay health contract mismatch: {key}") run([ DOCKER, "exec", RELAY_CONTAINER, "node", "-e", "const n=require('node:net');const s=n.connect({host:'device-edge-backhaul',port:19921});s.setTimeout(5000);s.once('connect',()=>{s.destroy();process.exit(0)});s.once('timeout',()=>process.exit(2));s.once('error',()=>process.exit(3))", ]) validate_host_network_boundary() assert_preserved_runtime(preserved) def write_journal(path: Path, record): path.parent.mkdir(parents=True, exist_ok=True) with path.open("a", encoding="utf-8") as handle: handle.write(json.dumps(record, sort_keys=True) + "\n") def archive_artifact(artifact: Path, destination_root: Path): destination = destination_root / artifact.name if destination.exists(): die("Device Edge artifact archive collision") os.replace(artifact, destination) return destination def rollback(backup: Path, preserved): restore_backup(backup) run(compose_command( "up", "--detach", "--no-deps", "--force-recreate", "--pull", "never", RELAY_SERVICE, ), cwd=LIVE_ROOT, timeout=300, capture=False) wait_healthy(RELAY_CONTAINER) current_source_state() validate_predecessor_runtime() assert_preserved_runtime(preserved) def plan_artifact(artifact_argument: str): assert_root() artifact = Path(artifact_argument) with tempfile.TemporaryDirectory(prefix="nodedc-edge-plan-") as directory: manifest, entries, _payload, digest, resolved = load_artifact( artifact, Path(directory), ) preflight(manifest, digest) print("== plan ==") print(f"artifact={resolved.name}") print(f"sha256={digest}") print(f"id={manifest['id']}") print(f"component={COMPONENT}") print(f"type={ARTIFACT_TYPE}") print(f"payload_root={LIVE_ROOT}") print(f"compose_root={LIVE_ROOT}") print(f"compose_project={COMPOSE_PROJECT}") print("compose_files=docker-compose.device-edge.yml docker-compose.device-edge.ingress.yml") print("build=/usr/bin/docker build --no-cache --network=host -f services/device-edge-relay/Dockerfile -t nodedc/device-edge-relay:local .") print("services=device-edge-relay") print("preserved_services=device-edge-backhaul tailnet") print(f"device_edge_ingress=ipvlan:l2:{INGRESS_PARENT}:{INGRESS_IPV4}:{INGRESS_PORT}/tcp") print(f"device_edge_lan={INGRESS_SUBNET}:gateway:{INGRESS_GATEWAY}") print(f"device_edge_ingress_ipv4_approval={INGRESS_IPV4_APPROVAL}") print("device_edge_host_port_publication=disabled") print("device_edge_health_publication=disabled") print("device_edge_private_upstream=device-edge-backhaul:19921") print("device_edge_source_admission=public-ipv4-only") print("device_edge_source_table_limit=2048") print("device_edge_byte_limit_per_direction=262144") print("device_edge_command_transport=disabled") print("device_edge_discovery_lifecycle=quarantine") print("device_edge_gelios=untouched") print("device_edge_amnezia=preserved:active:host-full-tunnel") print("device_edge_router_nat_firewall=unchanged") print("device_edge_rollback=restore-reviewed-ipvlan-predecessor-no-router-mutation") print("state=new") print("== files ==") for entry in entries: print(f" {entry}") def apply_artifact(artifact_argument: str): assert_root() artifact = Path(artifact_argument) acquire_lock() manifest = None digest = None resolved = None backup_id = None backup = None preserved = None try: with tempfile.TemporaryDirectory(prefix="nodedc-edge-apply-") as directory: manifest, _entries, payload, digest, resolved = load_artifact( artifact, Path(directory), ) preserved = preflight(manifest, digest) backup_id, backup = create_backup(manifest["id"]) publish_payload(payload) build_relay() run(compose_command( "up", "--detach", "--no-deps", "--force-recreate", "--pull", "never", RELAY_SERVICE, ), cwd=LIVE_ROOT, timeout=300, capture=False) validate_relay_runtime(preserved) archived = archive_artifact(resolved, APPLIED_ROOT) write_journal(APPLIED_JOURNAL, { "status": "ok", "patch": manifest["id"], "component": COMPONENT, "sha256": digest, "artifact": archived.name, "backup": backup_id, "appliedAt": datetime.now(timezone.utc).isoformat(), }) print( f"deploy-ok patch={manifest['id']} component={COMPONENT} " f"backup={backup_id}" ) except Exception as error: rollback_status = "not-started" if backup is not None and preserved is not None: try: rollback(backup, preserved) rollback_status = "ok" except Exception as rollback_error: rollback_status = f"failed:{type(rollback_error).__name__}" if resolved is not None and resolved.exists(): failed_name = ( FAILED_ROOT / f"{resolved.name}.{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}" ) os.replace(resolved, failed_name) if manifest is not None and digest is not None: write_journal(FAILED_JOURNAL, { "status": "failed", "patch": manifest["id"], "component": COMPONENT, "sha256": digest, "backup": backup_id, "rollback": rollback_status, "error": type(error).__name__, "failedAt": datetime.now(timezone.utc).isoformat(), }) if rollback_status.startswith("failed"): die(f"apply failed and rollback failed: {error}") die(f"apply failed; automatic rollback={rollback_status}: {error}") finally: release_lock() def verify_install(): assert_root() path = RUNNER_PATH if RUNNER_PATH.exists() else Path(__file__).resolve() assert_regular_nonsymlink(path, "runner") docker_version = run([DOCKER, "version", "--format", "{{.Server.Version}}"]).stdout.strip() compose_version = run([DOCKER, "compose", "version", "--short"]).stdout.strip() print(f"path={path}") print(f"sha256={sha256_file(path)}") print(f"python={sys.version.split()[0]}") print(f"docker={docker_version}") print(f"compose={compose_version}") print(f"device_edge_ingress_ipv4={INGRESS_IPV4}") print(f"device_edge_ingress_ipv4_approval={INGRESS_IPV4_APPROVAL}") print("device_edge_source_admission=public-ipv4-only") print("verify-install-ok") def main(arguments): if len(arguments) == 1 and arguments[0] == "verify-install": verify_install() return 0 if len(arguments) == 2 and arguments[0] == "plan": plan_artifact(arguments[1]) return 0 if len(arguments) == 2 and arguments[0] == "apply": apply_artifact(arguments[1]) return 0 print( "usage: nodedc-edge-deploy verify-install | plan | apply ", 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)