Files
NODEDC_MISSION_CORE/scripts/manage_map_gateway_link.py
T
DCCONSTRUCTIONS be58d589e2 feat(fleet): add board observation center with live sources and maps
Add adaptive per-vehicle layouts, full-panel dragging, source controls and automatic preview recovery for RealSense, Insta360 X4 and XGRIDS K1 observation views. Integrate cached Cesium map layers through the private NAS gateway link, retain bounded sensor command receipts, and document validation and operational handoff.
2026-09-10 22:12:05 +03:00

162 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Plan/apply a loopback-only SSH link to the existing NAS Map Gateway.
No remote command, NAS configuration, provider credential or cache is changed.
The user LaunchAgent reconnects independently of the Core process.
"""
from __future__ import annotations
import argparse
import hashlib
import http.client
import json
import os
import plistlib
import re
import socket
import subprocess
import tempfile
import time
from pathlib import Path
LABEL = "com.nodedc.mission-core.map-gateway-link"
PORT = 18103
def payload(target: str, runtime: Path) -> bytes:
if not re.fullmatch(r"[a-zA-Z0-9_][a-zA-Z0-9_.-]*@[a-zA-Z0-9][a-zA-Z0-9.-]*", target):
raise ValueError("SSH target must be an explicit user@host")
return plistlib.dumps({
"Label": LABEL,
"ProgramArguments": [
"/usr/bin/ssh", "-N", "-T", "-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=yes", "-o", "ExitOnForwardFailure=yes",
"-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15",
"-o", "ServerAliveCountMax=3", "-o", "PermitLocalCommand=no",
"-o", "ForwardAgent=no", "-o", "ForwardX11=no",
"-L", f"127.0.0.1:{PORT}:127.0.0.1:{PORT}", target,
],
"RunAtLoad": True, "KeepAlive": True, "ThrottleInterval": 30,
"ExitTimeOut": 10,
"StandardOutPath": str(runtime / "stdout.log"),
"StandardErrorPath": str(runtime / "stderr.log"),
}, sort_keys=True)
def digest(value: bytes | None) -> str:
return hashlib.sha256(value).hexdigest() if value is not None else "absent"
def health() -> bool:
connection = http.client.HTTPConnection("127.0.0.1", PORT, timeout=2)
try:
connection.request("GET", "/healthz", headers={
"x-nodedc-user-id": "mission-core-loopback-operator",
})
response = connection.getresponse()
raw = response.read(65537)
if response.status != 200 or len(raw) > 65536:
return False
value = json.loads(raw)
return value.get("ok") is True and value.get("service") == "nodedc-map-gateway"
except (OSError, ValueError, http.client.HTTPException):
return False
finally:
connection.close()
def loaded() -> bool:
return subprocess.run(["launchctl", "print", f"gui/{os.getuid()}/{LABEL}"],
capture_output=True, timeout=5, check=False).returncode == 0
def unload() -> None:
subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}/{LABEL}"],
capture_output=True, timeout=20, check=False)
deadline = time.monotonic() + 20
while loaded():
if time.monotonic() > deadline:
raise RuntimeError("Map Gateway link did not finish unloading")
time.sleep(.2)
def write_atomic(path: Path, value: bytes) -> None:
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".map-link-")
try:
with os.fdopen(fd, "wb") as stream:
stream.write(value)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
Path(temporary).unlink(missing_ok=True)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["plan", "apply", "status"])
parser.add_argument("--target")
parser.add_argument("--expected-current-sha256")
parser.add_argument("--expected-desired-sha256")
args = parser.parse_args()
if args.action == "status":
print(json.dumps({"loaded": loaded(), "healthy": health()}))
return
if not args.target:
parser.error("--target is required")
runtime = Path.home() / "Library/Logs/NODEDC/MissionCore/map-gateway-link"
agent = Path.home() / "Library/LaunchAgents" / f"{LABEL}.plist"
if agent.is_symlink():
raise ValueError("Refusing a symlink LaunchAgent")
previous = agent.read_bytes() if agent.exists() else None
if previous is not None and plistlib.loads(previous).get("Label") != LABEL:
raise ValueError("Refusing a foreign LaunchAgent")
desired = payload(args.target, runtime)
plan = {"label": LABEL, "agent_path": str(agent), "target": args.target,
"local_bind": f"127.0.0.1:{PORT}", "remote_bind": f"127.0.0.1:{PORT}",
"current_sha256": digest(previous), "desired_sha256": digest(desired),
"remote_mutation": False}
if args.action == "plan":
print(json.dumps(plan, indent=2))
return
if (args.expected_current_sha256 != digest(previous)
or args.expected_desired_sha256 != digest(desired)):
raise ValueError("Map Gateway link plan changed before apply")
was_loaded = loaded()
if previous == desired and was_loaded and health():
print(json.dumps({**plan, "applied": False, "healthy": True}))
return
if not was_loaded:
with socket.socket() as probe:
probe.bind(("127.0.0.1", PORT)) # Refuse an unrelated local listener.
runtime.mkdir(parents=True, exist_ok=True, mode=0o700)
agent.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if previous is not None:
write_atomic(runtime / f"backup-{digest(previous)}.plist", previous)
try:
if was_loaded:
unload()
write_atomic(agent, desired)
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(agent)],
capture_output=True, timeout=20, check=True)
deadline = time.monotonic() + 40
while not health():
if time.monotonic() > deadline:
raise RuntimeError("Map Gateway link did not pass health acceptance")
time.sleep(.5)
except BaseException:
unload()
if previous is not None:
write_atomic(agent, previous)
if was_loaded:
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(agent)],
capture_output=True, timeout=20, check=True)
else:
agent.unlink(missing_ok=True)
raise
print(json.dumps({**plan, "applied": True, "healthy": True}, indent=2))
if __name__ == "__main__":
main()