feat(host_agent): a group's updates and reboots on a weekly cadence, one host at a time (#5768)
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m20s
CI / unit (push) Successful in 1m34s
CI / publish (push) Skipped

#5768 "A maintenance cadence: a group's updates and reboots, one host at a
time, on a schedule". A plan names an inventory group, a weekday and time
in a time zone, a window and a reboot policy (only when required by
default). At its slot a run takes the group's hosts in order and gives each
a #5767 maintenance job. The next host starts only when the last is back in
service: its agent is posting and, for a swarm node, the node is active and
every replicated service is at its replicas. A failure stops the run, and
no host is started once the window has passed. A host's latest failure
shows as host_agent/<host>/maintenance_failed, for an ordinary alert rule.
The page is Hosts → Maintenance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 18:59:07 -04:00
co-authored by Claude Opus 5.5
parent 78581c843e
commit 200384e52d
17 changed files with 1077 additions and 15 deletions
+133
View File
@@ -0,0 +1,133 @@
# tests/plugins/host_agent/test_cadence.py
"""#5768: when a plan is due, when a host is back in service, and what a run
does next. Model-free: plans, runs, jobs and swarm rows are SimpleNamespaces."""
import json
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
import pytest
from plugins.host_agent import cadence as c
from plugins.host_agent import maintenance as m
# Wednesday 2026-10-14 23:00 UTC
NOW = datetime(2026, 10, 14, 23, 0, tzinfo=timezone.utc)
def _plan(**kw):
base = dict(weekday=6, at_time="03:00", time_zone="UTC", window_minutes=240, enabled=True,
last_slot_at=None, created_at=NOW - timedelta(days=30), reboot_policy="if_required")
base.update(kw)
return NS(**base)
def test_the_last_slot_is_the_latest_weekday_and_time_in_the_plans_zone():
assert c.last_slot(_plan(), NOW) == datetime(2026, 10, 11, 3, 0, tzinfo=timezone.utc)
# 03:00 in New York is 07:00 UTC in October (EDT)
ny = _plan(time_zone="America/New_York")
assert c.last_slot(ny, NOW) == datetime(2026, 10, 11, 7, 0, tzinfo=timezone.utc)
assert c.next_slot(_plan(), NOW) == datetime(2026, 10, 18, 3, 0, tzinfo=timezone.utc)
today = _plan(weekday=2, at_time="22:30")
assert c.last_slot(today, NOW) == datetime(2026, 10, 14, 22, 30, tzinfo=timezone.utc)
def test_a_slot_fires_once_and_only_while_its_window_is_open():
plan = _plan(weekday=2, at_time="22:30")
assert c.is_due(plan, NOW) == datetime(2026, 10, 14, 22, 30, tzinfo=timezone.utc)
plan.last_slot_at = c.is_due(plan, NOW)
assert c.is_due(plan, NOW) is None # fired already
late = _plan(weekday=2, at_time="18:00") # 5 h ago, window 4 h
assert c.is_due(late, NOW) is None
assert c.is_due(_plan(weekday=2, at_time="22:30", enabled=False), NOW) is None
new = _plan(weekday=2, at_time="22:30", created_at=NOW - timedelta(minutes=5))
assert c.is_due(new, NOW) is None # made after the slot
def _node(**kw):
base = dict(status="ready", availability="active", updated_at=NOW)
base.update(kw)
return NS(**base)
def _svc(name, running, desired, mode="replicated"):
return NS(service_name=name, running=running, desired=desired, mode=mode, updated_at=NOW)
def test_a_host_is_back_once_posting_its_node_active_and_its_services_at_replicas():
finished = NOW - timedelta(minutes=5)
kw = dict(job_finished_at=finished, now=NOW)
assert c.back_in_service(last_seen_at=None, node=None, services=[], **kw) == "its agent isn't posting yet"
assert c.back_in_service(last_seen_at=NOW, node=None, services=[], **kw) is None
assert "drain" in c.back_in_service(last_seen_at=NOW, node=_node(availability="drain"),
services=[_svc("web", 1, 1)], **kw)
assert c.back_in_service(last_seen_at=NOW, node=_node(updated_at=finished - timedelta(minutes=1)),
services=[], **kw) == "no swarm report since it came back"
assert c.back_in_service(last_seen_at=NOW, node=_node(),
services=[_svc("web", 1, 2), _svc("agent", 2, 3, mode="global")],
**kw) == "services below their replicas: web"
assert c.back_in_service(last_seen_at=NOW, node=_node(), services=[_svc("web", 2, 2)], **kw) is None
def _run(**kw):
base = dict(host_ids=json.dumps(["h1", "h2"]), position=0, started_at=NOW - timedelta(minutes=10),
settle_since=None, skipped="[]")
base.update(kw)
return NS(**base)
def _job(state, detail=None):
return NS(state=state, detail=detail)
@pytest.mark.parametrize("job,back,run_kw,expected", [
(None, None, {}, ("start", None)),
(_job("rebooting"), None, {}, ("wait", None)),
(_job("done"), None, {}, ("next", None)),
(_job("done"), "its agent isn't posting yet", {}, ("settling", "its agent isn't posting yet")),
(_job("failed", "update failed: dpkg"), None, {}, ("fail", "update failed: dpkg")),
(_job("timed_out"), None, {}, ("fail", "Timed out")),
(None, None, {"position": 2}, ("done", None)),
])
def test_what_a_run_does_next(job, back, run_kw, expected):
assert c.next_move(_run(**run_kw), _plan(), job, back, NOW) == expected
def test_a_host_that_never_settles_fails_the_run_and_the_window_stops_new_hosts():
run = _run(settle_since=NOW - timedelta(minutes=31))
move, why = c.next_move(run, _plan(), _job("done"), "services below their replicas: web", NOW)
assert move == "fail" and why.startswith("not back in service within 30 min")
late = _run(started_at=NOW - timedelta(hours=5), position=1)
assert c.next_move(late, _plan(), None, None, NOW) == ("stop", "the window ended with 1 host not started")
def test_a_plan_says_when_it_runs():
assert c.describe(_plan()) == "Sundays at 03:00 (UTC), up to 4 h"
assert c.describe(_plan(window_minutes=90)) == "Sundays at 03:00 (UTC), up to 90 min"
def test_always_reboots_even_when_the_update_needs_none():
job = NS(id="j", state="updating", step_id="j-update", step_seen=False, reboot_policy="always",
reboot_needed=None, node_id=None, drain_host_id=None, packages=None, state_at=NOW,
finished_at=None, detail=None)
m.apply_result(job, {"id": "j-update", "state": "done", "ok": True, "reboot_required": False}, NOW)
assert job.state == "rebooting"
async def test_the_page_leads_with_a_running_run_and_lists_the_plans(app):
from quart import session
from tests._templates import render_plugin_template
plan = NS(id="p1", name="Docker hosts", enabled=True, reboot_policy="if_required")
run = NS(id="r1", state="running", started_at=NOW, manual=True, detail=None, finished_at=None)
rows = [{"host_id": "h1", "name": "docker02", "text": "Rebooting", "bad": False, "busy": True, "detail": None},
{"host_id": "h2", "name": "docker03", "text": "Waiting", "bad": False, "busy": False, "detail": None}]
async with app.test_request_context("/plugins/host_agent/maintenance"):
session["user_role"] = "admin"
page = await render_plugin_template(
app, "host_agent", "maintenance.html",
plans=[{"plan": plan, "group": "docker", "hosts": 2, "when": "Sundays at 03:00 (UTC), up to 4 h",
"next": NOW, "runs": [{"run": run, "rows": rows}]}],
groups=[NS(id="g1", name="docker")], live=[{"run": run, "plan": "Docker hosts", "rows": rows}],
failed=[], weekdays=c.WEEKDAYS, policies=m.REBOOT_POLICIES, default_zone="UTC")
assert page.index("Running: Docker hosts") < page.index("Add a plan")
assert page.index("docker02") < page.index("docker03")
assert "reboot only when required" in page and ">Run now</button>" in page