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