A new plan's last_slot_at holds the moment it was made, so its first slot is the next one; the tool returned that moment, a Wednesday evening for a Sunday plan, beside latest_run: null. cadence.fired_slot returns it only when it falls on one of the plan's slots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
7.7 KiB
Python
149 lines
7.7 KiB
Python
# 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</button>" in page
|