fix(telemetry): recover worker agent and Tailscale transport automatically
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user