Files
FabledSteward/plugins/host_agent/maintenance.py
T
bvandeusenandClaude Opus 5.5 22b0be2b6c
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m28s
CI / unit (push) Failing after 1m31s
CI / publish (push) Skipped
feat(host_agent): Steward hands a host a maintenance job and follows it through the reboot (#5767)
#5767 "Steward hands a host a maintenance job and follows it to the end,
through its own restarts", milestone 514.

A host_maintenance_jobs row per job: queued → updating → [draining →]
rebooting → [undraining →] done, or failed / timed_out. Steward's answer to
an ingest post carries the step that host's agent should carry out now; the
agent (1.18.0) leaves each step id for the root helper once, and the helper
refuses an id it already ran, so repeats are harmless. Results come back as
the #5766 maintenance events, matched by step id; the reboot completes when
the host's `started` event has a boot_at after the reboot was handed out.

The job reboots only when the update says one is needed (or never, if asked).
A swarm node is drained first by itself if it's a manager or by a manager
with the helper, and made active after; with no manager to drain it the job
is refused. A minute tick times out a job past its state's deadline. A
maintenance window over host_agent/<host> lasts the rest of the job.

Everything is on the row, so a Steward restart, or the reboot of the host
Steward runs on, resumes from the next post. Ingest runs it in its own
savepoint so a job going wrong never costs the samples.

The Agent panel leads with a running job and says what a finished one did;
Manage agent has Update now (with "never reboot"), or says why a job can't
start. Integration test: hand off, Steward restarts, the post-boot events
close the job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:42:12 -04:00

385 lines
16 KiB
Python

# plugins/host_agent/maintenance.py
"""A host's maintenance job, followed to its end through any restart (#5767).
Steward plans and the host's agent acts: a job is one host's update and, only
when the update says one is needed, its reboot, with the host's swarm node
drained first and made active again after. Each action is one step the agent
hands to its root helper (maint.py, #5766). Everything lives on the job's row,
so Steward restarting, or the host Steward runs on rebooting, loses nothing:
the next post from the agent carries the step's result, and the `started`
event a host sends after booting is what completes a reboot.
The transitions here are model-free (a job is anything with the attributes),
so they're unit-tested without a database; routes.ingest and the scheduler's
tick apply them.
States: queued → updating → [draining →] rebooting → [undraining →] done,
or failed / timed_out. A job with no reboot needed goes updating → done.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
ACTIVE = ("queued", "updating", "draining", "rebooting", "undraining")
FINISHED = ("done", "failed", "timed_out")
# The helper action each handed state asks for.
STEP_ACTION = {"updating": "update", "draining": "drain", "rebooting": "reboot",
"undraining": "undrain"}
# How long a state may last before the job is given up. Queued counts from the
# job's creation (the host may be silent); the rest from their hand-off.
DEADLINES = {
"queued": timedelta(hours=1),
"updating": timedelta(minutes=90), # the helper itself stops at 60
"draining": timedelta(minutes=10),
"rebooting": timedelta(minutes=30),
"undraining": timedelta(minutes=10),
}
# A host's clock and Steward's differ by seconds; a boot this far before the
# reboot was handed out is still taken as that reboot.
BOOT_SLACK = timedelta(seconds=60)
DETAIL_MAX = 500
def step_id(job, state: str) -> str:
"""The helper request id for a job's step: unique per job and step, and in
the helper's id alphabet (letters, digits, dashes)."""
return f"{job.id}-{STEP_ACTION[state]}"
def _enter(job, state: str, now: datetime) -> None:
job.state, job.state_at = state, now
job.step_id = step_id(job, state) if state in STEP_ACTION else None
job.step_seen = False # a new step is handed out until its result arrives
if state in FINISHED:
job.finished_at = now
def _finish(job, state: str, now: datetime, detail: str | None = None) -> None:
if detail:
job.detail = detail[:DETAIL_MAX]
_enter(job, state, now)
def start(job, now: datetime) -> None:
"""The host's agent is about to be handed the job: update first."""
if job.state == "queued":
_enter(job, "updating", now)
def executor(job) -> str:
"""The host whose agent carries out the current step: the job's own host,
except a drain or undrain, which a swarm manager does."""
if job.state in ("draining", "undraining") and job.drain_host_id:
return job.drain_host_id
return job.host_id
def handoff(job) -> dict | None:
"""What to hand the executing agent now, or None. Handed on every post
until a result for the step arrives; the agent and the helper both ignore
an id they've already taken, so a repeat is harmless."""
if job.state not in STEP_ACTION or job.step_seen:
return None
out = {"id": job.step_id, "action": STEP_ACTION[job.state]}
if job.state in ("draining", "undraining") and job.node_id:
out["node"] = job.node_id
return out
def _after_update(job, now: datetime) -> None:
if not (job.reboot_needed and job.reboot_policy != "never"):
_enter(job, "done", now)
elif job.node_id and job.drain_host_id:
_enter(job, "draining", now)
else:
_enter(job, "rebooting", now)
def apply_result(job, event: dict, now: datetime) -> bool:
"""Apply a `maintenance` event for this job's current step; True when the
job changed. A result for a step the job has moved past is ignored, so a
repeated or late report does nothing."""
if job.state not in STEP_ACTION or event.get("id") != job.step_id:
return False
state = event.get("state")
if state in ("running", "rebooting"):
if job.step_seen:
return False
job.step_seen = True
return True
if state not in ("done", "failed"):
return False
if state == "failed" or event.get("ok") is False:
what = STEP_ACTION[job.state]
detail = f"{what} failed: {event.get('detail') or 'no detail'}"
if job.state == "undraining":
detail += " (the node is still drained)"
_finish(job, "failed", now, detail)
return True
if job.state == "updating":
if isinstance(event.get("packages"), int):
job.packages = event["packages"]
job.reboot_needed = event.get("reboot_required") is True
_after_update(job, now)
elif job.state == "draining":
_enter(job, "rebooting", now)
elif job.state == "undraining":
_enter(job, "done", now)
else: # rebooting: the boot completes it, not this
return False
return True
def apply_boot(job, boot_at: datetime | None, now: datetime) -> bool:
"""A `started` event from the job's host. A boot after the reboot was
handed out is that reboot: the host is back."""
if job.state != "rebooting" or boot_at is None or job.state_at is None:
return False
if boot_at < job.state_at - BOOT_SLACK:
return False # the agent restarted; the host didn't
job.back_at = now
if job.node_id and job.drain_host_id:
_enter(job, "undraining", now)
else:
_enter(job, "done", now)
return True
def check_deadline(job, now: datetime) -> bool:
"""Give up on a job whose state has lasted past its deadline."""
limit = DEADLINES.get(job.state)
since = job.state_at or job.created_at
if limit is None or since is None or now - since <= limit:
return False
waiting = {"queued": "the host's agent didn't check in",
"updating": "the update didn't finish",
"draining": "the node wasn't drained",
"rebooting": "the host didn't come back",
"undraining": "the node wasn't made active again"}[job.state]
_finish(job, "timed_out", now, f"{waiting} within {int(limit.total_seconds() // 60)} min")
return True
def window_end(job, now: datetime) -> datetime:
"""When the job's maintenance window should end: the longest the rest of
the job could take, or now once it has finished."""
if job.state in FINISHED:
return now
left = sum((DEADLINES[s] for s in ACTIVE[ACTIVE.index(job.state):]), timedelta())
return now + left
def utcnow() -> datetime:
return datetime.now(timezone.utc)
# ── applied to the database: ingest, the tick, and starting a job ────────────
async def _window(session, job, host_name: str, now: datetime) -> None:
"""Keep the job's maintenance window over the host until the job ends: one
scoped to this host's agent metrics, so an alert the job causes waits."""
from steward.models.alerts import MaintenanceWindow
if job.window_id:
w = await session.get(MaintenanceWindow, job.window_id)
if w is not None:
w.end_at = max(w.start_at, window_end(job, now))
return
if job.created_by is None or job.state in FINISHED:
return # a window needs an owner; none for a finished job
w = MaintenanceWindow(name=f"Maintenance: {host_name}"[:128], start_at=now,
end_at=window_end(job, now), scope_source="host_agent",
scope_resource=host_name, created_by=job.created_by)
session.add(w)
await session.flush()
job.window_id = w.id
async def _host_name(session, host_id: str) -> str:
from steward.models.hosts import Host
host = await session.get(Host, host_id)
return host.name if host is not None else host_id
async def apply_events(session, host_id: str, events, now: datetime) -> int:
"""Apply one post's `maintenance` results and `started` boots to the jobs
they belong to; returns how many jobs changed. A result is matched by its
step id, so a drain reported by a manager reaches the job of the node it
drained."""
from sqlalchemy import select
from .downtime import _at
from .models import HostMaintenanceJob
if not isinstance(events, list):
return 0
changed = set()
for event in events:
if not isinstance(event, dict):
continue
if event.get("kind") == "maintenance" and isinstance(event.get("id"), str):
job = (await session.execute(select(HostMaintenanceJob).where(
HostMaintenanceJob.step_id == event["id"],
HostMaintenanceJob.state.in_(ACTIVE)))).scalar_one_or_none()
if job is not None and apply_result(job, event, now):
changed.add(job)
elif event.get("kind") == "started":
boot_at = _at(event.get("boot_at"))
for job in (await session.execute(select(HostMaintenanceJob).where(
HostMaintenanceJob.host_id == host_id,
HostMaintenanceJob.state == "rebooting"))).scalars():
if apply_boot(job, boot_at, now):
changed.add(job)
for job in changed:
await _window(session, job, await _host_name(session, job.host_id), now)
return len(changed)
async def handoffs(session, host_id: str, host_name: str, now: datetime) -> list[dict]:
"""The steps this host's agent should carry out now. A queued job for the
host starts here, when its agent is known to be listening."""
from sqlalchemy import or_, select
from .models import HostMaintenanceJob
jobs = (await session.execute(select(HostMaintenanceJob).where(
HostMaintenanceJob.state.in_(ACTIVE),
or_(HostMaintenanceJob.host_id == host_id,
HostMaintenanceJob.drain_host_id == host_id)
).order_by(HostMaintenanceJob.created_at))).scalars().all()
out = []
for job in jobs:
if job.state == "queued" and job.host_id == host_id:
start(job, now)
await _window(session, job, host_name, now)
if executor(job) == host_id:
step = handoff(job)
if step is not None:
out.append(step)
return out
async def tick(session, now: datetime) -> int:
"""Give up on jobs past their deadline; returns how many."""
from sqlalchemy import select
from .models import HostMaintenanceJob
gave_up = 0
for job in (await session.execute(select(HostMaintenanceJob).where(
HostMaintenanceJob.state.in_(ACTIVE)))).scalars():
if check_deadline(job, now):
gave_up += 1
await _window(session, job, await _host_name(session, job.host_id), now)
return gave_up
class CannotStart(Exception):
"""Why a host can't take a maintenance job now, in words for the page."""
def pick_drainer(node_hostname_to_id: dict, managers: dict, host_name: str,
host_id: str, helpers: set) -> tuple:
"""(node_id, drain_host_id) for a host in a swarm, (None, None) for one
that isn't. `managers` maps a manager's host id to its swarm node id;
`helpers` is the hosts whose agent has the maintenance helper. The host
drains itself when it is a manager, else another manager does."""
node_id = node_hostname_to_id.get(host_name.lower())
if node_id is None:
return None, None
if host_id in managers and host_id in helpers:
return node_id, host_id
for mgr in sorted(managers):
if mgr != host_id and mgr in helpers:
return node_id, mgr
raise CannotStart("it is a swarm node and no swarm manager has the maintenance "
"helper to drain it: run Update agent on a manager")
async def plan_job(session, host, reg, reboot_policy: str = "if_required") -> tuple:
"""(node_id, drain_host_id) for a job on `host`, or CannotStart saying why
it can't have one now. The panel asks this before offering the button."""
from sqlalchemy import select
from .models import HostAgentRegistration, HostMaintenanceJob
if reg is None or not reg.maint_helper:
raise CannotStart("its agent has no maintenance helper: run Update agent first")
if reboot_policy not in ("if_required", "never"):
raise CannotStart(f"unknown reboot policy {reboot_policy!r}")
busy = (await session.execute(select(HostMaintenanceJob.id).where(
HostMaintenanceJob.host_id == host.id,
HostMaintenanceJob.state.in_(ACTIVE)))).first()
if busy is not None:
raise CannotStart("a maintenance job is already running on it")
if reboot_policy == "never":
return None, None
nodes, managers = await _swarm(session)
helpers = set((await session.execute(select(HostAgentRegistration.host_id).where(
HostAgentRegistration.maint_helper.is_(True)))).scalars())
return pick_drainer(nodes, managers, host.name, host.id, helpers)
async def create_job(session, host, reg, user_id: str | None,
reboot_policy: str = "if_required", now: datetime | None = None):
"""Queue a maintenance job for `host`; it starts with the agent's next post."""
from .models import HostMaintenanceJob
now = now or utcnow()
node_id, drain_host_id = await plan_job(session, host, reg, reboot_policy)
job = HostMaintenanceJob(host_id=host.id, state="queued", state_at=now,
reboot_policy=reboot_policy, node_id=node_id,
drain_host_id=drain_host_id, created_by=user_id, created_at=now)
session.add(job)
await session.flush()
return job
async def latest_job(session, host_id: str, now: datetime, days: int = 7):
"""The host's running job, else its last one in `days`, for the panel."""
from sqlalchemy import select
from .models import HostMaintenanceJob
return (await session.execute(select(HostMaintenanceJob).where(
HostMaintenanceJob.host_id == host_id,
HostMaintenanceJob.created_at >= now - timedelta(days=days),
).order_by(HostMaintenanceJob.state.in_(ACTIVE).desc(),
HostMaintenanceJob.created_at.desc()).limit(1))).scalar_one_or_none()
LABELS = {
"queued": "Waiting for the agent",
"updating": "Updating",
"draining": "Draining its swarm node",
"rebooting": "Rebooting",
"undraining": "Making its swarm node active again",
"done": "Done",
"failed": "Failed",
"timed_out": "Timed out",
}
def summary(job) -> str:
"""One line on what the job did, for the panel."""
if job.state not in FINISHED:
return LABELS[job.state]
parts = []
if job.packages is not None:
parts.append(f"{job.packages} package{'s' if job.packages != 1 else ''} updated")
if job.back_at is not None:
parts.append("rebooted")
elif job.reboot_needed and job.reboot_policy == "never":
parts.append("reboot needed, not allowed by this job")
elif job.state == "done" and job.reboot_needed is False:
parts.append("no reboot needed")
head = LABELS[job.state]
return f"{head}: {', '.join(parts)}" if parts else head
async def _swarm(session) -> tuple:
"""({node hostname: node id}, {manager host id: its node id}) from what the
Docker plugin last stored; empty when it isn't installed. A manager is a
host whose agent reported the swarm's nodes."""
try:
from plugins.docker.models import DockerSwarmNode
except ImportError:
return {}, {}
from sqlalchemy import select
from steward.models.hosts import Host
rows = (await session.execute(select(DockerSwarmNode))).scalars().all()
nodes = {(n.hostname or "").lower(): n.node_id for n in rows if n.hostname}
reporters = {n.host_id for n in rows}
names = dict((await session.execute(select(Host.id, Host.name).where(
Host.id.in_(reporters)))).all()) if reporters else {}
managers = {hid: nodes.get(name.lower()) for hid, name in names.items()}
return nodes, managers