Files
FabledSteward/tests/integration/test_host_maintenance_cadence.py
T
bvandeusenandClaude Opus 5.5 200384e52d
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m20s
CI / unit (push) Successful in 1m34s
CI / publish (push) Skipped
feat(host_agent): a group's updates and reboots on a weekly cadence, one host at a time (#5768)
#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>
2026-10-14 18:59:07 -04:00

135 lines
5.4 KiB
Python

"""Integration (#5768): a plan updates its group's hosts one at a time; the
second starts only once the first is back, and a failure stops the run and
flags the host for an alert rule.
The plugin's cadence and models are reached through the module the app loaded
(see test_docker_disk_page): importing the plugin's models again raises
"Table already defined"."""
from __future__ import annotations
import asyncio
import hashlib
import importlib
import os
import uuid
from datetime import datetime, timezone
import pytest
pytestmark = pytest.mark.integration
def _app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
def _ha(sub):
from steward.core.plugin_manager import get_loaded_plugin_modules
loaded = get_loaded_plugin_modules().get("host_agent")
base = loaded.__name__ if loaded is not None else "plugins.host_agent"
return importlib.import_module(f"{base}.{sub}")
async def _seed(app):
from sqlalchemy import text
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
from steward.models.hosts import Host
from steward.models.users import User
tag = uuid.uuid4().hex[:8]
out = {"tokens": {}, "hosts": {}}
async with app.db_sessionmaker() as s:
async with s.begin():
user = User(username=f"cad-{tag}", email=f"cad-{tag}@test", password_hash="x")
group = AnsibleGroup(name=f"cad-{tag}")
s.add_all([user, group])
await s.flush()
for n in ("a", "b"):
host = Host(name=f"cad-{n}-{tag}", address="10.9.9.6")
s.add(host)
await s.flush()
target = AnsibleTarget(name=f"cad-{n}-{tag}", address="10.9.9.6", host_id=host.id)
target.groups = [group]
s.add(target)
token = f"tok-{n}-{tag}-{uuid.uuid4().hex}"
await s.execute(text(
"INSERT INTO host_agent_registrations "
"(id, host_id, token_hash, token_created_at, last_seen_at, maint_helper, "
" created_at, updated_at) VALUES (:id, :h, :hash, now(), now(), true, now(), now())"),
{"id": str(uuid.uuid4()), "h": host.id,
"hash": hashlib.sha256(token.encode()).hexdigest()})
out["tokens"][n], out["hosts"][n] = token, host
plan_id = str(uuid.uuid4())
await s.execute(text(
"INSERT INTO host_maintenance_plans (id, name, group_id, weekday, at_time, time_zone, "
" window_minutes, reboot_policy, enabled, created_by, created_at) "
"VALUES (:id, :name, :g, 6, '03:00', 'UTC', 240, 'if_required', true, :u, now())"),
{"id": plan_id, "name": f"plan-{tag}", "g": group.id, "u": user.id})
out["plan_id"] = plan_id
return out
async def _post(app, token, events=()):
resp = await app.test_client().post(
"/plugins/host_agent/ingest",
json={"samples": [{"ts": datetime.now(timezone.utc).isoformat(), "cpu_pct": 1.0}],
"events": list(events)},
headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200, await resp.get_data(as_text=True)
return (await resp.get_json()).get("maintenance", [])
async def _tick(app):
cadence = _ha("cadence")
async with app.db_sessionmaker() as s:
async with s.begin():
await cadence.tick(s, datetime.now(timezone.utc))
return await cadence.failed_hosts(s, datetime.now(timezone.utc))
async def _run(app, plan_id):
from sqlalchemy import text
async with app.db_sessionmaker() as s:
return (await s.execute(text(
"SELECT state, position, detail FROM host_maintenance_runs WHERE plan_id = :p"),
{"p": plan_id})).one()
def test_hosts_go_one_at_a_time_and_a_failure_stops_the_run():
app = _app()
async def _go():
seed = await _seed(app)
cadence, models = _ha("cadence"), _ha("models")
async with app.db_sessionmaker() as s:
async with s.begin():
plan = await s.get(models.HostMaintenancePlan, seed["plan_id"])
await cadence.start_run(s, plan, datetime.now(timezone.utc), manual=True)
await _tick(app)
# Only the first host (by name) has a job; the second waits.
ta, tb = seed["tokens"]["a"], seed["tokens"]["b"]
assert await _post(app, tb) == []
(step,) = await _post(app, ta)
assert step["action"] == "update"
done = {"kind": "maintenance", "id": step["id"], "action": "update", "state": "done",
"ok": True, "packages": 2, "reboot_required": False}
await _post(app, ta, [done])
# Back in service (posting, not a swarm node): the next host starts.
await _tick(app)
assert (await _run(app, seed["plan_id"]))[:2] == ("running", 1)
(step_b,) = await _post(app, tb)
failed = {"kind": "maintenance", "id": step_b["id"], "action": "update",
"state": "failed", "ok": False, "detail": "dpkg was interrupted"}
await _post(app, tb, [failed])
flags = await _tick(app)
state, _, detail = await _run(app, seed["plan_id"])
assert state == "failed" and seed["hosts"]["b"].name in detail and "dpkg" in detail
assert flags[seed["hosts"]["b"].name] == 1.0 and flags[seed["hosts"]["a"].name] == 0.0
asyncio.run(_go())