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
+169
View File
@@ -1343,3 +1343,172 @@ async def update_via_ansible():
if err:
return _error(400, "update_failed", err)
return redirect(url_for("ansible.run_detail", run_id=run.id))
# ── Maintenance cadence (#5768) ───────────────────────────────────────────────
def _run_rows(run, jobs: dict, names: dict) -> list[dict]:
"""A run's hosts in order, each with where it stands, then those passed over."""
import json as _json
rows = []
for i, host_id in enumerate(_json.loads(run.host_ids)):
job = jobs.get((run.id, host_id))
if job is not None:
text = maintenance.summary(job)
bad = job.state in ("failed", "timed_out")
busy = job.state in maintenance.ACTIVE
elif run.state == "running" and i == run.position:
text, bad, busy = "Starting", False, True
else:
text, bad, busy = ("Not reached" if run.state != "running" else "Waiting"), False, False
if run.state == "running" and i == run.position and job is not None and job.state == "done":
text, busy = "Coming back into service", True
rows.append({"host_id": host_id, "name": names.get(host_id, host_id), "text": text,
"bad": bad, "busy": busy, "detail": job.detail if job is not None else None})
for s in _json.loads(run.skipped):
rows.append({"host_id": s["host_id"], "name": names.get(s["host_id"], s["host_id"]),
"text": f"Skipped: {s['reason']}", "bad": False, "busy": False, "detail": None})
return rows
@host_agent_bp.get("/maintenance")
@require_role(UserRole.viewer)
async def maintenance_page():
"""Maintenance plans: a group's updates and reboots on a weekly cadence,
one host at a time. A running run first, then a failed one, then the plans
and their recent runs."""
from steward.models.ansible_inventory import AnsibleGroup
from steward.models.users import User
from . import cadence
from .models import HostMaintenanceJob, HostMaintenancePlan, HostMaintenanceRun
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
plans = (await db.execute(select(HostMaintenancePlan)
.order_by(HostMaintenancePlan.name))).scalars().all()
groups = (await db.execute(select(AnsibleGroup).order_by(AnsibleGroup.name))).scalars().all()
runs = (await db.execute(select(HostMaintenanceRun)
.order_by(HostMaintenanceRun.started_at.desc()).limit(60))).scalars().all()
jobs = {}
if runs:
for j in (await db.execute(select(HostMaintenanceJob).where(
HostMaintenanceJob.run_id.in_([r.id for r in runs]))
.order_by(HostMaintenanceJob.created_at))).scalars():
jobs[(j.run_id, j.host_id)] = j
names = dict((await db.execute(select(Host.id, Host.name))).all())
me = await db.get(User, session.get("user_id")) if session.get("user_id") else None
sizes = {g.id: len(await cadence.group_hosts(db, g.id)) for g in groups}
group_name = {g.id: g.name for g in groups}
by_plan: dict = {}
for r in runs:
by_plan.setdefault(r.plan_id, []).append(r)
view = []
for p in plans:
prs = by_plan.get(p.id, [])[:5]
view.append({"plan": p, "group": group_name.get(p.group_id, "—"),
"hosts": sizes.get(p.group_id, 0), "when": cadence.describe(p),
"next": cadence.next_slot(p, now) if p.enabled else None,
"runs": [{"run": r, "rows": _run_rows(r, jobs, names)} for r in prs]})
# What needs attention first: a run going now, then a run that failed lately.
plan_name = {p.id: p.name for p in plans}
live = [{"run": r, "plan": plan_name.get(r.plan_id, "—"), "rows": _run_rows(r, jobs, names)}
for r in runs if r.state == "running"]
failed = [{"run": r, "plan": plan_name.get(r.plan_id, "—")} for r in runs
if r.state == "failed" and r.finished_at and now - r.finished_at < timedelta(days=7)]
return await render_template(
"maintenance.html", plans=view, groups=groups, live=live, failed=failed,
weekdays=cadence.WEEKDAYS, policies=maintenance.REBOOT_POLICIES,
default_zone=(me.time_zone if me and me.time_zone else "UTC"))
@host_agent_bp.post("/maintenance/plans")
@require_role(UserRole.admin)
async def maintenance_plan_create():
from steward.core.formatting import zone
from steward.models.ansible_inventory import AnsibleGroup
from . import cadence
from .models import HostMaintenancePlan
form = await request.form
name = (form.get("name") or "").strip()[:128]
try:
weekday = int(form.get("weekday", ""))
at_time = (form.get("at_time") or "").strip()
cadence.parse_at(at_time)
window = int(form.get("window_minutes") or 240)
except ValueError:
return _error(400, "invalid", "weekday, time (HH:MM) and window must be numbers")
policy = form.get("reboot_policy") or "if_required"
tz = (form.get("time_zone") or "UTC").strip()
if not name or not 0 <= weekday <= 6 or not 30 <= window <= 24 * 60 \
or policy not in maintenance.REBOOT_POLICIES:
return _error(400, "invalid", "name, a weekday, a window of 30 min to 24 h and a reboot policy are needed")
if zone(tz) is timezone.utc and tz.upper() != "UTC":
return _error(400, "invalid", f"unknown time zone {tz!r}")
async with current_app.db_sessionmaker() as db:
if await db.get(AnsibleGroup, form.get("group_id") or "") is None:
return _error(400, "invalid", "pick an inventory group")
plan = HostMaintenancePlan(name=name, group_id=form["group_id"], weekday=weekday,
at_time=f"{cadence.parse_at(at_time):%H:%M}", time_zone=tz,
window_minutes=window, reboot_policy=policy, enabled=True,
created_by=session.get("user_id"),
# the first slot is the next one, not one already past
last_slot_at=datetime.now(timezone.utc))
db.add(plan)
await db.commit()
await audit_request("host_agent.maintenance_plan_created", "maintenance_plan", plan.id,
{"name": name})
return redirect(url_for("host_agent.maintenance_page"))
async def _plan_action(plan_id: str, act):
from .models import HostMaintenancePlan
async with current_app.db_sessionmaker() as db:
plan = await db.get(HostMaintenancePlan, plan_id)
if plan is None:
return _error(404, "not_found")
out = await act(db, plan)
if out is not None:
return out
await db.commit()
return redirect(url_for("host_agent.maintenance_page"))
@host_agent_bp.post("/maintenance/plans/<plan_id>/toggle")
@require_role(UserRole.admin)
async def maintenance_plan_toggle(plan_id: str):
async def act(db, plan):
plan.enabled = not plan.enabled
if plan.enabled:
plan.last_slot_at = datetime.now(timezone.utc) # no catching up on a missed slot
resp = await _plan_action(plan_id, act)
await audit_request("host_agent.maintenance_plan_toggled", "maintenance_plan", plan_id)
return resp
@host_agent_bp.post("/maintenance/plans/<plan_id>/delete")
@require_role(UserRole.admin)
async def maintenance_plan_delete(plan_id: str):
async def act(db, plan):
await db.delete(plan)
resp = await _plan_action(plan_id, act)
await audit_request("host_agent.maintenance_plan_deleted", "maintenance_plan", plan_id)
return resp
@host_agent_bp.post("/maintenance/plans/<plan_id>/run")
@require_role(UserRole.admin)
async def maintenance_plan_run_now(plan_id: str):
"""Run the plan now. Its schedule keeps its time (Ansible ruling: Run now
leaves the schedule as it was)."""
from . import cadence
from .models import HostMaintenanceRun
async def act(db, plan):
busy = (await db.execute(select(HostMaintenanceRun.id).where(
HostMaintenanceRun.plan_id == plan.id,
HostMaintenanceRun.state == "running"))).first()
if busy is not None:
return _error(409, "busy", "this plan already has a run going")
await cadence.start_run(db, plan, datetime.now(timezone.utc), manual=True)
resp = await _plan_action(plan_id, act)
await audit_request("host_agent.maintenance_plan_run", "maintenance_plan", plan_id)
return resp