# 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_a_plan_that_has_not_fired_has_no_last_slot(): """#5863: a new plan's last_slot_at is the moment it was made, a Wednesday evening for a Sunday plan; only a slot that fired is reported.""" made = _plan(time_zone="America/New_York", last_slot_at=datetime(2026, 10, 14, 23, 54, 58, tzinfo=timezone.utc)) assert c.fired_slot(made) is None fired = datetime(2026, 10, 18, 7, 0, tzinfo=timezone.utc) # Sunday 03:00 New York assert c.fired_slot(_plan(time_zone="America/New_York", last_slot_at=fired)) == fired assert c.fired_slot(_plan()) is None 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 # A node the job didn't drain stays drained, as the operator keeps it. assert c.back_in_service(last_seen_at=NOW, node=_node(availability="drain"), services=[_svc("web", 1, 1)], expect_active=False, **kw) is None assert "down" in c.back_in_service(last_seen_at=NOW, node=_node(status="down", availability="drain"), services=[_svc("web", 1, 1)], expect_active=False, **kw) 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" in page