Host-scoped update-restart obligation (061195fac1/953b6f6f08/3be255eca6) lands on the PM model: the obligation record, its readers and the legacy per-home marker compat come in as-is. The catch-up restart path (`_apply_pending_fleet_restart_catchup` / `_run_pending_fleet_restart`) is retired here (the fleet restart rides the completion owner), so main's per-host restart-once guard on that path is not carried; its unit→live MainPID collapse IS ported into the live post-update systemd pass (`_restart_systemd_gateway_units`), with the two collapse tests rewritten against that function (red on the pre-port tree: `_unit_main_pid` absent). Tests that exercised only the retired catch-up path are dropped. Desktop: main's shared log-rotation planner replaces the inline constants in main.ts; the merge keeps our machine-profile import beside its import. utf-8 → utf-8-sig on the three new BOM-intolerant reads (footguns lint).
222 lines
8.5 KiB
Python
222 lines
8.5 KiB
Python
"""Host-scoped update→restart obligation for ``hermes update``.
|
|
|
|
Multiplex-only (Teknium ruling): exactly ONE ``hermes gateway run`` per host serves every
|
|
profile, so "this pull still owes the fleet a restart" is a property of the HOST, not of one
|
|
profile's ``HERMES_HOME``. The legacy ``$HERMES_HOME/fleet_restart_pending`` marker was
|
|
per-home: ``hermes -p coder update`` armed and cleared coder's copy while restarting the
|
|
SHARED process, and every other profile's CLI could neither see nor discharge that obligation
|
|
— it simply armed its own and re-killed the same host process.
|
|
|
|
The record therefore lives beside the host rendezvous record, in
|
|
:func:`gateway.host_rendezvous.host_state_dir` (``$HERMES_GATEWAY_LOCK_DIR`` else
|
|
``$XDG_STATE_HOME/hermes/gateway-locks``) — the one cross-profile, per-OS-user state root the
|
|
tree already has. It is written once per host, read by every profile's CLI, and cleared once.
|
|
|
|
The same "one host process, not one per profile" identity is what
|
|
:func:`collapse_units_to_host_processes` applies to enumerated systemd units: leftover
|
|
per-profile ``hermes-gateway-<p>.service`` units on a multiplexed host all point at the same
|
|
live ``MainPID``, so restarting each one restarts the host process N times.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Iterable, Optional
|
|
|
|
logger = logging.getLogger("hermes_cli.update_cmd")
|
|
|
|
#: One file per OS user, beside ``host-gateway.json`` / ``host-serve.json``.
|
|
HOST_OBLIGATION_NAME = "host-update-restart.json"
|
|
|
|
_RECORD_VERSION = 1
|
|
|
|
|
|
def host_obligation_path() -> Optional[Path]:
|
|
"""Path of the host obligation record, or ``None`` when the host state dir is unresolvable."""
|
|
try:
|
|
from gateway.host_rendezvous import host_state_dir
|
|
|
|
return host_state_dir() / HOST_OBLIGATION_NAME
|
|
except Exception: # pragma: no cover - import/env failure must never break the updater
|
|
logger.debug("Host obligation path unavailable", exc_info=True)
|
|
return None
|
|
|
|
|
|
def read_host_obligation() -> Optional[dict]:
|
|
"""The published obligation record, or ``None`` when absent/corrupt/foreign-versioned."""
|
|
path = host_obligation_path()
|
|
if path is None:
|
|
return None
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
except (OSError, UnicodeDecodeError, ValueError):
|
|
return None
|
|
if not isinstance(payload, dict) or payload.get("version") != _RECORD_VERSION:
|
|
return None
|
|
return payload
|
|
|
|
|
|
def host_obligation_present() -> bool:
|
|
"""True when the record FILE exists, parseable or not.
|
|
|
|
Fail-closed: a corrupt record is an obligation whose terms are unknown, never a discharged
|
|
one — the restart is still owed and the reader falls back to "no recorded inventory".
|
|
"""
|
|
path = host_obligation_path()
|
|
if path is None:
|
|
return False
|
|
try:
|
|
return path.is_file()
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def amend_host_obligation(**fields: Any) -> None:
|
|
"""Merge ``fields`` into the armed record (test/diagnostic surface). Never raises."""
|
|
record = read_host_obligation()
|
|
path = host_obligation_path()
|
|
if record is None or path is None:
|
|
return
|
|
record.update(fields)
|
|
try:
|
|
from utils import atomic_json_write
|
|
|
|
atomic_json_write(path, record, mode=0o600)
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
logger.debug("Could not amend host update-restart obligation: %s", exc)
|
|
|
|
|
|
def write_host_obligation(
|
|
*, expected_sha: str = "", runtimes: Optional[list] = None, profile: str = ""
|
|
) -> bool:
|
|
"""Arm the host obligation. True when it was written. Never raises.
|
|
|
|
Re-arming from a second profile for the SAME pulled SHA keeps the existing record (and its
|
|
``restarted`` proof) instead of resetting it: the host owes one restart, not one per profile.
|
|
"""
|
|
path = host_obligation_path()
|
|
if path is None:
|
|
return False
|
|
existing = read_host_obligation()
|
|
if existing is not None and expected_sha and existing.get("expected_sha") == expected_sha:
|
|
# Same pull, second profile: the host owes ONE restart, so keep the standing record (and
|
|
# any proof that the restart already happened) rather than resetting it. A later arm that
|
|
# carries the owed inventory still upgrades it — an inventory-less record owes no set.
|
|
if runtimes is None:
|
|
return True
|
|
inventory = {"version": 1, "runtimes": runtimes}
|
|
if existing.get("inventory") != inventory:
|
|
amend_host_obligation(inventory=inventory)
|
|
return True
|
|
payload: dict[str, Any] = {
|
|
"version": _RECORD_VERSION,
|
|
"started": time.time(),
|
|
"pid": os.getpid(),
|
|
"armed_by_profile": profile or "",
|
|
"expected_sha": expected_sha or "",
|
|
}
|
|
if runtimes is not None:
|
|
payload["inventory"] = {"version": 1, "runtimes": runtimes}
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
from utils import atomic_json_write
|
|
|
|
atomic_json_write(path, payload, mode=0o600)
|
|
except Exception as exc:
|
|
logger.debug("Could not write host update-restart obligation: %s", exc)
|
|
return False
|
|
return True
|
|
|
|
|
|
def clear_host_obligation() -> None:
|
|
"""Discharge the obligation for the whole host. Never raises."""
|
|
path = host_obligation_path()
|
|
if path is None:
|
|
return
|
|
try:
|
|
path.unlink(missing_ok=True)
|
|
except OSError as exc:
|
|
logger.debug("Could not clear host update-restart obligation: %s", exc)
|
|
|
|
|
|
def obligation_fields() -> Optional[dict[str, str]]:
|
|
"""The obligation in the legacy ``key=value`` field shape, or ``None`` when unarmed.
|
|
|
|
Keeps one parser for both sources: the fields a reader needs (``expected_sha``, the
|
|
serialized ``inventory``) are identical whether they came from the host record or from an
|
|
in-flight legacy per-home marker.
|
|
"""
|
|
record = read_host_obligation()
|
|
if record is None:
|
|
return None
|
|
fields = {"expected_sha": str(record.get("expected_sha") or "")}
|
|
inventory = record.get("inventory")
|
|
if inventory is not None:
|
|
fields["inventory"] = json.dumps(inventory)
|
|
return fields
|
|
|
|
|
|
def mark_host_restart_completed(sha: str) -> None:
|
|
"""Record that the host process was restarted onto ``sha``. Never raises."""
|
|
record = read_host_obligation()
|
|
path = host_obligation_path()
|
|
if record is None or path is None:
|
|
return
|
|
record["restarted"] = {"sha": sha or "", "pid": os.getpid(), "at": time.time()}
|
|
try:
|
|
from utils import atomic_json_write
|
|
|
|
atomic_json_write(path, record, mode=0o600)
|
|
except Exception as exc:
|
|
logger.debug("Could not stamp host restart completion: %s", exc)
|
|
|
|
|
|
def host_restart_already_completed(sha: Optional[str]) -> bool:
|
|
"""True when THIS host obligation was already restarted onto ``sha``.
|
|
|
|
The guard that makes the catch-up restart idempotent per host: a second profile running
|
|
``hermes update`` must attach to the first restart's outcome, never kill the shared
|
|
multiplexer again.
|
|
"""
|
|
record = read_host_obligation()
|
|
if record is None or not sha:
|
|
return False
|
|
restarted = record.get("restarted")
|
|
return isinstance(restarted, dict) and str(restarted.get("sha") or "") == sha
|
|
|
|
|
|
def collapse_units_to_host_processes(
|
|
units: Iterable[str], main_pid: Callable[[str], int]
|
|
) -> tuple[list[str], dict[str, str]]:
|
|
"""Split enumerated units into ``(restart, {legacy_unit: covering_unit})``.
|
|
|
|
Units resolving to the same live ``MainPID`` are ONE host process; restarting each of them
|
|
restarts that process N times, which on a multiplexed host is an N-fold outage triggered by
|
|
leftover per-profile units. A unit with no readable main PID (inactive, unprivileged scope)
|
|
keeps its own restart: identity that cannot be proved is never collapsed away.
|
|
"""
|
|
restart: list[str] = []
|
|
covered: dict[str, str] = {}
|
|
owner_by_pid: dict[int, str] = {}
|
|
for unit in units:
|
|
try:
|
|
pid = int(main_pid(unit) or 0)
|
|
except Exception:
|
|
# Identity that cannot be proved keeps its own restart; a probe failure of any kind
|
|
# must never abort the whole pass.
|
|
pid = 0
|
|
if pid <= 0:
|
|
restart.append(unit)
|
|
continue
|
|
owner = owner_by_pid.get(pid)
|
|
if owner is None:
|
|
owner_by_pid[pid] = unit
|
|
restart.append(unit)
|
|
else:
|
|
covered[unit] = owner
|
|
return restart, covered
|