Files
FabledSteward/tests/plugins/host_agent/test_maintenance.py
T
bvandeusenandClaude Opus 5.5 1ba4784fa6
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m29s
CI / integration (push) Successful in 2m7s
CI / publish (push) Skipped
fix(maintenance): check first, drain before a Docker upgrade, wait 5 min after a drain, see containers back (#6098)
On 2026-10-18 an update on VDNT-Docker02 restarted the Docker daemon six
seconds in, before the drain that came only with the reboot, and every
container on the node stopped. Docker's own packages restart the daemon from
their install scripts; needrestart's list-only mode doesn't stop that.

- The helper gains a `check` action (agent 1.24.0): a dry run of the update
  for each package manager, whether Docker is among it, and the running
  containers outside the swarm with their restart policies.
- A swarm node is drained before an update that upgrades Docker, else only
  before a reboot, and at most once a job.
- After a drain the job settles 5 minutes before the update or reboot.
- Once the work is over, the containers that ran outside the swarm must be
  seen running again within 10 minutes, or the job fails naming them.
- An older agent gets no check and its job runs as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:44:45 -04:00

272 lines
13 KiB
Python

# tests/plugins/host_agent/test_maintenance.py
"""#5767: a host's maintenance job, step by step, from the agent's reports.
The transitions are model-free, so a job here is a SimpleNamespace with the
row's attributes; the database side is tests/integration/test_host_maintenance_job.py.
"""
import json
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from plugins.host_agent import agent as a
from plugins.host_agent import maintenance as m
T0 = datetime(2026, 10, 14, 23, 0, tzinfo=timezone.utc)
def _job(**kw):
base = dict(id="job-1", host_id="h1", state="queued", state_at=T0, created_at=T0,
reboot_policy="if_required", step_id=None, step_seen=False, node_id=None,
drain_host_id=None, packages=None, reboot_needed=None, back_at=None,
detail=None, finished_at=None, drained_at=None, docker_update=None, checked=None)
base.update(kw)
return SimpleNamespace(**base)
def _result(job, state, **kw):
return {"kind": "maintenance", "id": job.step_id, "state": state, **kw}
def test_an_update_with_no_reboot_needed_is_done_after_the_update():
job = _job()
m.start(job, T0)
assert m.handoff(job) == {"id": "job-1-update", "action": "update"}
assert m.apply_result(job, _result(job, "running"), T0)
assert m.handoff(job) is None # taken: not handed again
m.apply_result(job, _result(job, "done", ok=True, packages=3, reboot_required=False), T0)
assert (job.state, job.packages, job.finished_at) == ("done", 3, T0)
assert m.summary(job) == "Done: 3 packages updated, no reboot needed"
def test_a_swarm_node_is_drained_rebooted_and_made_active_again():
job = _job(node_id="n" * 25, drain_host_id="mgr")
m.start(job, T0)
m.apply_result(job, _result(job, "done", ok=True, packages=12, reboot_required=True), T0)
assert job.state == "draining" and m.executor(job) == "mgr"
assert m.handoff(job) == {"id": "job-1-drain", "action": "drain", "node": "n" * 25}
m.apply_result(job, _result(job, "done", ok=True), T0)
# The node's tasks get 5 minutes to move before the reboot (#6098).
assert job.state == "settling" and job.drained_at == T0 and m.handoff(job) is None
assert not m.settle(job, T0 + timedelta(minutes=4))
assert m.settle(job, T0 + timedelta(minutes=5))
assert job.state == "rebooting" and m.executor(job) == "h1"
assert m.handoff(job) == {"id": "job-1-reboot", "action": "reboot"}
t1 = T0 + timedelta(minutes=4)
assert m.apply_boot(job, T0 + timedelta(minutes=2), t1)
assert job.state == "undraining" and job.back_at == t1 and m.executor(job) == "mgr"
m.apply_result(job, _result(job, "done", ok=True), t1)
assert job.state == "done"
assert m.summary(job) == "Done: 12 packages updated, rebooted"
def test_a_job_that_may_never_reboot_stops_after_the_update():
job = _job(reboot_policy="never")
m.start(job, T0)
m.apply_result(job, _result(job, "done", ok=True, packages=1, reboot_required=True), T0)
assert job.state == "done"
assert "reboot needed, not allowed" in m.summary(job)
def test_only_a_boot_after_the_reboot_was_handed_out_completes_it():
job = _job(state="rebooting", step_id="job-1-reboot", state_at=T0)
assert not m.apply_boot(job, T0 - timedelta(days=3), T0) # the agent restarted, not the host
assert not m.apply_boot(job, None, T0)
assert job.state == "rebooting"
assert m.apply_boot(job, T0 - timedelta(seconds=30), T0) # within the clock slack
assert job.state == "done"
def test_a_failed_step_fails_the_job_and_says_which():
job = _job(state="undraining", step_id="job-1-undrain", node_id="n" * 25, drain_host_id="mgr")
m.apply_result(job, _result(job, "failed", ok=False, detail="This node is not a swarm manager."), T0)
assert job.state == "failed"
assert job.detail.startswith("undrain failed: This node is not a swarm manager.")
assert "still drained" in job.detail
def test_a_late_or_repeated_result_does_nothing():
job = _job()
m.start(job, T0)
stale = {"kind": "maintenance", "id": "job-1-drain", "state": "done", "ok": True}
assert not m.apply_result(job, stale, T0)
assert m.apply_result(job, _result(job, "running"), T0)
assert not m.apply_result(job, _result(job, "running"), T0)
m.apply_result(job, _result(job, "done", ok=True, reboot_required=False), T0)
assert not m.apply_result(job, {"kind": "maintenance", "id": "job-1-update", "state": "done"}, T0)
@pytest.mark.parametrize("state,minutes,expected", [
("queued", 59, False), ("queued", 61, True),
("updating", 91, True), ("rebooting", 29, False), ("rebooting", 31, True),
])
def test_a_job_is_given_up_past_its_state_deadline(state, minutes, expected):
job = _job(state=state, step_id=f"job-1-{m.STEP_ACTION.get(state, 'x')}")
assert m.check_deadline(job, T0 + timedelta(minutes=minutes)) is expected
if expected:
assert job.state == "timed_out" and "within" in job.detail
def test_the_window_lasts_the_rest_of_the_job_and_ends_with_it():
job = _job(state="rebooting", step_id="job-1-reboot")
assert m.window_end(job, T0) == T0 + timedelta(minutes=30 + 10 + 15)
job.state = "done"
assert m.window_end(job, T0) == T0
def test_a_manager_drains_itself_and_a_worker_is_drained_by_a_manager():
nodes = {"docker02": "a" * 25, "docker03": "b" * 25}
managers = {"h2": "a" * 25}
assert m.pick_drainer(nodes, managers, "Docker02", "h2", {"h2", "h3"}) == ("a" * 25, "h2")
assert m.pick_drainer(nodes, managers, "Docker03", "h3", {"h2", "h3"}) == ("b" * 25, "h2")
assert m.pick_drainer(nodes, managers, "Util01", "h4", {"h2"}) == (None, None)
with pytest.raises(m.CannotStart, match="no swarm manager"):
m.pick_drainer(nodes, managers, "Docker03", "h3", {"h3"})
def test_a_node_the_operator_keeps_drained_is_left_drained():
# A manager kept free of work (availability drain) is neither drained
# before nor made active after: the job only updates and reboots it.
nodes = {"util01": "c" * 25, "docker02": "a" * 25}
managers = {"h1": "c" * 25, "h2": "a" * 25}
assert m.pick_drainer(nodes, managers, "Util01", "h1", {"h1", "h2"}, "drain") == ("c" * 25, None)
assert m.pick_drainer(nodes, managers, "Util01", "h1", {"h1", "h2"}, "pause") == ("c" * 25, None)
assert m.pick_drainer(nodes, managers, "Util01", "h1", {"h1", "h2"}, "active") == ("c" * 25, "h1")
# ── a check first: Docker upgraded, drain before; containers seen again (#6098)
NODE = "n" * 25
CONTAINERS = [{"name": "ak-outpost", "restart": "unless-stopped"}, {"name": "scratch", "restart": "no"}]
def _checked(job, **kw):
m.apply_result(job, _result(job, "done", ok=True, pending=7, **kw), T0)
def test_a_new_enough_agent_checks_first():
assert m.can_check("1.24.0") and m.can_check("1.30.2") and m.can_check("2.0.0")
assert not m.can_check("1.23.9") and not m.can_check(None) and not m.can_check("dev")
job = _job()
m.start(job, T0, check=True)
assert job.state == "checking" and m.handoff(job) == {"id": "job-1-check", "action": "check"}
def test_an_update_that_upgrades_docker_drains_the_node_before_it():
job = _job(node_id=NODE, drain_host_id="mgr")
m.start(job, T0, check=True)
_checked(job, docker_update=True, docker_packages=["docker-ce"], containers=[])
assert job.state == "draining" and job.docker_update is True
assert job.checked == {"pending": 7, "docker_packages": ["docker-ce"], "containers": []}
m.apply_result(job, _result(job, "done", ok=True), T0)
assert job.state == "settling"
assert m.settle(job, T0 + timedelta(minutes=5)) and job.state == "updating"
m.apply_result(job, _result(job, "done", ok=True, packages=9, reboot_required=True), T0)
assert job.state == "rebooting" # drained already: no second drain
assert m.apply_boot(job, T0 + timedelta(minutes=8), T0 + timedelta(minutes=9))
assert job.state == "undraining"
m.apply_result(job, _result(job, "done", ok=True), T0)
assert job.state == "done" and m.summary(job) == "Done: Docker upgraded, 9 packages updated, rebooted"
def test_a_drained_node_with_no_reboot_is_made_active_after_the_update():
job = _job(node_id=NODE, drain_host_id="mgr", reboot_policy="never")
m.start(job, T0, check=True)
_checked(job, docker_update=True)
m.apply_result(job, _result(job, "done", ok=True), T0)
m.settle(job, T0 + timedelta(minutes=5))
m.apply_result(job, _result(job, "done", ok=True, reboot_required=True), T0)
assert job.state == "undraining"
def test_an_update_without_docker_doesnt_drain_unless_it_reboots():
job = _job(node_id=NODE, drain_host_id="mgr")
m.start(job, T0, check=True)
_checked(job, docker_update=False)
assert job.state == "updating"
m.apply_result(job, _result(job, "done", ok=True, reboot_required=False), T0)
assert job.state == "done" and job.drained_at is None # never drained, so no undrain
def test_docker_on_a_node_the_operator_keeps_drained_isnt_drained_again():
job = _job(node_id=NODE, drain_host_id=None)
m.start(job, T0, check=True)
_checked(job, docker_update=True)
assert job.state == "updating"
def test_the_containers_outside_the_swarm_are_waited_for_after_the_reboot():
job = _job()
m.start(job, T0, check=True)
_checked(job, docker_update=False, containers=CONTAINERS)
m.apply_result(job, _result(job, "done", ok=True, reboot_required=True), T0)
t1 = T0 + timedelta(minutes=6)
assert m.apply_boot(job, T0 + timedelta(minutes=5), t1)
assert job.state == "recovering" and m.handoff(job) is None
assert not m.recovered(job, {"ak-outpost"}, t1 + timedelta(minutes=2)) # still waiting
assert m.recovered(job, {"ak-outpost", "scratch", "other"}, t1 + timedelta(minutes=3))
assert job.state == "done"
def test_a_container_that_doesnt_run_again_fails_the_job_by_name():
job = _job(state="recovering", state_at=T0, checked={"containers": CONTAINERS})
assert not m.recovered(job, {"ak-outpost"}, T0 + timedelta(minutes=9))
assert m.recovered(job, {"ak-outpost"}, T0 + timedelta(minutes=10))
assert job.state == "failed"
assert job.detail == "not running again 10 min after the job: scratch (no restart policy)"
def test_a_job_with_nothing_outside_the_swarm_ends_at_the_boot():
job = _job(state="rebooting", step_id="job-1-reboot", checked={"containers": []})
assert m.apply_boot(job, T0, T0) and job.state == "done"
def test_what_a_check_said_is_kept_bounded():
job = _job(state="checking", step_id="job-1-check")
many = [{"name": f"c{i}" * 40, "restart": "always"} for i in range(60)] + ["junk", {"name": 3}]
_checked(job, docker_update="yes", docker_packages=["docker-ce", 4] * 9, containers=many)
assert job.docker_update is False # only a real True counts
assert len(job.checked["containers"]) == m.CONTAINERS_KEPT
assert len(job.checked["containers"][0]["name"]) == 64
assert job.checked["docker_packages"] == ["docker-ce"] * 9
@pytest.mark.parametrize("state, minutes", [("checking", 31), ("settling", 16), ("recovering", 16)])
def test_the_new_states_are_given_up_too(state, minutes):
job = _job(state=state, step_id="job-1-check" if state == "checking" else None)
assert m.check_deadline(job, T0 + timedelta(minutes=minutes)) and job.state == "timed_out"
# ── the agent's side ─────────────────────────────────────────────────────────
def test_the_agent_leaves_each_handed_step_once(tmp_path):
requested: set = set()
steps = [{"id": "job-1-update", "action": "update"}, {"id": "job-1-x", "action": "shell"}, "junk"]
assert a.take_maintenance(steps, requested, requests_dir=str(tmp_path)) == ["job-1-update"]
assert json.loads((tmp_path / "job-1-update.json").read_text()) == {
"id": "job-1-update", "action": "update"}
(tmp_path / "job-1-update.json").unlink() # the helper took it
assert a.take_maintenance(steps, requested, requests_dir=str(tmp_path)) == []
assert not (tmp_path / "job-1-update.json").exists()
assert a.take_maintenance(None, requested, requests_dir=str(tmp_path)) == []
def test_the_agent_reads_stewards_answer_when_asked(monkeypatch):
class Resp:
status = 200
def read(self, n):
return b'{"ok": true, "maintenance": [{"id": "job-1-reboot", "action": "reboot"}]}'
def __enter__(self):
return self
def __exit__(self, *exc):
return False
monkeypatch.setattr(a.urllib.request, "urlopen", lambda req, timeout: Resp())
answer: dict = {}
assert a.post_payload("https://x", "tok", {}, answer) == (True, 200)
assert answer["maintenance"] == [{"id": "job-1-reboot", "action": "reboot"}]