334 lines
12 KiB
Python
334 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Versioned macOS telemetry startup: plan/apply/rollback, no worker job launch.
|
|
|
|
The prepared stack owns credentials and volumes. SSH provides only a loopback
|
|
MQTT forward through an existing strictly pinned Tailscale SSH profile.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import plistlib
|
|
import re
|
|
import subprocess
|
|
import time
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
from manage_mission_core_launch_agent import _wait_for_health, _write_atomic
|
|
from migrate_worker_tunnel_logs import reload_agent
|
|
|
|
from k1link.launchd_logs import launchd_log_path, prepare_launchd_log
|
|
from k1link.web.compute_contour_network import _replace_environment_value
|
|
|
|
STARTUP = "com.nodedc.telemetry-startup.local"
|
|
TUNNEL = "com.nodedc.telemetry-tunnel.local"
|
|
CORE = "com.nodedc.mission-core.local"
|
|
DOCKER = "/Applications/Docker.app/Contents/Resources/bin/docker"
|
|
|
|
|
|
def command(args, timeout=30):
|
|
# Never echo Compose output: expanded environments can contain credentials.
|
|
return subprocess.run(args, capture_output=True, timeout=timeout, check=False)
|
|
|
|
|
|
def receiver_ready():
|
|
try:
|
|
with urllib.request.urlopen("http://127.0.0.1:18030/health", timeout=3) as r:
|
|
d = json.loads(r.read())
|
|
return (
|
|
d.get("ok") is True
|
|
and d.get("mqtt_connected") is True
|
|
and d.get("database_reachable") is True
|
|
)
|
|
except (OSError, ValueError):
|
|
return False
|
|
|
|
|
|
def broker_loopback():
|
|
result = command(
|
|
[
|
|
DOCKER,
|
|
"inspect",
|
|
"--format",
|
|
"{{json .NetworkSettings.Ports}}",
|
|
"ndc-mission-core-mqtt-broker",
|
|
],
|
|
10,
|
|
)
|
|
try:
|
|
bindings = json.loads(result.stdout).get("1883/tcp", [])
|
|
return result.returncode == 0 and bindings == [{"HostIp": "127.0.0.1", "HostPort": "1883"}]
|
|
except (ValueError, AttributeError):
|
|
return False
|
|
|
|
|
|
def reconcile(stack: Path):
|
|
"""One bounded retry, repeated by launchd after delayed Docker/network start."""
|
|
if receiver_ready() and broker_loopback():
|
|
return "ready"
|
|
if command([DOCKER, "info", "--format", "{{.ServerVersion}}"], 10).returncode:
|
|
# Desktop's own login setting is not a dependency manager. No VM/resource
|
|
# setting is changed and no user inference job is started here.
|
|
command(["/usr/bin/open", "-g", "-a", "/Applications/Docker.app"], 10)
|
|
return "waiting-for-docker"
|
|
compose = [
|
|
DOCKER,
|
|
"compose",
|
|
"--project-directory",
|
|
str(stack),
|
|
"--env-file",
|
|
str(stack / ".env"),
|
|
"-f",
|
|
str(stack / "compose.yaml"),
|
|
]
|
|
if command([*compose, "config", "--quiet"], 20).returncode:
|
|
return "configuration-unavailable"
|
|
if command(
|
|
[
|
|
*compose,
|
|
"up",
|
|
"-d",
|
|
"--no-build",
|
|
"--pull",
|
|
"never",
|
|
"--wait",
|
|
"--wait-timeout",
|
|
"45",
|
|
"broker",
|
|
"timescale",
|
|
"normalizer",
|
|
],
|
|
60,
|
|
).returncode:
|
|
return "waiting-for-receiver"
|
|
return "ready" if receiver_ready() and broker_loopback() else "waiting-for-receiver"
|
|
|
|
|
|
def private_file(path):
|
|
if path.is_symlink() or not path.is_file() or path.stat().st_uid != os.getuid():
|
|
raise ValueError("Expected owned regular configuration file")
|
|
return path.read_bytes()
|
|
|
|
|
|
def plan(stack: Path, ssh_alias: str, repository: Path, agents: Path):
|
|
if not stack.is_absolute() or stack.resolve() != stack or not repository.is_absolute():
|
|
raise ValueError("Canonical absolute installation paths required")
|
|
if re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,80}", ssh_alias) is None:
|
|
raise ValueError("Invalid SSH profile")
|
|
for name in (
|
|
"compose.yaml",
|
|
"runtime/agents.json",
|
|
"runtime/mosquitto/acl",
|
|
"runtime/mosquitto/passwords",
|
|
):
|
|
private_file(stack / name)
|
|
env = private_file(stack / ".env")
|
|
public = dict(
|
|
line.split("=", 1)
|
|
for line in env.decode().splitlines()
|
|
if "=" in line and not line.startswith("#")
|
|
)
|
|
if (
|
|
public.get("MISSIONCORE_MQTT_PORT", "1883") != "1883"
|
|
or public.get("MISSIONCORE_TELEMETRY_QUERY_PORT", "18030") != "18030"
|
|
):
|
|
raise ValueError("This deployment profile requires MQTT 1883 and query 18030")
|
|
# Verify the existing trust boundary; never enroll a host or accept a new key.
|
|
ssh = command(["/usr/bin/ssh", "-G", ssh_alias])
|
|
settings = dict(line.split(" ", 1) for line in ssh.stdout.decode().splitlines() if " " in line)
|
|
if (
|
|
ssh.returncode
|
|
or settings.get("stricthostkeychecking") not in ("true", "yes")
|
|
or not settings.get("hostname", "").endswith(".ts.net")
|
|
):
|
|
raise ValueError("A strictly pinned Tailscale SSH profile is required")
|
|
python = repository / ".venv/bin/python"
|
|
if not python.is_file() or not Path(DOCKER).is_file():
|
|
raise ValueError("Prepared application Python and Docker Desktop are required")
|
|
common = dict(
|
|
RunAtLoad=True, AbandonProcessGroup=False, ProcessType="Background", ExitTimeOut=15
|
|
)
|
|
startup = dict(
|
|
common,
|
|
Label=STARTUP,
|
|
StartInterval=30,
|
|
ProgramArguments=[
|
|
str(python),
|
|
str(repository / "scripts/manage_telemetry_startup.py"),
|
|
"reconcile",
|
|
"--stack-root",
|
|
str(stack),
|
|
],
|
|
StandardOutPath="/dev/null",
|
|
StandardErrorPath=str(launchd_log_path("telemetry-startup.log")),
|
|
)
|
|
tunnel = dict(
|
|
common,
|
|
Label=TUNNEL,
|
|
KeepAlive=True,
|
|
ThrottleInterval=10,
|
|
ProgramArguments=[
|
|
"/usr/bin/ssh",
|
|
"-o",
|
|
"BatchMode=yes",
|
|
"-o",
|
|
"StrictHostKeyChecking=yes",
|
|
"-o",
|
|
"ExitOnForwardFailure=yes",
|
|
"-o",
|
|
"ConnectTimeout=8",
|
|
"-o",
|
|
"ServerAliveInterval=10",
|
|
"-o",
|
|
"ServerAliveCountMax=3",
|
|
"-N",
|
|
"-T",
|
|
"-R",
|
|
"127.0.0.1:1883:127.0.0.1:1883",
|
|
ssh_alias,
|
|
],
|
|
StandardOutPath="/dev/null",
|
|
StandardErrorPath=str(launchd_log_path("telemetry-tunnel.log")),
|
|
)
|
|
core_path = agents / (CORE + ".plist")
|
|
core = plistlib.loads(private_file(core_path))
|
|
if core.get("Label") != CORE or core.get("WorkingDirectory") != str(repository):
|
|
raise ValueError("Canonical Mission Core installation changed")
|
|
core["EnvironmentVariables"]["MISSIONCORE_TELEMETRY_PLANE_ROOT"] = str(stack)
|
|
desired = {
|
|
stack / ".env": _replace_environment_value(
|
|
env.decode(), "MISSIONCORE_MQTT_BIND_ADDRESS", "127.0.0.1"
|
|
).encode()
|
|
}
|
|
for label, doc in ((STARTUP, startup), (TUNNEL, tunnel), (CORE, core)):
|
|
path = agents / (label + ".plist")
|
|
if path.exists() and plistlib.loads(private_file(path)).get("Label") != label:
|
|
raise ValueError("Foreign LaunchAgent at target path")
|
|
desired[path] = plistlib.dumps(doc, sort_keys=True)
|
|
changes = []
|
|
for path, data in desired.items():
|
|
before = private_file(path) if path.exists() else None
|
|
changes.append(
|
|
dict(
|
|
path=str(path),
|
|
before=hashlib.sha256(before).hexdigest() if before is not None else None,
|
|
after=hashlib.sha256(data).hexdigest(),
|
|
)
|
|
)
|
|
document = dict(
|
|
schema_version="missioncore.telemetry-startup-plan/v1",
|
|
changes=changes,
|
|
stack_root=str(stack),
|
|
ssh_profile=ssh_alias,
|
|
mqtt="loopback-over-ssh-tailscale",
|
|
startup="macOS user login; retry every 30s",
|
|
worker_jobs_started=False,
|
|
)
|
|
document["artifact_sha256"] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
|
|
document["compose_sha256"] = hashlib.sha256((stack / "compose.yaml").read_bytes()).hexdigest()
|
|
document["sha256"] = hashlib.sha256(json.dumps(document, sort_keys=True).encode()).hexdigest()
|
|
return document, desired
|
|
|
|
|
|
def restore(backup: Path, agents: Path):
|
|
manifest = json.loads(private_file(backup / "manifest.json"))
|
|
for item in manifest["changes"]:
|
|
path = Path(item["path"])
|
|
current = hashlib.sha256(private_file(path)).hexdigest() if path.exists() else None
|
|
if current not in (item["before"], item["after"]):
|
|
raise ValueError("Installed configuration changed; refusing rollback")
|
|
for label in (STARTUP, TUNNEL):
|
|
command(["launchctl", "bootout", f"gui/{os.getuid()}/{label}"])
|
|
for i, item in enumerate(manifest["changes"]):
|
|
path = Path(item["path"])
|
|
if item["before"] is None:
|
|
path.unlink(missing_ok=True)
|
|
else:
|
|
old = private_file(backup / str(i))
|
|
if hashlib.sha256(old).hexdigest() != item["before"]:
|
|
raise ValueError("Rollback payload changed")
|
|
_write_atomic(path, old)
|
|
for label in (STARTUP, TUNNEL, CORE):
|
|
path = agents / (label + ".plist")
|
|
if path.exists():
|
|
reload_agent(path, label)
|
|
if not _wait_for_health(45):
|
|
raise RuntimeError("Core health not accepted after rollback")
|
|
return {
|
|
"restored": True,
|
|
"container_state": "not changed; declared endpoint restored on next reconciliation",
|
|
}
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument("action", choices=["plan", "apply", "reconcile", "rollback"])
|
|
p.add_argument("--stack-root", type=Path)
|
|
p.add_argument("--repository-root", type=Path, default=Path(__file__).resolve().parents[1])
|
|
p.add_argument("--ssh-alias", default="mission-gpu")
|
|
p.add_argument("--expected-sha256")
|
|
p.add_argument("--backup", type=Path)
|
|
a = p.parse_args()
|
|
agents = Path.home() / "Library/LaunchAgents"
|
|
if a.action == "rollback":
|
|
if a.backup is None:
|
|
p.error("--backup required")
|
|
print(json.dumps(restore(a.backup, agents)))
|
|
return
|
|
if a.stack_root is None:
|
|
p.error("--stack-root required")
|
|
if a.action == "reconcile":
|
|
try:
|
|
phase = reconcile(a.stack_root)
|
|
except (OSError, subprocess.SubprocessError, ValueError):
|
|
phase = "receiver-unavailable"
|
|
# No repeating stdout log; health remains independently observable.
|
|
print(json.dumps({"phase": phase}))
|
|
return
|
|
document, desired = plan(a.stack_root, a.ssh_alias, a.repository_root, agents)
|
|
if a.action == "plan":
|
|
print(json.dumps(document, indent=2))
|
|
return
|
|
if a.expected_sha256 != document["sha256"]:
|
|
raise ValueError("Startup plan changed before apply")
|
|
backup = (
|
|
a.repository_root
|
|
/ ".runtime/mission-core/telemetry-service-backups"
|
|
/ (str(time.time_ns()))
|
|
)
|
|
backup.mkdir(parents=True, mode=0o700)
|
|
_write_atomic(backup / "manifest.json", json.dumps(document).encode())
|
|
for i, path in enumerate(desired):
|
|
if path.exists():
|
|
_write_atomic(backup / str(i), private_file(path))
|
|
for name in ("telemetry-startup.log", "telemetry-tunnel.log"):
|
|
prepare_launchd_log(launchd_log_path(name))
|
|
try:
|
|
for path, data in desired.items():
|
|
_write_atomic(path, data)
|
|
for label in (STARTUP, TUNNEL, CORE):
|
|
reload_agent(agents / (label + ".plist"), label)
|
|
if not _wait_for_health(45):
|
|
raise RuntimeError("Canonical Core failed health acceptance")
|
|
except BaseException:
|
|
restore(backup, agents)
|
|
raise
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"installed": True,
|
|
"backup": str(backup),
|
|
"plan_sha256": document["sha256"],
|
|
"telemetry_acceptance": "pending fresh worker sample",
|
|
}
|
|
)
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|