fix(service): supervise canonical Mission Core lifecycle
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plan/apply the user-owned Mission Core LaunchAgent with rollback acceptance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.local_service_launchd import (
|
||||
MISSION_CORE_LAUNCH_AGENT_LABEL,
|
||||
MissionCoreLaunchAgentError,
|
||||
plan_mission_core_launch_agent,
|
||||
)
|
||||
|
||||
_LAUNCHCTL_TRANSITION_TIMEOUT_SECONDS = 30.0
|
||||
_LAUNCHCTL_TRANSITION_POLL_SECONDS = 0.1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("action", choices=("plan", "apply", "status"))
|
||||
parser.add_argument("--repository-root", type=Path, required=True)
|
||||
parser.add_argument(
|
||||
"--agent-path",
|
||||
type=Path,
|
||||
default=Path.home()
|
||||
/ "Library/LaunchAgents/com.nodedc.mission-core.local.plist",
|
||||
)
|
||||
parser.add_argument("--expected-current-sha256")
|
||||
parser.add_argument("--expected-desired-sha256")
|
||||
arguments = parser.parse_args()
|
||||
if arguments.action == "status":
|
||||
print(json.dumps(_status(), sort_keys=True))
|
||||
return 0
|
||||
plan = plan_mission_core_launch_agent(
|
||||
repository_root=arguments.repository_root,
|
||||
agent_path=arguments.agent_path,
|
||||
)
|
||||
if arguments.action == "plan":
|
||||
print(json.dumps(plan.to_dict(), indent=2, sort_keys=True))
|
||||
return 0
|
||||
if (
|
||||
arguments.expected_current_sha256 != plan.current_sha256
|
||||
or arguments.expected_desired_sha256 != plan.desired_sha256
|
||||
):
|
||||
raise MissionCoreLaunchAgentError("launch agent plan changed before apply")
|
||||
previous = plan.agent_path.read_bytes()
|
||||
backup_root = (
|
||||
arguments.repository_root.expanduser().resolve()
|
||||
/ ".runtime/mission-core/launch-agent-backups"
|
||||
)
|
||||
backup_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
backup = backup_root / f"{plan.current_sha256}.plist"
|
||||
if backup.exists() and hashlib.sha256(backup.read_bytes()).hexdigest() != plan.current_sha256:
|
||||
raise MissionCoreLaunchAgentError("launch agent backup identity collision")
|
||||
if not backup.exists():
|
||||
_write_atomic(backup, previous)
|
||||
_write_atomic(plan.agent_path, plan.desired_payload)
|
||||
try:
|
||||
_reload_launch_agent(plan.agent_path)
|
||||
accepted = _wait_for_health(60.0)
|
||||
if not accepted:
|
||||
raise MissionCoreLaunchAgentError("reloaded Mission Core did not become healthy")
|
||||
except BaseException:
|
||||
_write_atomic(plan.agent_path, previous)
|
||||
_reload_launch_agent(plan.agent_path)
|
||||
if not _wait_for_health(60.0):
|
||||
raise MissionCoreLaunchAgentError(
|
||||
"launch agent apply failed and rollback did not recover health"
|
||||
) from None
|
||||
raise
|
||||
result = {
|
||||
**plan.to_dict(),
|
||||
"applied": True,
|
||||
"backup_path": str(backup),
|
||||
"health_accepted": True,
|
||||
"status": _status(),
|
||||
}
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
def _write_atomic(path: Path, payload: bytes) -> None:
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "wb", closefd=True) as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
except BaseException:
|
||||
with suppress(OSError):
|
||||
os.close(descriptor)
|
||||
with suppress(FileNotFoundError):
|
||||
os.unlink(temporary)
|
||||
raise
|
||||
|
||||
|
||||
def _reload_launch_agent(path: Path) -> None:
|
||||
"""Reload only after launchd proves the previous job is fully absent.
|
||||
|
||||
``launchctl bootout`` can return while the job is still represented by an
|
||||
``xpcproxy`` transition. An immediate bootstrap is then rejected even
|
||||
though the plist is valid. Waiting on the exact label avoids that race and
|
||||
keeps apply/rollback symmetric.
|
||||
"""
|
||||
|
||||
domain = f"gui/{os.getuid()}"
|
||||
target = f"{domain}/{MISSION_CORE_LAUNCH_AGENT_LABEL}"
|
||||
bootout = subprocess.run(
|
||||
["launchctl", "bootout", f"{domain}/{MISSION_CORE_LAUNCH_AGENT_LABEL}"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30.0,
|
||||
)
|
||||
if bootout.returncode != 0 and _launch_agent_loaded(target):
|
||||
raise MissionCoreLaunchAgentError(
|
||||
f"launchctl bootout failed with exit code {bootout.returncode}"
|
||||
)
|
||||
_wait_until_launch_agent_unloaded(target)
|
||||
completed = subprocess.run(
|
||||
["launchctl", "bootstrap", domain, str(path)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30.0,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise MissionCoreLaunchAgentError(
|
||||
"launchctl bootstrap rejected the Mission Core agent "
|
||||
f"with exit code {completed.returncode}"
|
||||
)
|
||||
|
||||
|
||||
def _launch_agent_loaded(target: str) -> bool:
|
||||
completed = subprocess.run(
|
||||
["launchctl", "print", target],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5.0,
|
||||
)
|
||||
return completed.returncode == 0
|
||||
|
||||
|
||||
def _wait_until_launch_agent_unloaded(target: str) -> None:
|
||||
deadline = time.monotonic() + _LAUNCHCTL_TRANSITION_TIMEOUT_SECONDS
|
||||
while time.monotonic() < deadline:
|
||||
if not _launch_agent_loaded(target):
|
||||
return
|
||||
time.sleep(_LAUNCHCTL_TRANSITION_POLL_SECONDS)
|
||||
raise MissionCoreLaunchAgentError(
|
||||
"Mission Core launch agent did not finish bootout before the deadline"
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_health(timeout_seconds: float) -> bool:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
if _healthy():
|
||||
return True
|
||||
time.sleep(0.25)
|
||||
return False
|
||||
|
||||
|
||||
def _healthy() -> bool:
|
||||
connection = http.client.HTTPConnection("127.0.0.1", 8000, timeout=1.0)
|
||||
try:
|
||||
connection.request("GET", "/api/health", headers={"Connection": "close"})
|
||||
response = connection.getresponse()
|
||||
payload = response.read(64 * 1024 + 1)
|
||||
except (OSError, TimeoutError, http.client.HTTPException):
|
||||
return False
|
||||
finally:
|
||||
connection.close()
|
||||
if response.status != 200 or len(payload) > 64 * 1024:
|
||||
return False
|
||||
try:
|
||||
document = json.loads(payload)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return False
|
||||
return bool(
|
||||
isinstance(document, dict)
|
||||
and document.get("ok") is True
|
||||
and document.get("status") == "ok"
|
||||
and document.get("service") == "mission-core-control-plane"
|
||||
)
|
||||
|
||||
|
||||
def _status() -> dict[str, object]:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"launchctl",
|
||||
"print",
|
||||
f"gui/{os.getuid()}/{MISSION_CORE_LAUNCH_AGENT_LABEL}",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5.0,
|
||||
)
|
||||
return {
|
||||
"label": MISSION_CORE_LAUNCH_AGENT_LABEL,
|
||||
"launchd_loaded": completed.returncode == 0,
|
||||
"health_ok": _healthy(),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user