Files
FabledSteward/plugins/host_agent/scheduler.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

144 lines
6.4 KiB
Python

# plugins/host_agent/scheduler.py
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Iterable
from sqlalchemy import select
from steward.core.alerts import MetricBatch
from steward.core.scheduler import ScheduledTask
from steward.core.watch import watch
from steward.hosts.service import agent_stale_after_seconds
from steward.models.hosts import Host
from steward.models.metrics import PluginMetricLatest
from .downtime import announced_stops, restart_grace_seconds
from .models import HostAgentRegistration
logger = logging.getLogger(__name__)
def arrivals(app) -> dict[str, datetime]:
"""{host id: when its agent's newest post reached Steward}, in memory (#5514).
last_seen_at is written in the ingest's own transaction, so when Steward
can't store a post (its storage stalls and the request is dropped) the
agent's report is lost with it, and an agent posting every few seconds read
as offline. A post that arrived is the agent reaching Steward; storing it
is Steward's part. Set before any write, so it holds however slow they are.
"""
got = getattr(app, "_agent_arrivals", None)
if got is None:
got = app._agent_arrivals = {}
return got
def seconds_since_seen(regs: Iterable, host_names: dict[str, str],
now: datetime, watching_since: datetime | None = None,
arrived: dict[str, datetime] | None = None,
already_silent: set[str] | frozenset = frozenset(),
explained_until: dict[str, datetime] | None = None,
) -> list[tuple[str, float]]:
"""(host name, seconds since its agent last reported) per registration.
An agent that has never reported counts from when it was registered. A
registration whose host is gone is skipped. Nothing counts from before
`watching_since`, when Steward last started watching without a break: an
agent can't report while Steward is down, so that time is not its silence
(#5441). An agent that is really gone reads as offline once it has been
quiet that long while Steward watched. `arrived` (arrivals(app)) counts a
post Steward received but couldn't store as the agent seen (#5514).
`already_silent` names hosts whose last recorded figure was already past
the offline window: their agent was gone before Steward stopped, so their
whole silence counts. Counting them from `watching_since` read them as
just seen, which resolved their alert on every restart and fired it again
three minutes later (#5719).
`explained_until` ({host id: time}) is a stop the agent announced, a reboot
or its own restart, plus its grace (#5756): until then the agent reads as
just seen. A host still silent past it reads its whole silence, so agent
offline fires as the grace runs out, for silence nobody explained.
"""
out = []
for r in regs:
name = host_names.get(r.host_id)
seen = max((t for t in (r.last_seen_at or r.created_at, (arrived or {}).get(r.host_id))
if t is not None), default=None)
if name is None or seen is None:
continue
if watching_since is not None and name not in already_silent:
seen = max(seen, watching_since)
if r.host_id in (explained_until or {}):
seen = max(seen, explained_until[r.host_id])
out.append((name, max(0.0, (now - seen).total_seconds())))
return out
async def record_seconds_since_seen(app) -> None:
"""Record host_agent/<host>/seconds_since_seen for every registered agent,
so "agent offline" is an ordinary alert rule (seconds_since_seen > 180)."""
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as session:
async with session.begin():
regs = (await session.execute(select(HostAgentRegistration))).scalars().all()
watching_since = watch(app, "host_agent_seconds_since_seen").tick(now)
if not regs:
return
host_names = dict((await session.execute(
select(Host.id, Host.name).where(Host.id.in_([r.host_id for r in regs]))
)).all())
# Agents whose last figure was already past the offline window keep
# counting through a restart of Steward (#5719).
stale_after = agent_stale_after_seconds(app.config)
already_silent = set((await session.execute(
select(PluginMetricLatest.resource_name).where(
PluginMetricLatest.source_module == "host_agent",
PluginMetricLatest.metric_name == "seconds_since_seen",
PluginMetricLatest.resource_name.in_(list(host_names.values())),
PluginMetricLatest.value > stale_after))).scalars())
explained = await announced_stops(session, now, restart_grace_seconds(app.config))
batch = MetricBatch()
for name, seconds in seconds_since_seen(regs, host_names, now, watching_since,
arrivals(app), already_silent, explained):
batch.add(source_module="host_agent", resource_name=name,
metric_name="seconds_since_seen", value=seconds, recorded_at=now)
await batch.record(session)
def make_task(app) -> ScheduledTask:
async def _record():
try:
await record_seconds_since_seen(app)
except Exception:
logger.exception("host_agent: recording seconds_since_seen failed")
return ScheduledTask(
name="host_agent_seconds_since_seen",
coro_factory=_record,
interval_seconds=60,
run_on_startup=False,
)
def make_maintenance_task(app) -> ScheduledTask:
"""Gives up on maintenance jobs past their deadline (#5767). Every other
move a job makes comes from its agent's posts."""
async def _tick():
from . import maintenance
try:
async with app.db_sessionmaker() as session:
async with session.begin():
gave_up = await maintenance.tick(session, datetime.now(timezone.utc))
if gave_up:
logger.warning("host_agent: %d maintenance job(s) timed out", gave_up)
except Exception:
logger.exception("host_agent: maintenance tick failed")
return ScheduledTask(
name="host_agent_maintenance",
coro_factory=_tick,
interval_seconds=60,
run_on_startup=False,
)