fix(update): cover catch-up restart clients with the unit budget

This commit is contained in:
Teknium
2026-09-07 02:12:53 -07:00
parent bb12ee7a5a
commit 08f2c78d92
4 changed files with 38 additions and 10 deletions
+22 -5
View File
@@ -7,7 +7,8 @@ import sys
import tempfile
import time
repo, tag, output = sys.argv[1:]
repo, tag, output = sys.argv[1:4]
catchup = sys.argv[4:] == ["--catchup"]
if not tag.isalnum():
raise SystemExit("tag must be alphanumeric")
home = Path(tempfile.mkdtemp(prefix=f"updater-{tag}-"))
@@ -15,7 +16,18 @@ allowed = {key: os.environ[key] for key in ("PATH", "XDG_RUNTIME_DIR", "DBUS_SES
os.environ.clear()
os.environ.update(allowed, HOME=str(home), HERMES_HOME=str(home / "hermes"))
sys.path.insert(0, repo)
from hermes_cli.update_cmd_fleet import _systemctl_reset_and_restart
from hermes_cli import update_cmd_fleet as fleet
_systemctl_reset_and_restart = fleet._systemctl_reset_and_restart
actual_systemctl = fleet._systemctl
def scoped_systemctl(argv, *, timeout):
if "list-units" in argv:
if "--user" not in argv:
return subprocess.CompletedProcess(argv, 0, "", "")
argv = [arg for arg in argv if arg not in ("hermes-gateway*", "hermes-serve*")] + [unit + ".service"]
return actual_systemctl(argv, timeout=timeout)
if catchup:
# Bound discovery to our transient unit; never enumerate user services.
fleet._systemctl = scoped_systemctl
unit = f"hermes-serve-audit-089c35aa-{tag}"
cmd = ["systemctl", "--user"]
def run(args):
@@ -24,13 +36,18 @@ def pid():
return run(cmd + ["show", unit, "--property=MainPID", "--value"]).stdout.strip()
record: dict[str, object] = {"repo": repo, "tag": tag, "unit": unit, "home": str(home), "tier": "native disposable systemd service"}
try:
start = run(["systemd-run", "--user", "--unit", unit, "--property=ExecStop=/bin/sleep 16", "--property=TimeoutStopSec=30", "--property=TimeoutStartSec=30", "/bin/sleep", "infinity"])
start = run(["systemd-run", "--user", "--unit", unit, f"--property=ExecStop=/bin/sleep {31 if catchup else 16}", "--property=TimeoutStopSec=45", "--property=TimeoutStartSec=30", "/bin/sleep", "infinity"])
assert start.returncode == 0, start.stderr
old = pid()
began = time.monotonic()
try:
result = _systemctl_reset_and_restart(cmd, unit)
record.update(returncode=result.returncode, stderr=result.stderr)
if catchup:
failed = []
fleet._restart_systemd_gateway_units_best_effort(failed)
record.update(failed_units=failed)
else:
result = _systemctl_reset_and_restart(cmd, unit)
record.update(returncode=result.returncode, stderr=result.stderr)
except subprocess.TimeoutExpired as exc:
record.update(timeout=exc.timeout)
record["elapsed"] = time.monotonic() - began
+1 -1
View File
@@ -208,7 +208,7 @@ def _restart_systemd_gateway_units_best_effort(failed: list, listings) -> None:
manage_cmd = list(_cmd) + ["--no-ask-password"]
if _needs_sudo(_scope):
manage_cmd = ["sudo", "-n"] + manage_cmd
result = _systemctl_reset_and_restart(manage_cmd, svc_name)
result = _systemctl_reset_and_restart(manage_cmd, svc_name, scope_cmd=_cmd)
if result.returncode != 0 or not _wait_for_service_active(_cmd, svc_name):
failed.append(svc_name)
@@ -6,14 +6,17 @@ import pytest
from hermes_cli import update_cmd_fleet as fleet
@pytest.mark.parametrize("graceful,retry", [(False, False), (False, True), (True, False)])
@pytest.mark.parametrize("graceful,retry", [(False, False), (False, True), (True, False), ("catchup", False)])
def test_unit_transaction_budget_preserves_scope_and_health(monkeypatch, graceful, retry):
scope = ["systemctl", "--no-ask-password"]
manage = ["sudo", "-n", *scope]
catchup = graceful == "catchup"
scope = ["systemctl", "--user"] if catchup else ["systemctl", "--no-ask-password"]
manage = scope if catchup else ["sudo", "-n", *scope]
calls = []
def systemctl(cmd, *, timeout):
calls.append((cmd, timeout))
if "list-units" in cmd:
return subprocess.CompletedProcess(cmd, 0, "hermes-serve-test.service loaded active running", "")
if "show" in cmd:
assert cmd[:len(scope)] == scope
output = "42" if "--property=MainPID" in cmd else "TimeoutStopUSec=70s\nTimeoutStartUSec=90s"
@@ -24,6 +27,13 @@ def test_unit_transaction_budget_preserves_scope_and_health(monkeypatch, gracefu
return subprocess.CompletedProcess(cmd, 0, "active", "")
monkeypatch.setattr(fleet, "_systemctl", systemctl)
if catchup:
monkeypatch.setattr(fleet, "_SYSTEMD_SCOPES", (("user", scope),))
failed = []
fleet._restart_systemd_gateway_units_best_effort(failed)
assert not failed
assert sum("restart" in cmd for cmd, _ in calls) == 1
return
monkeypatch.setattr(fleet, "_drain_or_signal_gateway_for_update", lambda *a: True)
health = iter([False, True] if retry else [True])
monkeypatch.setattr(fleet, "_wait_for_service_active", lambda *a, **kw: next(health))
@@ -17,7 +17,8 @@ field failure each stage guards are documented in `hermes_cli/AGENTS.md`; user-f
The systemd blunt-restart fallback waits for the unit's `TimeoutStopUSec` plus
`TimeoutStartUSec`, with 15 seconds of client-side slack. It reads the target unit
in the same manager scope as the restart; both the initial attempt and retry use
this budget. A start after a graceful drain uses only the start budget plus slack.
this budget, including the catch-up restart after an interrupted update.
A start after a graceful drain uses only the start budget plus slack.
A missing, unparseable, or infinite phase limit falls back to 90 seconds
for that phase, keeping unattended updates bounded. Timing out the `systemctl`
client does **not** cancel the manager's transaction. Custom multi-command stop