145 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 38ae99fb8e feat(hosts): the hottest sensor is named with its chip, and the host-reading cutoffs are read and set over MCP (#6806)
CI / lint (push) Successful in 1s
CI / unit (push) Successful in 1m39s
CI / integration (push) Successful in 2m2s
CI / publish (push) Successful in 1m12s
Agent 1.27.0 sends each temperature sensor's hwmon chip. Steward keeps the
chip per host and label beside the reading, so a sensor's metric key stays
its label, and the strip and the heat alert name it as "NVMe · Sensor 2"
rather than a bare "Sensor 2".

get_thresholds (viewer) and set_thresholds (admin, audited) read and change
the warn/crit cutoffs CPU, memory, disk, load and temperature are graded by,
as Settings → Thresholds does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:39:31 -05:00
bvandeusenandClaude Opus 5.5 6eeb4039d7 feat(hosts): the live strip shows the hottest sensor's temperature, get_host marks sensors that don't count, and the Monitors card's button reads "Add" (#6803)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m58s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 1m7s
- The host page's live strip gains a Temperature tile: temp_c_max (real
  thermometers only), its last 6 h and the sensor setting it, graded by
  Settings → Thresholds and counted in the header's worst reading, in °C.
- MCP get_host: each temperature carries `counted`; an unwired input is
  `counted: false` with `not_counted_because`, listed after the real ones.
  host_agent registers the judge (judge_sensors), so core imports no plugin.
- The Monitors card's "Add monitor" wrapped into two lines beside the
  uptime; it reads "Add", titled "Add a monitor for this host".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:00:49 -05:00
bvandeusenandClaude Opus 5.5 c38fd68e48 feat(ui): a host's "Full metrics" heads its page, and a resource's anomalies are one line with a way to review them (#6797)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m38s
CI / integration (push) Successful in 2m0s
CI / publish (push) Successful in 1m10s
The host page's link to the full metrics sat in the Agent card, under the
anomaly card and the vitals strip, and was missed. It now heads the vitals
strip at the top of the page.

The anomaly card on the host, monitor, container and Traefik pages listed
every anomaly of the last 30 days with its review form, reviewed ones
included, and took over the top of the page. It is now one line: how many
wait for review, which are out of their normal now, and a Review button to
Alerts → Anomalies narrowed to that resource (?source=&resource=), where a
review returns. It is hidden when nothing waits and nothing is going on.
The callers fetch up to 100 rows so the count isn't capped at 10 or 20.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:09:18 -05:00
bvandeusenandClaude Opus 5.5 12a5f83c67 fix(host-agent): a large update records its package count (#6794)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m38s
CI / integration (push) Successful in 2m1s
CI / publish (push) Successful in 20s
The root helper counted updated packages from the last 2,000 characters
of the package manager's output. Every manager prints its count (apt's
"N upgraded, N newly installed", dnf's "Upgrade N Packages", zypper's
"N packages to upgrade", pacman's "Packages (N)") before the long log of
what it then does, so a large update pushed the line out of the tail
and the job recorded no count. Docker03's 2026-11-01 job applied 17
updates and reported none; Docker02's single package was counted.

The update steps now read their whole output (run(keep=None)), and only
the failure text and the job's detail are cut short. Agent 1.26.1, so
hosts show the helper as an update to take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:03:07 -05:00
bvandeusenandClaude Opus 5.5 c3072312a4 fix(anomalies): one slow reading no longer opens an anomaly (#6793)
CI / integration (push) Successful in 2m3s
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m38s
CI / publish (push) Successful in 1m30s
A single tall reading lifted a 15-minute average past the usual height
on its own, so one slow request opened a row that stayed open until the
reading rolled out of the window. Four of the seven rows that occurred
after the 10-30 detection change were this: HA web (one 6.7 s check),
error-pages, codex and mailcow-svc latency.

detect() now opens by height only when the window is still past the
usual height, and still departs, with its highest reading left out. A
window that fails that falls through to the held test, which now needs
at least HELD_FEWEST (3) readings behind its median, so a lone reading
on a quiet router opens nothing either way.

The median and the topless average share one per-key read (_per_key).
The architecture doc and get_metric_normal's description state the
rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:41:29 -05:00
bvandeusenandClaude Opus 5.5 19f405e55a test(homeassistant): the page's wording on who reboots after an OS update (#6755)
CI / unit (push) Successful in 1m36s
CI / lint (push) Successful in 1s
CI / integration (push) Successful in 2m0s
CI / publish (push) Successful in 19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:22:37 -05:00
bvandeusenandClaude Opus 5.5 ecc59cc882 feat(homeassistant): a scheduled OS update reboots the machine and is done once the new version runs (#6755)
CI / lint (push) Successful in 1s
CI / unit (push) Successful in 1m37s
CI / integration (push) Failing after 2m0s
CI / publish (push) Skipped
Home Assistant writes an OS update and leaves the reboot to someone, so a scheduled
one never finished unattended. The schedule now asks for the reboot once, follows it
through Home Assistant going away, and counts it done when the Supervisor reports a
different version with nothing pending. A machine that doesn't come back or falls
back to the old version fails after 20 minutes, which ends the slot and raises
update_failed. Install on the page and through MCP still leave the reboot to the person.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:15:03 -05:00
bvandeusenandClaude Opus 5.5 4c57735dd9 fix(homeassistant): storage reads used of total with its percentage, and an OS update is said to wait on a reboot (#6753, #6754)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m59s
CI / publish (push) Successful in 19s
The tile shows Home Assistant's own used figure beside the total, so it matches
Home Assistant's storage page. An OS update doesn't reboot the machine: Supervisor
writes the image, reports the new version and raises a repair asking for a reboot.
The confirmation, schedule warning, MCP docs and plugin docs now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:51:28 -05:00
bvandeusenandClaude Opus 5.5 5ffececae5 feat(homeassistant): the page shows the device's storage and how full it is (#6753)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m59s
CI / publish (push) Successful in 20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:03:13 -05:00
bvandeusenandClaude Opus 5.5 ee9913b6d0 fix(prometheus): the migration removing sign-in targets runs on a new database, where the metric tables may not exist yet (#6747)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m38s
CI / integration (push) Successful in 1m57s
CI / publish (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:24:00 -05:00
bvandeusenandClaude Opus 5.5 f7a856cb90 feat(homeassistant): it shows system problems and installs updates on a weekly slot, and leaves device readings alone (#6748, #6749, #6747)
CI / unit (push) Successful in 1m37s
CI / integration (push) Failing after 1m54s
CI / publish (push) Skipped
CI / lint (push) Successful in 2s
The connection's job is keeping Home Assistant running. Each poll now reads open
repairs, Supervisor health, disk, recovery mode and add-ons that should be running
over its websocket, for the page, the overview and the problems_open, disk_free_pct
and update_failed metrics. That needs a websocket client, so websockets joins the
dependencies.

A weekly slot, set on the page or through MCP and off until then, installs add-ons,
then Core, then the OS, each after its own confirmed backup, ending at the first
failure. The slot arithmetic moves from the host agent's cadence to steward.core.weekly
so both share it.

The device scrape goes: the page's Metrics section and a target's token_from from
4c9ed02 are removed, and a migration deletes targets using it, as they'd otherwise ask
with no token. A refused token still stops a scrape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:16:42 -05:00
bvandeusenandClaude Opus 5.5 4c9ed02652 feat(homeassistant): its page offers the metrics scrape under the same sign-in, so one user covers everything (#6744)
CI / lint (push) Successful in 1s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m58s
CI / publish (push) Successful in 1m13s
A Prometheus target's token_from names the plugin that supplies its token at each
scrape. Read its metrics makes the target; Use this sign-in moves one at that address
across, keeping its name, filters and history. A refusal stops the connection, so one
Try again or sign-in starts everything, and the sign-in is only sent to its own address.
A 404 says how to turn on Home Assistant's Prometheus integration, as Steward doesn't
change Home Assistant. Refused also joins the volatile columns a backup check ignores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:06:55 -05:00
bvandeusenandClaude Opus 5.5 66a5dd12fd fix(prometheus): a refused token stops the scraping, as Home Assistant bans an address after a few refusals (#6355)
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m55s
CI / publish (push) Successful in 20s
CI / lint (push) Successful in 2s
Also names the Home Assistant connection by its address in the audit log and
timeline, where its id showed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 09:41:53 -05:00
bvandeusenandClaude Opus 5.5 a1dfa7b821 test: an invite's zone may be EDT or EST, and an unsent install counts from a recent backup (#6355)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m53s
CI / unit (push) Successful in 1m37s
CI / publish (push) Successful in 1m13s
The invite test asserted EDT for a time a day from now, so it failed from today:
a one-day link now ends after New York's clocks change on 1 November. It would
have stayed red until March.

The Home Assistant test used a fixed backup time, which put the unsent install
past its deadline at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 12:19:12 -04:00
bvandeusenandClaude Opus 5.5 0aed863c67 feat(homeassistant): an admin signs in to Home Assistant, with nothing added to it (#6355)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Failing after 1m55s
CI / publish (push) Skipped
Replaces the read-only token and the webhook automation, which every install
would have had to paste into its own Home Assistant. Connect now goes to Home
Assistant's own sign-in page, and Steward keeps the refresh token that comes
back, encrypted, and calls backup.create_automatic and update.install directly.

The connection page warns that the stored credential has the same access as
the user who signed in, and recommends a service account so Steward's actions
are easy to tell apart in Home Assistant's logs. A user who isn't an
administrator gets the status page and no Install.

Those actions answer only when finished, so a request cut off once sent counts
as sent and the outcome is read from the entities. Removing the connection, or
signing in again, ends the old sign-in on Home Assistant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 12:14:34 -04:00
bvandeusenandClaude Opus 5.5 9c9e14e79c fix(homeassistant): its MCP tools register, stating read_only as the server requires (#6355)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m39s
CI / integration (push) Successful in 1m51s
CI / publish (push) Successful in 19s
Without it the plugin's hook raised at boot and left it with no tools. The
test's stand-in for register gave read_only a default, so now both arguments
are required there too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:25:51 -04:00
bvandeusenandClaude Opus 5.5 fe9bba1042 test(homeassistant): its page is a section's top page, and a test's unused import goes (#6355)
CI / unit (push) Successful in 1m37s
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m53s
CI / publish (push) Successful in 1m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:16:46 -04:00
bvandeusenandClaude Opus 5.5 b2a6e92ed3 feat(homeassistant): waiting updates, backup age, and updates installed after a confirmed backup (#6355)
CI / unit (push) Failing after 1m42s
CI / integration (push) Successful in 1m51s
CI / publish (push) Skipped
CI / lint (push) Failing after 2s
A new plugin reads Home Assistant with a read-only token every 5 minutes and
shows which Core, OS and add-on updates are waiting and how old the last backup
is. Install takes a backup through a local-only webhook, confirms Home
Assistant's last-backup time moved, installs, and follows the update to its end,
through Home Assistant's and Steward's own restarts. One runs at a time. Each
ends as one timeline entry.

Steward holds nothing that can act on the house: the webhook's automation can
only take a backup or install an allowed update (System 231's ruling). A refused
token stops polling, as Home Assistant bans an address after a few refusals.

Milestone 576 steps 1 to 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:09:27 -04:00
bvandeusenandClaude Opus 5.5 8b9b7fc11d chore(docker): the history move logs which services its old keys belong to (#6718)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m35s
CI / integration (push) Successful in 1m47s
CI / publish (push) Successful in 19s
#6718 "A swarm container's metrics, normal and anomalies are keyed by its task
name, so they start over whenever the task moves". The move found 391,091
hourly-only task names against 51 services and about 400 keys with raw samples.
Log the services with the most keys and a few names, to read before those keys
start moving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:13:20 -04:00
bvandeusenandClaude Opus 5.5 acc487f9c3 fix(docker): the move of task-named history rests between passes (#6718)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m34s
CI / integration (push) Successful in 1m49s
CI / publish (push) Successful in 20s
#6718 "A swarm container's metrics, normal and anomalies are keyed by its task
name, so they start over whenever the task moves". Live, the move took
VDNT-Docker02's I/O pressure (psi_io_full_avg60) from about 1% to about 23%,
and passes of 5000 raw samples outran their 15 s slot. Each moved sample
rewrites its row and every index on it.

Batches are 1000 samples, and after a pass the task waits four times as long
as it took, so it holds the disk for about a fifth of the time and the move
slows if the disk does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:01:10 -04:00
bvandeusenandClaude Opus 5.5 4689386b69 fix(docker): the move of task-named history finishes many small keys in one pass (#6718)
CI / integration (push) Successful in 1m48s
CI / publish (push) Successful in 20s
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m34s
#6718 "A swarm container's metrics, normal and anomalies are keyed by its task
name, so they start over whenever the task moves". Live, the move found 391,440
keys, about 2,500 of them with raw samples, and took one key per 15 s pass:
about 68 days, in sorted order, so recent history could wait months while
charts and normals read thin.

A pass now runs for up to 2 s, and keys with raw samples go first. Old hourly
keys take a few statements each. A key with no anomalies no longer rewrites the
service's timeline entries. The start logs how many keys each table holds, as
that count is far higher than expected and not explained yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:53:43 -04:00
bvandeusenandClaude Opus 5.5 177ea12c9b fix(anomalies): a burst within what a metric reaches in most hours opens nothing (#6727)
CI / unit (push) Successful in 1m36s
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m50s
CI / publish (push) Successful in 1m11s
#6727 "A metric that bursts now and then opens anomalies at hours whose usual
peak reads 0". usual_peak is the p90 of a slot's hourly max, so a metric that
bursts in under about 1 hour in 10 reads 0 there however often it bursts across
the day. #6723 "An anomaly opens on a burst of minutes inside the metric's usual
peak, so most rows aren't outside the norm" therefore still opened on those.

usual_anywhere is the median, across the hours of the day, of each hour's
highest peak. Height is now judged against the greater of it and the slot's
usual peak. Bursts at any hour lift the median; a job at a time of its own
raises a few hours and leaves it low, so the same level at another time of day
is still an anomaly, as System 182 rules. A held level still opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:45:16 -04:00
bvandeusenandClaude Opus 5.5 db39fc2604 feat(docker): a swarm service's metrics stay under one key across its tasks (#6718)
#6718 "A swarm container's metrics, normal and anomalies are keyed by its task
name, so they start over whenever the task moves". The operator: "the metrics
should be keys to the service name not the task name... as that's what's
consistent between updates."

CPU, memory, restart count and health now go under "swarm/<service>", so a
normal, its anomalies and an alert rule carry across a redeploy or a move
between nodes. Network rates stay "<host>/<service>", the task id gone, as they
answer who on a host is sending. Tasks of a multi-task service share the key,
so its level reads as an average across them (of 51 live services, only
authentik-worker and portainer_agent run more than one task).

History under task names moves in a background task, 5000 raw samples every
15 s, not a migration: the raw table holds about 2M such rows and the app
would wait on it at boot. Hourly rows combine where two tasks share an hour,
and rows still to review merge to one per metric.

A service's anomalies no longer reach a host's page, as it has no one host;
get_anomaly reads what happened around one from the service's own events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:45:16 -04:00
bvandeusenandClaude Opus 5.5 18191aa858 test(alerts): the no-data test's alert state carries last_value (#6724)
CI / unit (push) Successful in 1m36s
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m47s
CI / publish (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:09:57 -04:00
bvandeusenandClaude Opus 5.5 45aae6d585 feat(syslog): list patterns and add a ready-made one through MCP
CI / unit (push) Failing after 1m35s
CI / lint (push) Successful in 1s
CI / integration (push) Successful in 1m47s
CI / publish (push) Skipped
Patterns could only be added on the Syslog page, so an agent asked to map
them on the NASes couldn't. list_syslog_patterns gives each pattern's metric
and the ready-made ones not added; add_syslog_preset (admin, audited) adds one.
The key-picking helper moves beside the patterns for both callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:04:34 -04:00
bvandeusenandClaude Opus 5.5 4cce3f0d12 feat(host_agent): the heat alert says which sensor set the temperature
"Host running hot" read the host's hottest sensor and didn't name it, so the
operator couldn't tell what hit 100 °C on VDNT-Util01. The alert email now
names the sensor and its reading, through the same names_behind a count uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:04:34 -04:00
bvandeusenandClaude Opus 5.5 e51d969c43 feat(alerts): a rule can fire when a reading rises, "rose by N" (#6724)
A rule compared a reading with a fixed number, so Synology Disk 1's bad
sectors going from 3,160 to 3,392 could say nothing: a count that only grows
is soon past any line. "rose by" compares each reading with the one before it,
kept on the alert's state and only for such a rule. A fall (a replaced disk)
doesn't fire. On the rule form, through MCP, and worded in emails, the alert
sentence, history and the timeline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:03:29 -04:00
bvandeusenandClaude Opus 5.5 d83f8ffe9b fix(anomalies): a level over its usual hold opens an anomaly only past its usual peak or once held (#6723)
Rows on the live instance were mostly bursts of a few minutes: 28 of 52
peaked within the metric's own usual peak, and 42 of 52 latest occurrences
lasted 30 minutes or less, which is what a burst shorter than the 15-minute
average looks like once its span is back-dated. Nothing asked how high or how
long, as the System 182 ruling says a normal records.

A departure now also has to be past the slot's usual peak, or held: the
median of the last hour over the same line. Those spans were also keeping the
burstiest hours out of learning, lowering the usual lines further.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:01:55 -04:00
bvandeusenandClaude Opus 5.5 50b8922895 fix(syslog): a device through Docker's address stays one source when that address changes (#6717)
CI / unit (push) Successful in 1m36s
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m45s
CI / publish (push) Successful in 1m24s
Docker's ingress address depends on the node Steward's task is on, and sources
were keyed by (address, sender). After a task move the same device arrived under
a new key and became a new "offered" row, so its lines were counted, not kept.

A named sender is now one row whatever address it last came from, enforced by a
partial unique index. Migration syslog_005 folds rows sharing a name into the
added one and moves their lines and patterns across.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:02:04 -04:00
bvandeusenandClaude Opus 5.5 57b2aafd1a fix(prometheus): keep/drop patterns split on any whitespace, and a *_time_seconds value reads as a moment (#6423)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m46s
CI / integration (push) Successful in 1m48s
CI / publish (push) Successful in 19s
A pasted drop list whose line breaks had become spaces was stored as one
pattern that matched nothing, so Home Assistant's two 526-series
bookkeeping families filled the 1,000-series cap. No metric name holds
whitespace, so patterns now split on spaces and tabs as well as lines
and commas.

Home Assistant names its Unix times *_time_seconds (e.g.
homeassistant_last_updated_time_seconds), which read as "20751d 21h".
is_moment() takes both suffixes, for the value shown and the chart axis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:01:21 -04:00
bvandeusenandClaude Opus 5.5 057c95c45e fix(prometheus, syslog): word the pause and resume audit actions; the page test reads the escaped apostrophe (#6419)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m39s
CI / integration (push) Successful in 1m45s
CI / publish (push) Successful in 1m13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:57:38 -04:00
bvandeusenandClaude Opus 5.5 d20d78498e fix(prometheus, syslog): a target or pattern pauses with a Pause/Resume button, not an "on" tick box in its form (#6419)
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m42s
CI / integration (push) Successful in 1m45s
CI / publish (push) Skipped
Monitors, schedules and maintenance plans already pause with a
Pause/Resume button and say "Paused" where they're listed. Prometheus
targets and syslog patterns only had a tick box inside the edit form,
so a target refused by its endpoint couldn't be stopped from its page.

- Both get a Pause/Resume button (target page and list; patterns list)
  posting to /toggle, recorded in the audit log, and a Paused badge.
- The forms lose the tick box. A new one starts running; an edit keeps
  whether it is paused.
- A target's "off" state is now "paused", in its pages and MCP tools.
  A resumed target reads "not scraped yet" until its next scrape, and its
  counter rates start over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:48:31 -04:00
bvandeusenandClaude Opus 5.5 0b787a0676 fix(alerts): an alert on a count says how many of what, and names them (#6400)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m48s
CI / publish (push) Successful in 1m8s
"swarm services short on swarm is 6." becomes "6 swarm services are
short of their replicas.", with a Which line naming each service and how
many of its tasks run. The swarm figures get labels, and the counting
figures (swarm, mounts, drives, updates) read as sentences in the email,
its subject and the Alerts page. A plugin names what its count counts
through core.alerts.names_behind; the Docker plugin does for the three
swarm figures, from the stored swarm. Webhooks get alert.which too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:00:56 -04:00
bvandeusenandClaude Opus 5.5 ae789bc0f1 test(ansible): the swarm prune guard's comment states why, not when (#6399)
CI / lint (push) Successful in 1s
CI / unit (push) Successful in 1m37s
CI / integration (push) Successful in 1m47s
CI / publish (push) Successful in 15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:30:00 -04:00
bvandeusenandClaude Opus 5.5 b2f4dde288 fix(ansible): the swarm prune never runs a docker prune that touches images; it lives in its own folder (#6399)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m42s
CI / integration (push) Successful in 1m47s
CI / publish (push) Successful in 1m10s
The first applied run lost VDNT-Docker03 29 images (10.4 GB) before its
script ran. Its `docker system prune` removes dangling images, and Docker
counts an image with no tag as dangling: every image a swarm task pulled by
digest. Docker03 was left holding only its own tasks' images, as under the
old prune. Nothing went down, since a running container's image can't be
removed.

- Step 1 now prunes containers, networks, build cache and (prune_volumes)
  volumes one by one. Every image decision is left to the script, which
  keeps the images services are pinned to.
- A test guards against any image-pruning command in the playbook.
- The playbook moves to maintenance/swarm_prune/, with its script in that
  folder's files/, so a helper's name can't collide with another
  playbook's (the operator's direction for playbooks with helpers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:22:41 -04:00
bvandeusenandClaude Opus 5.5 b40e672cda fix(ansible): the swarm prune reads every image from Docker's API, untagged ones included (#6399)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m49s
CI / publish (push) Successful in 1m4s
`docker image ls` on Docker 29 leaves out images with no tag, which is every
image a swarm task pulled by digest: the check run on VDNT-Docker03 saw 4 of
its 58. Missing them only kept them, but superseded digests there would
never have gone. The list now comes from GET /images/json on the socket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:15:10 -04:00
bvandeusenandClaude Opus 5.5 6f0903f548 feat(ansible): a swarm prune that keeps every image a swarm service is pinned to on every node (#6399)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m47s
CI / publish (push) Successful in 1m10s
The nightly prune ran `docker system prune -a`, which deletes every image no
local container uses, so each swarm node kept only its own tasks' images.
When the other node failed or was drained, its services had to pull first:
on 2026-10-25 Traefik took 11.5 min to start on Docker03.

maintenance/swarm_prune.yml (built in) prunes stopped containers, unused
networks, dangling images and build cache, then removes only the images that
no container uses and no swarm service is pinned to, wherever that service
runs. Old digests left by redeploys still go.

- A container's image is kept by its id and by the reference it was made
  from, since on the containerd store the id alone hasn't held a tag.
- A node that can't list the swarm's services removes no images and fails
  rather than pruning blind.
- Check mode lists what would go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:08:37 -04:00
bvandeusenandClaude Opus 5.5 74a5b26cab fix(maintenance): word the plan spread audit action (#6398)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Successful in 1m49s
CI / publish (push) Successful in 1m11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:47:04 -04:00
bvandeusenandClaude Opus 5.5 715b58a09f feat(maintenance): a run spreads swarm services back out after its last host, moving only what the emptier node can start (#6398)
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m36s
CI / integration (push) Successful in 1m47s
CI / publish (push) Skipped
Each weekly run drains Docker02 then Docker03, and the swarm never moves a
task back by itself, so every run ended with nearly every service on one
node. On 2026-10-25 the operator had that spread out by hand.

- Agent 1.26.0 reports each swarm service's pinned digest, and every
  repo@sha256 its host holds (from /system/df, every 10 min).
- Once a run's last host is back in service, Steward picks services on the
  fullest node to move to the emptiest until they are within one task. It
  takes services with several replicas on one node first. A service moves only
  when the emptier node already holds its exact image. One the plan keeps in
  place, one below its replicas, and one with no pinned digest stay put, and
  the run says why.
- A swarm manager's helper carries it out through a new action, rebalance,
  with the service names (validated, at most 48). It force-updates each in
  turn with the spec unchanged and no registry lookup, skips any a
  constraint places, and gives each 5 min to settle.
- The run ends done with what moved, or failed if a move didn't settle or
  the manager didn't answer in 60 min. A docker-scoped window covers the
  restarts, and the step goes on the timeline.
- Plans gain "Spread swarm services back out" (on by default) and "Keep in
  place", on the create form and in an "After the run" form on each plan.
  list_maintenance_plans shows both and the latest run's spread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:42:26 -04:00
bvandeusenandClaude Opus 5.5 d102fc3333 fix(maintenance): a job given up after draining its node makes the node active again (#6387)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m48s
CI / publish (push) Successful in 20s
On 2026-10-25 the 09:00 job on VDNT-Docker02 drained the node, which ran
Traefik and Steward. The agent's result reaches Steward through Traefik,
and Traefik took 11.5 minutes to start on Docker03 (its image had to be
pulled). The job gave up at the 10-minute drain deadline and finished
timed_out where it stood, so the node stayed drained for hours.

- A job that fails or times out while its node may be drained (the drain
  was handed out, or went through and wasn't undone) goes to undraining
  first. The outcome is kept in the new column ends_as, and the job
  finishes as that once the node is active again. If the undrain goes
  wrong too, detail says both.
- The drain deadline is 30 minutes instead of 10, so a result held up while
  the proxy moves still lands.
- Migration host_agent_012_ends_as. get_maintenance_job returns ends_as.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:05:57 -04:00
bvandeusenandClaude Opus 5.5 2a561210c3 feat(tickets): open, comment and resolve tickets from the UI and MCP; known issues on alerts and anomalies (#6351, #6352)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Successful in 1m51s
CI / publish (push) Successful in 1m16s
A ticket records a known problem: title, area, hosts, subject, description,
links to alert rules and anomalies, and a log of comments, edits, resolving
and reopening. Opening, resolving (with the resolution as the note) and
reopening go on the timeline; every step is audited, and the audit rows stay
off the timeline.

An alert or anomaly an open ticket covers (by link, by subject, or a
host-wide ticket on the host itself) shows a Known issue badge, and offers
"Open a ticket" prefilled from its rule or anomaly when none does. Hosts,
monitors and containers get a Known issues card.

MCP: list_tickets, get_ticket (viewer); open_ticket, update_ticket,
comment_ticket, resolve_ticket, reopen_ticket (operator). list_alerts and
list_anomalies carry known_issue; get_overview, get_host, get_monitor and
get_container carry open_tickets. Also update_change and delete_change for
changes recorded by hand (author or admin).

Tickets are left out of backups, like the timeline (2026-10-15 ruling).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:06:43 -04:00
bvandeusenandClaude Opus 5.5 1ea86c0330 test: a reader's zone comes from their account; a London expiry reads BST or GMT (#6350)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 1m45s
CI / integration (push) Successful in 1m49s
CI / publish (push) Successful in 1m21s
The change-form test set the zone on the session, which the auth middleware
resets from the account on every request. The password reset test asserted
BST, which stopped being London's zone name on 25 Oct.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:39:14 -04:00
bvandeusenandClaude Opus 5.5 3899e0e299 fix(timeline): register log_change with read_only=False, which register_tool requires (#6350)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m49s
CI / integration (push) Failing after 2m6s
CI / publish (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:31:37 -04:00
bvandeusenandClaude Opus 5.5 329aa15e3d feat(timeline): record a change by hand from the Timeline, a host's page or MCP log_change (#6350)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m20s
CI / unit (push) Failing after 1m33s
CI / publish (push) Skipped
A change Steward can't see happen (a config edited, a disk swapped) is
recorded by whoever made it: what was done, its area, when (in the reader's
own zone), the hosts, what it was done to and a detail. Its author or an
admin edits or deletes it; recording, editing and deleting are audited and
those audit rows stay off the timeline. log_change records one as
"<user> via MCP", editable by the token's user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:26:02 -04:00
bvandeusenandClaude Opus 5.5 2b97c36fd4 feat(timeline): maintenance, reboots, deploys, crash-loops and reviewed anomalies on the timeline (#6349)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m52s
CI / publish (push) Successful in 1m12s
The host agent writes each maintenance job once it ends, and each stretch a
host was away (a reboot, a crash, lost contact, the agent restarting), kept up
to date until it is back. Docker judges each snapshot: a unit on a new image,
or a container recreated, is a deploy; deaths past the Thresholds crash-loop
count are one crash-loop entry that later deaths extend; single restarts are
recorded but hidden. An anomaly has one entry whose type follows its review:
only those judged real are shown by default.

A source whose record changes keeps one entry for it (record_or_update).
Plugins register the records their entries point to, so an aged-out job or
downtime reads "Details no longer kept". The first fill runs once per source
(timeline.filled), the host agent's jobs and downtime included.

get_anomaly's "what else was going on" reads the timeline for the host or
resource instead of querying alerts, runs, maintenance, downtime and container
deaths itself; Docker's own source keeps only starts, stops and health
changes. A new kind, Observed, is for what Steward saw happen with no rule or
person behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:21:44 -04:00
bvandeusenandClaude Opus 5.5 8b42a6a865 test(timeline): a page item can turn its divider off (#6348)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m32s
CI / integration (push) Successful in 1m42s
CI / publish (push) Successful in 1m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:08:37 -04:00
bvandeusenandClaude Opus 5.5 4ea735d87c feat(timeline): one record of what happened to the infrastructure, newest first (#6348)
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m31s
CI / integration (push) Successful in 1m41s
CI / publish (push) Skipped
A stored log, timeline_entries, with one row per thing that happened. Each
source writes its row in the same transaction as its own record, so the
history outlives the 30/90/365-day retention of the tables it points to:

- alert events (core/alerts._add_event): fired, resolved, acknowledged,
  went silent, readings back;
- finished Ansible runs (executor, and the startup sweep of interrupted
  runs): changed something, changed nothing, failed, dry run;
- audit rows (core/audit.log_audit): Steward's settings, users and access,
  sign-ins, backups. Actions another writer records are left to it.

Entries carry a kind (fixed) and an area (Service, Host OS, Hardware,
Storage, Network, Security, Monitoring). Settings → Timeline edits the
areas and each type's area and visibility; both are applied when the
timeline is read, so a change re-sorts past entries and a hidden type
comes back when shown. Kept forever unless a retention is set.

The first start fills the timeline from the last 90 days of records once,
skipping any already covered. A host rename carries its entries.

UI: Timeline in the sidebar (filter by host, area, kind; scrolls on), a
History card on each host page. MCP: get_timeline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:03:05 -04:00
bvandeusenandClaude Opus 5.5 8e2f17d198 fix(maintenance): a late return, once back, stays back, and every reader agrees (#6175)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m31s
CI / integration (push) Successful in 1m39s
CI / publish (push) Successful in 1m26s
18032a4 re-checked a "not back in service" job every minute with no end,
so any later swarm wobble on the host (radarr's failed task during the
downloads move, say) flipped maintenance_failed back to 1: five false
firings and ten emails for Docker02 overnight on 10-19. And the overview's
finding never learned the rule at all, so it still said Docker02's
maintenance failed three days on.

The job now keeps came_back_at, set the first time its host is found back.
maintenance.still_failed(job) is the one answer: the figure, the overview,
the cadence page's run rows and the host panel all ask it. A failed update
or a timed-out job still flags its host until a job succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:44:38 -04:00
bvandeusenandClaude Opus 5.5 b75b883326 feat(traefik): a 4xx share is worth a look only once it is a lot of 4xx (#6105)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m42s
CI / publish (push) Successful in 1m9s
Three 404s on a quiet router read 7.5% and were raised as an anomaly; most of
the 4xx anomalies on review stood for 3 to 7 errors in 15 minutes. A new
Settings → Thresholds minimum, traefik.threshold.min_4xx (default 20, more
than one a minute held for the window), applies to both the router's grade
on its page and to anomaly detection, as min_5xx does for 5xx.

learn_normal takes `fewest`: for a share gated on a per-second rate, the
fewest events (rate × window × share) it must stand for to be judged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:02:17 -04:00
bvandeusenandClaude Opus 5.5 4639ac6e52 fix(monitors): a ping or TCP check times the network, not Steward (#6105)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m28s
CI / integration (push) Successful in 1m39s
CI / publish (push) Successful in 1m14s
response_ms was the whole `ping -c 1` process, or an open_connection awaited
on the event loop: about 15 ms on a LAN whose round trip is under 1, and every
host read slow together whenever Steward was busy. A ping now reports the
round trip ping itself printed (the elapsed time only if it printed none);
a TCP connect is timed in a thread, the name resolved first and not counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:38:17 -04:00
bvandeusenandClaude Opus 5.5 403025e201 fix(traffic): find a host's senders in the latest-value table, not by a prefix scan of every metric (#6115)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 1m30s
CI / integration (push) Successful in 1m40s
CI / publish (push) Successful in 20s
get_host_senders on VDNT-Docker02 ran past 120 s: a prefix LIKE on
plugin_metrics.resource_name can't use its index (the column's collation
isn't C), so each read went through the whole table, and the host page's
Who's sending panel repeats it on every refresh. The containers and NFS
mounts under a host are now named from plugin_metric_latest, and their
series read by resource IN (...) on the series index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:59:50 -04:00
bvandeusenandClaude Opus 5.5 26bc57ce04 feat(traffic): a host's top senders, on its page and as get_host_senders (#6115)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m39s
CI / publish (push) Successful in 1m4s
The host page gains a "Who's sending" panel, and the MCP a tool, that
put the host's outbound (or inbound) traffic down to its containers and
NFS mounts: read in shared time buckets, so the host's busiest bucket
says who was sending in it. A swarm service's tasks add up under its
name; what none of them accounts for is the host itself.

Also fixes CI 9488: per-container net_rx_bps / net_tx_bps join the
docker alert catalog, so a rule can catch a container flooding the
uplink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:38:28 -04:00
bvandeusenandClaude Opus 5.5 613ed4e78b feat(traffic): each container's network rate, and each NFS mount's bytes, so a host's traffic has a sender (#6115)
CI / unit (push) Failing after 1m30s
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m38s
CI / publish (push) Skipped
Docker02's outbound traffic tops out the internet connection and Steward
couldn't say who sends it: a container's counters were kept only as a
running total, and NFS traffic belongs to no container.

- Docker ingest turns each running container's cumulative network counters
  into net_rx_bps / net_tx_bps on "<host>/<container>" (net_rates), counting
  from the stored row so a restart of Steward loses nothing; a container
  new, recreated or restarted has no rate until its next snapshot.
- Agent 1.25.0 reads the bytes sent and received per NFS mount from
  mountstats (already parsed for the delays) as tx_bps / rx_bps; Steward
  stores them as nfs_tx_bps / nfs_rx_bps on "<host>:<mount>".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:35:24 -04:00
bvandeusenandClaude Opus 5.5 18032a4782 fix(maintenance): a host that came back late stops reading as failed once it is back (#6105)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m30s
CI / integration (push) Successful in 1m39s
CI / publish (push) Successful in 1m5s
Docker02's Sunday job updated and rebooted, then failed only because
portal_app was short of its replica past the 30-minute settle. Its
maintenance_failed figure stayed 1, and the alert kept firing, until a
later job succeeds. A job failed for not coming back into service is
checked again each minute and reads 0 once the host is back; a failed
or timed-out update still reads 1 until a job succeeds.

Also: compose.deploy.yml's syslog comment still said devices are told
apart by address under Swarm (stale since #6096).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:23:53 -04:00
bvandeusenandClaude Opus 5.5 fcd58224de feat(syslog): a device through Docker's address is placed by its name, or a device address set on it (#6096)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m32s
CI / integration (push) Successful in 1m42s
CI / publish (push) Successful in 20s
The SNMP poll asks each device's sysName in the same GET and keeps it
(snmp_003_sys_name). A syslog source told apart by name finds its own
address from the SNMP device (reported or given name) or host of that
name, so its lines reach the SNMP device's and host's pages again; the
edit page gains Device address, which always wins (syslog_004).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:24:58 -04:00
bvandeusenandClaude Opus 5.5 5ce3dfe69d fix(syslog): a row from before the name split, last seen as a device, stays that device's (#6096)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m39s
CI / publish (push) Successful in 19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:16:08 -04:00
bvandeusenandClaude Opus 5.5 db185fa9e2 test(syslog): the Docker-address warning's new wording (#6096)
CI / lint (push) Successful in 1s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m38s
CI / publish (push) Successful in 21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:11:37 -04:00
bvandeusenandClaude Opus 5.5 0a647bbb82 feat(syslog): devices through Swarm's ingress told apart by name; the send-to address is a setting (#6096)
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m30s
CI / integration (push) Successful in 1m38s
CI / publish (push) Skipped
Through ingress every device arrives from Docker's own address, so a
source there is keyed by the hostname its lines carry (new column
sender, unique with address). The set-up help no longer guesses the
address from the web hostname: it shows the plugin's send_to setting,
covers ingress and host mode, ASUSTOR ADM, and both syslog formats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:05:28 -04:00
bvandeusenandClaude Opus 5.5 d2926b6663 test(maintenance): the swarm job's boot comes after the reboot the settle hands out (#6098)
CI / lint (push) Successful in 8s
CI / unit (push) Successful in 1m33s
CI / integration (push) Successful in 1m42s
CI / publish (push) Successful in 20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:50:12 -04:00
bvandeusenandClaude Opus 5.5 1ba4784fa6 fix(maintenance): check first, drain before a Docker upgrade, wait 5 min after a drain, see containers back (#6098)
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m29s
CI / integration (push) Successful in 2m7s
CI / publish (push) Skipped
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
bvandeusenandClaude Opus 5.5 da81010a75 fix(alerts): an alert that fired and ended inside maintenance isn't mailed resolved (#6097)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m39s
CI / publish (push) Successful in 1m9s
During Docker02's maintenance reboot its Agent offline alert fired muted by
the job's window, then resolved just after the window closed: the resolution
was mailed for a firing nobody had been told of. A resolution whose firing
was held and never released is now recorded with a note, not sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:41:51 -04:00
bvandeusenandClaude Opus 5.5 ed9da56efa feat(syslog): set-up help on the page, port 5514 published, Docker-hidden senders called out (#6003)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m38s
CI / publish (push) Successful in 20s
The Syslog page says where to send (this server, its port and protocols) and
where the setting is on Synology, QNAP, TrueNAS, OpenWrt, UniFi and rsyslog,
open until a device sends. A sender whose address is on Steward's own
container network (Swarm ingress publishing hides the real sender) is
flagged, with the host-mode ports to publish instead. compose files and the
Dockerfile publish 5514/udp and /tcp; docs/plugins/syslog.md describes the
plugin and the deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:01:56 -04:00
bvandeusenandClaude Opus 5.5 96f774ce24 fix(syslog): chart marks give up after 10 s rather than waiting on (#6002)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m38s
CI / unit (push) Successful in 1m28s
CI / publish (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 06:40:01 -04:00
bvandeusenandClaude Opus 5.5 521d84f318 feat(syslog): lines beside the readings: around an anomaly, in get_anomaly, and marked on host and SNMP charts (#6002)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m37s
CI / publish (push) Successful in 1m9s
get_anomaly's circumstances gain the host's or SNMP device's syslog
warnings and worse (mcp_events / mcp_resource_events). An anomaly row opens
"Syslog around it", read only when opened. A shared Chart.js plugin
(chart-marks.js) draws a dashed line at each syslog error on the host
metrics charts and an SNMP device's charts, with a line saying how many.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 06:36:31 -04:00
bvandeusenandClaude Opus 5.5 f37a578136 test(syslog): a preset's 15-minute window doesn't reach the 40-minute-old nfsd line (#6001)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m35s
CI / publish (push) Successful in 1m18s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 06:27:23 -04:00
bvandeusenandClaude Opus 5.5 8eae3d3825 feat(syslog): pattern alerts: telling phrases counted per source each minute for alert rules, storage-trouble presets (#6001)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m31s
CI / integration (push) Failing after 1m40s
CI / publish (push) Skipped
A pattern (phrases, a severity, a source, a window) is counted every minute
into plugin_metrics as lines_matching_<key> per added source, zero included,
so an ordinary alert rule fires on a match and clears once the window passes.
Each count reads only the lines stored since the last, by line id; a new or
changed pattern reads its window once.

Patterns page with ready-made storage-trouble presets and "Alert on it",
which opens the rule form aimed at the count. Source names are kept unique,
and a rename moves its counts and the rules on them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 06:22:54 -04:00
bvandeusenandClaude Opus 5.5 dadec4e355 feat(syslog): a source's lines to read and search, on its page, its host's and its SNMP device's, and over MCP (#6000)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m36s
CI / publish (push) Successful in 1m6s
- /plugins/syslog/sources/<id>: the lines newest first, loading as you
  scroll, filtered by severity and worse, app, text and time, with day
  dividers and times to the second in the reader's zone.
- The Syslog page links each source and shows its lines in the last hour.
- A Syslog panel on a host's page and an SNMP device's: the newest lines of
  the sources bound to it or at its address.
- MCP: list_syslog_sources and search_syslog (source, host, text, severity
  and worse, app, range, paging by next_before).
- Shared: escape_like moves to steward/core/like.py and cut_middle to the
  MCP shaping module, replacing four inline copies; |when takes seconds=.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 06:05:52 -04:00
bvandeusenandClaude Opus 5.5 bf6019a2af test(syslog): each integration step its own session (#5999)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m34s
CI / publish (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:15:13 -04:00
bvandeusenandClaude Opus 5.5 6ff9cffed4 fix(syslog): word its audit actions, list its tables for backups, mark its page top-level, reach its modules as loaded (#5999)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Failing after 1m38s
CI / publish (push) Skipped
- Audit: syslog.source_* worded, and a Syslog filter area.
- Backups keep syslog_sources (added, named, ignored; what it last sent
  isn't compared) and leave syslog_lines out, as logs.
- The integration test reaches the plugin as loaded (#5775), so its
  tables aren't defined twice; ruff E741.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:12:59 -04:00
bvandeusenandClaude Opus 5.5 51892a02c0 feat(syslog): a syslog receiver: UDP and TCP, senders offered before they're kept, each source's lines in a ring (#5999)
CI / lint (push) Failing after 2s
CI / unit (push) Failing after 1m27s
CI / integration (push) Failing after 1m35s
CI / publish (push) Skipped
- plugins/syslog: RFC 5424 and RFC 3164 parsing, RFC 6587 TCP framing.
- Lines are queued as they arrive and stored every 5 s through a Holding,
  so a storage stall delays them rather than losing them.
- A new sender is an offered source, counted and not kept, until an admin
  adds it on the Syslog page (name, host); it can be ignored instead.
- The listener follows the plugin's settings (port, UDP, TCP), rebinding
  within seconds of a save; a port it can't open is said on the page.
- Hourly ring per source: age window and byte cap from Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:09:07 -04:00
bvandeusenandClaude Opus 5.5 a59d1a7709 fix(plugins): a plugin that can't start live says why and offers a restart (#6087)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m28s
CI / integration (push) Successful in 1m33s
CI / publish (push) Successful in 1m29s
Saving an enabled plugin threw away hot_reload_plugin's (ok, message), so a
plugin that failed to load live read "Enabled, not loaded" with nothing
more. Now:
- a live-load failure is recorded with its reason, as a boot failure is, and
  logged; a later success clears it;
- any save of an enabled plugin that isn't running tries to start it, not
  only the off-to-on toggle;
- enabling one already running is a success, not a failure;
- the plugin's page offers "Restart to load" while it is enabled and not
  running.

Quart never marks the app as having served its first request, so
register_blueprint after startup is allowed: hot-loading is not refused
wholesale, as #6087 suspected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:56:54 -04:00
bvandeusenandClaude Opus 5.5 f7616b71c3 fix(dashboard): the Add-a-widget drawer starts at the sidebar's edge, so none of it hides behind it (#6086)
main's z-index makes it a stacking context, so the fixed drawer (z-index 60)
could never rise over the sticky sidebar (z-index 20): its title, group
labels and first column of widgets sat underneath. Above 900px the drawer
now starts at left:220px; below, the sidebar is off-canvas and it keeps the
full width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:56:54 -04:00
bvandeusenandClaude Opus 5.5 56063f27a4 fix(docker): a container's memory is its processes' own; file cache shown on its page (#6030)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m45s
CI / publish (push) Successful in 1m43s
The agent sent Docker's working set (usage less inactive_file), which still
counts recently used file cache. A file-heavy container with no limit read
191 GB while its processes held 2.2 GB, and the host's own figure, which
already leaves cache out, disagreed with it.

Agent 1.23.0 sends mem_used_bytes / mem_used_pct (anon + shmem, so
Postgres's shared buffers count; v1: rss + shmem) and mem_cache_bytes
apart. The new names keep the two meanings apart in history and in learned
normals: docker_013 renames the columns, clears the old values and adds
mem_cache_bytes. The Docker page, swarm by-node sums, Busiest Containers,
container detail, normals and the MCP tools all read the new figure. File
cache is shown only on the container's page and in get_container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:27:29 -04:00
bvandeusenandClaude Opus 5.5 1335b9afaf test(host_agent): the NFS line test gives the host a reading, so its metrics render (#5996)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m31s
CI / integration (push) Successful in 1m33s
CI / publish (push) Successful in 1m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:51:07 -04:00
bvandeusenandClaude Opus 5.5 ad8bbd8d69 feat(snmp): a NAS's disk and volume I/O, CPU waiting on disk, available memory, interface errors (#5997)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m31s
CI / unit (push) Failing after 1m32s
CI / publish (push) Skipped
- Synology: what each disk and volume reads and writes, bytes and requests
  a second and the load DSM gives it (SYNOLOGY-STORAGEIO- and -SPACEIO-MIB,
  64-bit byte counters), and each disk's reconnects and sectors not found.
- Linux server: CPU waiting on disk, the wait tick counter's share of the
  CPU's, and memory available (free + buffers + cache). A reading may now be
  worked out from others: the poll asks for its inputs, rates each counter,
  and stores only the result.
- Interface traffic: errors and discards in and out, per second.
- byte_count unit: a byte counter shown as bytes a second.

Asustor's MIB has nothing more for this; its Linux server readings gain the
same CPU wait and available memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:50:52 -04:00
bvandeusenandClaude Opus 5.5 3f9aae7f46 feat(host_agent): NFS delay per mount, read from /proc/self/mountstats (#5996)
CI / unit (push) Failing after 1m32s
CI / integration (push) Successful in 1m31s
CI / publish (push) Skipped
CI / lint (push) Successful in 2s
Each sample carries each NFS mount's requests since the last one: how many
a second, their round trip and execute time (READ and WRITE on their own),
and how many were sent again or hit a major timeout. mountstats is the
kernel's own count and never touches the share, so it reads while one is
stuck. Steward stores them per mount and the slowest on the host as
nfs_exe_max_ms, for an "NFS slow" rule and the host page's NFS chart beside
mounts_stalled; each mount's bar says what a request took.

Agent 1.22.0 (which also carries the backlog order from 41fc807).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:46:14 -04:00
bvandeusenandClaude Opus 5.5 2df8431de9 feat: monitors, UniFi and Traefik keep collecting through a storage stall (#5995)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m38s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m11s
The same Holding as the SNMP poll, for the rest of Steward's own
collectors:

- Monitors: the list is read within 10 s or the last one read stands in;
  a start the database doesn't take is kept in memory, so a monitor isn't
  checked twice; each result is held and stored at the time it was
  checked. A stall shows on the monitors as the checks it saw, not a gap.
- UniFi and Traefik: each poll's writes are held (hold_write) and run
  oldest first in one transaction; every reading is stamped with its scrape.
- A held round the database refuses (as opposed to can't be reached) is
  stored alone and let go, so it can't hold up every round after it.

The Prometheus scrape is left as it is: its one target, Fabled Archives,
keeps its own database on the same NFS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:41:14 -04:00
bvandeusenandClaude Opus 5.5 41fc8073db feat: readings kept through a storage stall; an agent's backlog arrives in order (#5995)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m39s
CI / integration (push) Successful in 1m49s
CI / publish (push) Successful in 1m49s
On 2026-10-17 the NFS under Postgres stalled for ~15 minutes. Two things
went wrong for the readings that would have explained it:

- The SNMP poll stored before it polled again, so while the database hung
  it stopped polling. It now collects on schedule and hands each round to a
  Holding (steward/core/holding.py): one writer stores what is held, oldest
  first, each reading at the time it was collected. Bounded at two hours of
  polls, with a 30-minute deadline on a store; the device list read falls
  back to the last one read after 10 s.
- "Mount not answering" (10 checks in a row) went pending on Docker02 and
  Docker03 and never fired: the agent sent each post's backlog with its
  newest, healthy sample, and that sample ended every post's run. Agent
  1.22.0 queues the new sample behind the backlog, so Steward receives a
  host's samples in the order they were taken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:36:47 -04:00
bvandeusenandClaude Opus 5.5 bd1c32d2a4 fix(snmp): a disk listed late in UCD-DISKIO is read, and Asustor disks are named by bay (#5992)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Successful in 1m50s
CI / publish (push) Successful in 1m29s
The table walk stopped after 64 raw entries, counting the RAM disks, loops,
partitions and md arrays that rows_like then drops, so a Synology's eighth
disk was never reached. The walk is now bounded at 1,024 entries per column;
64 still caps the rows kept.

ADM labels its disks "Disk0".."Disk4" while their indexes run 1..5; the
Asustor disk table now names each row by its index ("Disk 1"). Keys are
unchanged, so history carries on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:16:25 -04:00
bvandeusenandClaude Opus 5.5 d065cafd11 feat(snmp): NAS presets for Synology and Asustor, reading every disk and volume (#5985, #5986)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 1m38s
CI / integration (push) Successful in 1m41s
CI / publish (push) Successful in 1m31s
A preset can now read a vendor's tables: one row per disk, volume, fan or
sensor. The scheduled poll discovers the rows by walking the device: on its
first poll, after a save, and then hourly, and only while it answers. Each row
becomes readings named by the device's own labels ("Disk 1 status"). Only the
columns a row answered are asked for.

A vendor status, whether a code (Synology) or a word (Asustor), is a state
reading. It is stored as a level (0 OK, 1 busy, 2 warning, 3 failed), so one
alert rule covers any vendor, and shown in the device's own word.

- Synology: system, power and fan status and temperature; per disk: status,
  health, temperature and bad sectors; per volume: RAID state (scrubbing,
  parity check, rebuild, degraded), free space and size.
- Asustor: per disk: status, SMART and temperature; per volume: status, free
  space and size; CPU and system temperature, free memory and fans.
  From ADM's ASUSTOR-DISK-MIB and ASUSTOR-SYSTEM-MIB.
- Linux server: plus each physical disk's 1-minute busy % (UCD-DISKIO-MIB).

Migration snmp_002_table_rows adds tables, tables_at and texts to
snmp_devices. Milestone 530.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:40:14 -04:00
bvandeusenandClaude Opus 5.5 4ded8ecee4 fix(mcp): get_anomaly shows a quick Steward restart, not only a gap over 90 s (#5862)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m27s
CI / integration (push) Successful in 1m33s
CI / publish (push) Successful in 1m15s
A redeploy's new process ticks at once, so its once-a-minute samples never
broke for 90 s and get_anomaly showed none of the day's restarts. The first
self-monitor tick in a process now records restart_gap_secs (time since the
last process's last sample), and steward_gaps shows each one as a restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:11:27 -04:00
bvandeusenandClaude Opus 5.5 22da64a284 fix(mcp): list_maintenance_plans reports only a slot that fired as last_slot_at (#5863)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m27s
CI / integration (push) Successful in 1m32s
CI / publish (push) Successful in 1m6s
A new plan's last_slot_at holds the moment it was made, so its first
slot is the next one; the tool returned that moment, a Wednesday
evening for a Sunday plan, beside latest_run: null. cadence.fired_slot
returns it only when it falls on one of the plan's slots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:00:48 -04:00
bvandeusenandClaude Opus 5.5 398312b8be fix(traefik): a renewed certificate is old, and list_certificates is bounded (#5864)
A Traefik that hasn't restarted keeps reporting the certificate it
replaced, so codex.fabledsword.com's old one (21 days left) was listed
beside its renewal (81 days) and would have raised an expiry warning on
2026-10-22. A reported certificate whose every name a later-expiring
one covers is now old, on the page, the widget, the overview and MCP.

list_certificates takes `limit` (default 100) with total/truncated, and
says how many served certificates no HTTP router uses (`unrouted`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:00:19 -04:00
bvandeusenandClaude Opus 5.5 622addc09e fix(mcp): get_anomaly keeps the events nearest the occurrence and says how many it left out (#5861)
Each source in an anomaly's `around` was cut to its oldest 40 with
nothing in the reply to say so; on a busy host the events nearest the
occurrence were the ones dropped. Each source now keeps the 40 nearest
the occurrence's start, and `around_left_out` counts the rest. Sources
return everything their bounded query read (Docker and the host agent
no longer cut at 40 and 20 themselves), so the count is honest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:59:16 -04:00
bvandeusenandClaude Opus 5.5 91bb199d14 fix(mcp): get_anomaly shows Steward's own restarts beside every anomaly, and a router's containers (#5862)
All 16 Traefik 4xx anomalies on 2026-10-15 came back with nothing
around them, though 15 were Steward's own restarts. A router has no
host, and every events source was read by host.

- Core: each break of more than 90 s in Steward's once-a-minute health
  sample is a "steward" event beside every anomaly, placed or not.
- Plugins: an optional mcp_resource_events(db, source, resource, since,
  until) for a resource with no host. Traefik's gives the container
  events of the swarm service behind a router, on any host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:58:28 -04:00
bvandeusenandClaude Opus 5.5 a24b9da69d fix(traefik): a router fails on 5xx only once it answered a few server errors (#5865)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m28s
CI / integration (push) Successful in 1m34s
CI / publish (push) Successful in 1m10s
One 502 among about 70 requests read steward@swarm critical at 1.4%:
past the 30-request minimum and far past the 0.1% cutoff, yet one
error. A new Settings → Thresholds field, "Fewest server errors to
fail" (traefik.threshold.min_5xx, default 3), must also be reached in
the 15-minute window; the 0.1% cutoff still catches a busy router.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:49:51 -04:00
bvandeusenandClaude Opus 5.5 c625528191 fix(mcp): a failing router in get_overview says since when, as the page does (#5860)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m27s
CI / integration (push) Successful in 1m33s
CI / publish (push) Successful in 19s
router_findings never passed `since`, so every failing router read as
brand new and sorted last among the criticals. The overview now reads
the failing routers' last day of history and dates each one with
problem_since, as the dashboard widget does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:40:35 -04:00
bvandeusenandClaude Opus 5.5 80674603bc fix(traefik): a router's 5xx share is judged only over enough requests, like its 4xx share (#5859)
CI / lint (push) Successful in 1s
CI / unit (push) Successful in 1m27s
CI / integration (push) Successful in 1m33s
CI / publish (push) Successful in 1m15s
Two 500s of about sixteen requests graded roundtable@swarm critical at
9.1%. The Settings → Thresholds minimum of requests in 15 minutes (30 by
default) now guards both shares; the 5xx share still keeps its fixed
cutoff whatever the router's history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:35:57 -04:00
bvandeusenandClaude Opus 5.5 bf017e65c2 feat(traefik): scrape every Traefik replica behind the metrics URL and add them up (#5844)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m27s
CI / integration (push) Successful in 1m33s
CI / publish (push) Successful in 19s
Through one address Steward read whichever replica answered, and with
host-mode ports behind a keepalived VIP that can be the replica serving
nothing: since the 18:43 restart every scrape landed on Docker03's idle
Traefik, which has no traefik_service_* series, so no router was stored.

A metrics_url host name that resolves to several addresses (a swarm
service's tasks.<service>, a Kubernetes headless Service, round-robin DNS)
is now scraped address by address, with the original Host header and TLS
name. Answers from one process are kept once; a replica that doesn't answer
is skipped with a warning. Requests, bytes and latency buckets are summed
over the replicas that have a baseline, so the sum's rise is the sum of each
one's own rise; certificates are listed once; Traefik's own figures add
connections and memory and keep the newest reload. A name with one address
is one scrape, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:55:54 -04:00
bvandeusenandClaude Opus 5.5 6fa62c9520 fix(mcp): read the successes once, so every failed run is checked against all of them (#5842)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m26s
CI / integration (push) Successful in 1m33s
CI / publish (push) Successful in 1m7s
still_failing was handed the query's one-pass result. The first failed
run's any() consumed it, so a second failure in the window found no
successes left and was reported though a later run had put it right
(ci-runner-volumes came back once keepalived-survey also had a failure).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:52:19 -04:00
bvandeusenandClaude Opus 5.5 98440d8de6 fix(traefik): count each Traefik process only against its own last scrape (#5844)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m28s
CI / integration (push) Successful in 1m33s
CI / publish (push) Successful in 20s
One metrics URL can reach several Traefik processes: a swarm service's
replicas behind the routing mesh or its VIP, a Kubernetes Service's pods,
a keepalived pair after failover, or the same Traefik after a restart.
Each keeps its own counters, so comparing one with another read the second
one's lifetime totals as new traffic: retired routers flashed 100% 502 and
an idle router showed 834 req/s whenever the scrape moved to the other
replica.

Each scrape is now keyed by Traefik's process_start_time_seconds and
compared only with the same process's previous scrape. A process seen for
the first time (or not in the last hour) sets its baseline and counts
nothing. Without a start time every scrape is one process, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:36:10 -04:00
bvandeusenandClaude Opus 5.5 70eb253190 fix(mcp): the overview drops an Ansible failure a later run put right (#5842)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m34s
CI / publish (push) Successful in 1m29s
get_overview listed every failed or interrupted run of the last 24 hours,
so a dry run that failed, was fixed and re-run green kept warning for a
day. A failure now drops out once a later success of the same playbook,
source and hosts is at least as real: an applied success clears either
kind of failure, a dry-run success only a dry-run failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:58:24 -04:00
bvandeusenandClaude Opus 5.5 eee8469a17 feat(mcp): SNMP devices and Prometheus targets: their state, readings and series (#5840)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m26s
CI / integration (push) Successful in 1m33s
CI / publish (push) Successful in 19s
The snmp plugin registers list_snmp_devices and get_snmp_device; the
prometheus plugin registers list_prometheus_targets and
get_prometheus_target. Both are viewer-level, read what the poller and
scraper stored, and exist only while their plugin is enabled.
get_overview names a device or target whose last poll or scrape failed;
stale, pending and off are left out.

A device's community and v3 keys and a target's token are never
returned, and a password in a target URL is masked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:31:46 -04:00
bvandeusenandClaude Opus 5.5 2d88315bb1 feat(mcp): the UniFi network: WAN, devices, clients, events, alarms and config (#5839)
get_network gives the WAN, clients, Wi-Fi networks, speed tests and devices
by status; list_devices, list_clients (everyone seen, or the newest poll's
top talkers), list_network_events, list_alarms and get_network_config
(networks, port forwards, firewall rules) read what the poller stored.
get_overview names the WAN down while the poll is current, a device offline
or degraded that the controller still reports, and active alarms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:28:19 -04:00
bvandeusenandClaude Opus 5.5 684a47b3eb feat(mcp): Traefik routers, certificates and traffic (#5838)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m37s
CI / publish (push) Successful in 20s
list_routers grades every router as the Traefik page does (the last 15
minutes, 5xx on its cutoff, 4xx and p95 against the router's normal) and
gives a troubled one its status codes, what they mean and the swarm
service behind it. get_router adds its history, codes, normal and
anomalies; list_certificates the served certificates and the routers they
cover; get_traffic the access log's clients, countries and routers.
get_overview names a router answering 5xx and a certificate near expiry.

The access-log aggregation moves into routes.summarize_access, shared by
the traffic page and get_traffic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:26:34 -04:00
bvandeusenandClaude Opus 5.5 0945d78290 feat(mcp): understand and judge a metric anomaly: what happened, what was usual, what else went on (#5837)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m30s
CI / integration (push) Successful in 1m34s
CI / publish (push) Successful in 1m8s
get_anomaly gives each occurrence against what is usual at that hour, the
metric around the newest one with its normal, what else happened on the
resource's host around each occurrence (alert transitions, Ansible runs,
other anomalies there and a count elsewhere, plus plugins' own events
through a new mcp_events hook: the host agent's maintenance jobs and
downtime, Docker's container starts, deaths and OOM kills), and how earlier
anomalies on the same metric were judged.

get_metric_normal reads the learned slots and the level that would be
flagged. review_anomaly takes one id or several, all or nothing, with a
note. get_anomaly_settings and (admin) set_anomaly_settings read and change
the review window and each normal's floor, as Settings → Thresholds does.

metric_series.points takes an optional `until`; core.alerts gains
host_of_resource; floor_setting names its floor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:17:34 -04:00
bvandeusenandClaude Opus 5.5 8feec4dc77 feat(mcp): Steward's own backups: the runs, the next one, checking and making one (#5836)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m25s
CI / integration (push) Successful in 1m31s
CI / publish (push) Successful in 1m13s
list_backups, check_backup and make_backup are Settings → Backups for an
agent, admin only like the page. make_backup runs the page's job in the
background and is audited as "<user> via MCP". Restoring stays in the
browser. get_overview names a scheduled backup that failed, is two hours
past its slot, or has no bucket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:09:12 -04:00
bvandeusenandClaude Opus 5.5 c7ca459b81 feat(mcp): swarm nodes link to their host and maintenance; the overview names a node that is down (#5835)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m31s
CI / unit (push) Successful in 1m38s
CI / publish (push) Successful in 19s
#5835 "MCP: swarm nodes", step 2 of milestone 520. list_swarm_services
already listed every node (role, leader, availability, status); the
audit missed it. What was missing:
- Each node row carries `host_id` (the Steward host of that name) and
  `maintenance` (the state of a maintenance job running on it), so an
  agent can go node -> get_host / get_maintenance_job.
- get_overview names a node whose status isn't ready, unless its host
  has a maintenance job running (rebooting on purpose).
- Drained alone is not a finding: VDNT-Util01 is a manager kept drained
  on purpose (#5777), and a drain a failed job left behind is already
  that job's maintenance_failed finding.
unique_nodes() keeps the newest report of each node, as the list did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:04:02 -04:00
bvandeusenandClaude Opus 5.5 3ad8eed83d feat: the host agent is always on; the MCP sees and starts host maintenance (#5834)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m32s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m14s
#5834 "The host agent is always on, and the MCP sees a host's
maintenance", step 1 of milestone 520 "The MCP sees what Steward has
grown".

The host agent is a core system (operator, 2026-10-15: "it's very core
to the functionality of Steward as a whole and shouldn't be considered
something that can be toggled off"). ALWAYS_ON_PLUGINS in core settings
makes to_plugins_cfg report it enabled whatever its stored row says, so
it always loads and its MCP tools are always listed. Settings -> Plugins
shows it as "Built in: always on" with no switch, and a save keeps it
enabled. Its code and URLs stay under plugins/host_agent: every
installed agent posts to /plugins/host_agent/ingest.

Maintenance through the MCP:
- list_maintenance_plans: each plan's group, weekly slot, reboot policy,
  next slot, and its latest run (hosts, how far it got, who it skipped
  and why, how it ended).
- get_maintenance_job(host | job): a host's jobs, newest and running
  first, or one job: state, packages updated, reboot, drained by, why it
  failed, when its current state times out.
- run_maintenance(host, reboot="if_required" | "never"): admin, as the
  host page's Update now; refuses a busy host or one with no helper;
  audited "<user> via MCP".
- The overview names a host whose latest job in the last week failed or
  timed out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:59:42 -04:00
bvandeusenandClaude Opus 5.5 e122860cdc feat(mcp): write, test and delete this instance's own playbooks (#5748)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m29s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m6s
#5748 "MCP can write, edit and test this instance's own playbooks
(steward-local)": two admin tools beside run_playbook, so an agent can
build automation for these hosts that isn't part of Steward itself.

- save_playbook(playbook, content, source="steward-local", replace=false)
  goes through the editor's own validate_playbook_yaml and
  write_playbook. A playbook that isn't a list of plays comes back with
  the YAML error and its line, and nothing is written. An existing file
  is overwritten only with replace=true. steward-builtin is refused
  (Steward's playbooks change in its repository), as are git sources
  (their repo owns them).
- After writing, `ansible-playbook --syntax-check` runs against a
  throwaway localhost inventory (no host contacted, 60 s), so a module
  name Ansible can't resolve shows before any run. The result rides the
  reply; the file is kept either way.
- delete_playbook(playbook, source="steward-local").
- Both audit as ansible.playbook.created/updated/deleted, "<user> via
  MCP", with the size and never the content.

The testing loop is the existing one: run_playbook defaults to check
mode, on one target, then get_ansible_run. The integration test does
that loop with the real ansible-playbook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:01:39 -04:00
bvandeusenandClaude Opus 5.5 39685585cd chore(host-agent): say why the root helper keeps its own os-release reader
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m27s
CI / unit (push) Successful in 1m37s
CI / publish (push) Successful in 20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:52:35 -04:00
bvandeusenandClaude Opus 5.5 4054bf6bbb feat(host-agent): updates on pacman, zypper and yum hosts (#5803, #5804)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m26s
CI / unit (push) Successful in 1m35s
CI / publish (push) Successful in 20s
Agent 1.21.0. The hourly update check now covers the package managers
beyond apt and dnf (#5803 "The agent's update check reads pacman,
zypper and yum"):
- yum: `yum -C check-update`, `updateinfo list security`,
  `needs-restarting -r`, as dnf.
- zypper: `--no-refresh list-updates` (`--dup` on Tumbleweed),
  needed security patches from `list-patches --category security`,
  and `zypper needs-rebooting`.
- pacman: `checkupdates` from pacman-contrib, which install-deps.sh now
  installs. pacman has no security notion; a reboot is required once
  the running kernel's /usr/lib/modules/<release> is gone.
The checks sit in one UPDATE_CHECKS table, and a failure names its
manager.

The root helper's update runs on the same hosts (#5804 "The scheduled
update runs on pacman, zypper and yum hosts"): yum upgrade, zypper
refresh + update (dup on Tumbleweed, with 102/103 counted as success),
pacman -Syu. Each step carries a label so a failure says which one,
and the reboot check reads zypper and the pacman kernel too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:47:13 -04:00
bvandeusenandClaude Opus 5.5 1878f91293 chore: the systemd unit runs Steward, not FabledNetMon
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m27s
CI / unit (push) Successful in 1m37s
CI / publish (push) Successful in 15s
systemd/fablednetmon.service still carried the project's first name in
its user, paths, command and config location, so it couldn't start
Steward as written. It is now systemd/steward.service: user steward,
/opt/steward, `steward --config /etc/steward/config.yaml`, with a note
that /data must be writable (secret.key, safety backups) and why
Restart=on-failure brings Steward back after a web UI restore (exit 3).

The legacy-name guard now reads service units too, and forbids
"netmon" beside "scryer" and "roundtable". The 2026-04 rebrand plan and
spec under docs/superpowers keep the old names: they record the rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:35:41 -04:00
bvandeusenandClaude Opus 5.5 8569b3d674 fix(backup): the safety backup's audit row names who restored (#5820)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m28s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m10s
On the live restore the "before restore" backup.made row read user
"unknown": _safety_backup recorded it with no username. It now carries
the person who started the restore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:32:08 -04:00
bvandeusenandClaude Opus 5.5 e445c83e67 feat(backup): restore at first-run setup, restore --in-place, docs lead with the UI (#5818, #5819)
CI / integration (push) Successful in 1m28s
CI / unit (push) Successful in 1m37s
CI / publish (push) Successful in 1m17s
CI / lint (push) Successful in 2s
#5818 "First-run setup: Restore from a backup, by upload": beside creating
the first admin, setup offers "Or restore from a backup". Only while no
user exists; the upload is checked (a newer backup or one whose key can't
open its secrets is refused and not kept), then restored with no safety
backup (nothing to keep), and Steward restarts into the backup's users.

#5819 "Docs and CI": backups.md leads with restoring from Settings →
Backups and from setup; `steward restore --in-place` does the same-schema
restore from the shell, and CI's image check now uses it with a real
pg_restore: a changed setting comes back and the audit log stays.

Also exempts errors/restoring.html from the breadcrumb guard, as with
the other whole-app error pages: it replaces every page while a restore
runs, so there is nowhere to trail back to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:05:39 -04:00
bvandeusenandClaude Opus 5.5 fb99f8b551 feat(backup): restore from Settings → Backups, with restoring mode (#5816, #5817)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m27s
CI / unit (push) Failing after 1m37s
CI / publish (push) Skipped
#5816 "Restoring mode": while a backup goes back in, the scheduler holds,
ingest and HTMX answer 503 with Retry-After (agents keep their samples),
and every page says what is happening. A safety backup of Steward as it
stands is made first, to /data/backups and the bucket. The backup's key
is put in place, the restore audited, and Steward exits with code 3 so
Swarm, compose or systemd starts it fresh. A failed in-place restore
changes nothing and says why on Backups; a full replace that fails after
the wipe restarts into first-run setup.

#5817 "Settings → Backups: Restore": a Restore button on each sent backup
and on the file card. The chosen backup is checked (the same comparison
as Check, now a shared partial) and held server-side for an hour under a
token; it starts only once the admin types "restore", and an older
backup also needs "Replace everything" ticked. A newer backup, or one
whose key can't open its secrets, gets no button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:03:01 -04:00
bvandeusenandClaude Opus 5.5 9e5f6e183a fix(backup): refuse another schema before reading the dump; tests read settings directly (#5815)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m25s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m11s
- restore_in_place checks the backup's migration heads before pg_restore
  writes anything, so a backup from another schema is refused at once.
- The integration tests read their own setting rows directly:
  get_all_settings knows only Steward's own keys.
- check.py: drop an import left unused by the move to pgdata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:56:57 -04:00
bvandeusenandClaude Opus 5.5 aab8581f20 feat(backup): restore a same-version backup in place, history kept, all or nothing (#5815)
CI / lint (push) Failing after 3s
CI / unit (push) Successful in 1m41s
CI / integration (push) Failing after 1m45s
CI / publish (push) Skipped
#5815 "Restore engine: in place for a same-version backup".

restore_in_place (steward/backup/restore.py) runs in one transaction:
1. It keeps aside what Steward records as it runs: ACTIVITY columns of rows
   that come back, and STATE_SETTINGS.
2. It drops every foreign key touching a replaced table.
3. It truncates the replaced tables and COPYs the backup's rows in
   unparsed, through asyncpg.
4. It puts the kept state back and moves sequences past their rows.
5. It puts right the rows whose parent didn't come back, by each key's own
   ON DELETE. A RESTRICT key that may be empty is cleared, so a run whose
   user is gone keeps its record.
6. It re-adds the keys, which checks every row.

A failure anywhere rolls the whole restore back. A backup made at another
schema is refused with NotSameSchema ("older": a full replace is the way;
"newer": update Steward).

The COPY-text reading moves into steward/backup/pgdata.py, shared by the
check and the restore. The integration tests stand in for pg_restore with
this database's own COPY output:
- settings come back while the audit log and the history of what remains
  stay;
- history of what didn't come back goes;
- a failure part-way changes nothing;
- a newer schema is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:53:38 -04:00
bvandeusenandClaude Opus 5.5 e8b75143f1 feat(backup): check a backup against what is in place, changing nothing (#5823)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m23s
CI / unit (push) Successful in 1m37s
CI / publish (push) Successful in 1m8s
#5823 "Check a backup: compare what it would restore with what is in
place, without restoring".

- steward/backup/check.py: pg_restore writes the backup's rows as COPY
  text with no database involved. The rows in place go out through COPY in
  the same form. Tables are compared on their primary keys, over the columns
  both sides have, and grouped by area.
- Sealed values are opened with each side's own key and compared as
  plaintext digests, so a secret sealed twice still matches. They are never
  shown. A backup whose key can't open its own secrets fails the check.
- What Steward records as it runs (last-seen times, a schedule's last run,
  backup history, scraped UniFi fields) is left out of the comparison:
  contents.ACTIVITY, STATE_SETTINGS and HAND_SET_ONLY.
- Settings → Backups:
  - a Check button on each sent backup, which fetches it from the bucket
    (new s3.get_file);
  - "Check a file" to upload one, with the size limit raised before the
    CSRF check reads the form;
  - a results page.
- Each backup Steward sends checks itself once written, and its history
  row says how that went.
- New CLI command `steward check-backup FILE`. CI's image smoke step checks a
  real backup against its source and expects a match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:48:48 -04:00
bvandeusenandClaude Opus 5.5 e6da0ff299 feat(backup): a backup holds settings and keys only, for recovery (#5822)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m25s
CI / unit (push) Successful in 1m36s
CI / publish (push) Successful in 1m10s
#5822 "Backup contents: settings and keys only". The operator's ruling
(System 180, 2026-10-15) overturns 2026-10-12's "Config + audit + alerts":
a backup is for system recovery and carries what would otherwise have to
be set up again, with no logs or history.

- contents.py: audit_events, alert_events, alert_states, metric_anomalies,
  metric_anomaly_spans and user_invites move to EXCLUDED.
- Alert state is no longer restored, and evaluation skips a rule with no
  state row. ensure_rule_states (alerts/service.py) gives every
  single-resource rule lacking one an inactive row; alert_reconcile runs
  it each minute.
- Docs, the Backups page and the CLI help say what a backup holds now.
  CI's image check also finds the audit log empty after a restore.
- tests/integration/test_backup_restore_fixes.py is removed: its case (an
  alert event naming a left-out Ansible run) no longer arises. The SET NULL
  fix-up is covered by a unit test on synthetic tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:39:55 -04:00
bvandeusenandClaude Opus 5.5 b29b160baa feat(widgets): Overview verdict, pending monitors' age, fullest filesystems first (#5810)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m23s
CI / unit (push) Successful in 1m33s
CI / publish (push) Successful in 1m16s
- Overview: a verdict line when something is wrong ('1 alert firing, 1
  monitor down.'); its picker description now says what it lists (alerts,
  monitors down, anomalies). Host figures have their home in Hosts.
  Comments no longer name the Status page that was folded into Monitors.
- Monitors widget: a monitor not checked yet says when it was added.
- Host page: filesystems fullest first, by the worse of space and inodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:11:37 -04:00
bvandeusenandClaude Opus 5.5 5c7a4cf9a0 feat(widgets): a troubled container on the Docker widget says since when (#5810)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m23s
CI / unit (push) Successful in 1m33s
CI / publish (push) Successful in 1m7s
Its problems and since come from the same container_problems and
problem_since the Docker page uses, read from the day's deaths and
health changes; troubled containers sort worst problem first, then name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:09:25 -04:00
bvandeusenandClaude Opus 5.5 c113cadbdd feat(widgets): Routers and Hosts problem rows say since when (#5810)
Routers: a router in trouble reads 'started 2h ago' or 'all of the last
24 h', from problem_since over the day's history, read for the troubled
routers only (a certificate's problem carries its own date). Hosts: a
down host reads 'a monitor is down for 3h'; host_glance keeps the
down_since host_status already had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:08:38 -04:00
bvandeusenandClaude Opus 5.5 423493e2bf fix(docker): a restarted agent's re-sent log lines are stored once (#5809)
An agent keeps its log cursors in memory, so after a restart it seeds each
container from its last 50 lines, which Steward already holds; docker_logs
has no unique key, so every update or reboot doubled them. The ingest now
reads what it already has for each container over the batch's own span,
in one query, and skips those lines. A push of new lines overlaps nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:08:38 -04:00
bvandeusenandClaude Opus 5.5 a68990db70 fix(alerts): an alert's email waits for the real commit, not a savepoint (#5807)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m24s
CI / unit (push) Successful in 1m34s
CI / publish (push) Successful in 1m16s
SQLAlchemy fires after_commit and after_rollback for a savepoint too. The
host-agent ingest releases one after recording its metrics, so an alert
raised by a sample started its email before the outer transaction
committed; twice on 2026-10-12 the outbox row hit the event's foreign key
first and the email was lost. A savepoint rolling back (a docker persist
failing) cleared every pending notification, the outer one's included.

_after_commit now runs its jobs only on the outermost commit, and a
rolled-back savepoint drops only the jobs added inside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:05:50 -04:00
bvandeusenandClaude Opus 5.5 e2055242f1 fix(host_agent): disks behind a RAID controller each keep their own name (#5806)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m24s
CI / unit (push) Successful in 1m32s
CI / publish (push) Successful in 1m6s
smartctl reports every disk behind a PERC/MegaRAID controller as /dev/bus/0
with type megaraid,N; the helper named a drive by its path alone, so all of
VDNT-Docker02's and Docker03's disks read as one drive "0", each overwriting
the last. The slot is now part of the name (bus0-megaraid3), and Steward gives
a repeated name an ordinal so two drives never share a series. Agent 1.20.1.

fix(ansible): Steward stopping mid-run logs no traceback (#5749)

The run's done-callback called exception() on a cancelled task, which raises;
it now logs a run cut short by shutdown at INFO. A finished run's in-memory
state is dropped by a timer rather than a sleeping task, which shutdown found
still pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:41:20 -04:00
bvandeusenandClaude Opus 5.5 6c4ef368f9 feat(host_agent): each drive's health, read with smartctl, alertable (#5789)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m24s
CI / unit (push) Successful in 1m32s
CI / publish (push) Successful in 1m10s
The root helper gains one read-only action, `drives`: it finds the drives
itself (smartctl --scan-open), reads each as JSON, and writes a summary the
agent carries on every sample (agent 1.20.0). Steward judges the figures in
drives.py (failing / worn / watch / ok / no SMART / unreadable), records them
per drive and as host roll-ups for covering rules, keeps the newest report on
the registration and lists it on the host page.

install-deps.sh installs smartmontools with apt, dnf, yum, zypper, pacman or
apk, run by install.sh and every agent playbook; one it can't install is
reported, never fatal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:17:50 -04:00
bvandeusenandClaude Opus 5.5 fdc5c07183 feat(swarm): services short, nodes down and managers lost are alertable (#5788)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m32s
CI / unit (push) Successful in 1m45s
CI / publish (push) Successful in 17s
On 2026-10-14 nine swarm services sat at 0/1 and nothing fired. From each
manager's swarm report the docker plugin now records three figures on
resource "swarm": swarm_services_short, swarm_nodes_down and
swarm_managers_unreachable. Drained nodes are left out of the last two,
since a drain is deliberate. The figures are recorded at most once every
30 s whichever manager reported, and are offered to alert rules.

Agent 1.19.0 sends each manager's ManagerStatus.Reachability. Older agents
fall back to the node's status.

A maintenance job on a swarm node also keeps a window over docker/swarm,
so its drain and reboot hold those alerts until the job ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:23:52 -04:00
bvandeusenandClaude Opus 5.5 26a3ec3339 feat(alerts): the host's temperature and throughput can drive alert rules (#5787)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m23s
CI / unit (push) Successful in 1m30s
CI / publish (push) Successful in 1m23s
temp_c_max was recorded on every ingest, but host_agent didn't offer it
to rules, so "host running hot" couldn't be watched. The catalog guard
didn't see the row(...) helper ingest builds its rows with. Now offered:
temp_c_max, temp_c per sensor, and network and disk throughput per host,
interface and disk. A new guard holds every literal row(...) name to the
catalog, except NOT_ALERTABLE, where each exclusion is given a reason.
temp_c_max reads in °C.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:20:11 -04:00
bvandeusenandClaude Opus 5.5 9a11f5788f test(host_agent): both sensor refreshes run in one event loop (#5786)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m24s
CI / unit (push) Successful in 1m32s
CI / publish (push) Successful in 18s
The second asyncio.run got a pooled connection bound to the first loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:45:59 -04:00
bvandeusenandClaude Opus 5.5 ec13f3797d perf(host_agent): the sensor judgment reads the week once, then only new hours (#5786)
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m23s
CI / unit (push) Successful in 1m31s
CI / publish (push) Skipped
On the live instance the hourly refresh took 3 min 8 s, averaging a week of
raw temp_c samples each time. The hourly averages are now kept in memory.
The first refresh after startup reads the whole window. Each later one reads
raw samples from the start of the hour the last read ended in, so the hour
that was still filling is read again whole. merge_hours folds the new rows
in and drops hours older than the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:42:11 -04:00
bvandeusenandClaude Opus 5.5 2ade4ebc46 fix(host_agent): sensors are judged from the raw week, not only its rollups (#5783)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m27s
CI / unit (push) Successful in 1m32s
CI / publish (push) Successful in 18s
Rollups exist only for hours older than the raw window (7 days by default),
so the first deploy read an empty week and judged nothing. refresh now
averages the raw samples by the hour and adds any rolled-up hours in the
window. Sensors are named from plugin_metric_latest, so each series is read
through its index instead of a pattern over every host_agent row. An
integration test covers both halves against Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:33:36 -04:00
bvandeusenandClaude Opus 5.5 0b29cdf26c feat(host_agent): a temperature sensor is judged by how it moves against the CPU (#5783)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m21s
CI / unit (push) Successful in 1m31s
CI / publish (push) Successful in 17s
A board's spare thermistor inputs report whatever an open input reads, and
the value rule from #5759 only catches the impossible ones. Once an hour,
sensors.refresh judges each sensor against the host's CPU (Package id N,
Tctl/Tdie/TccdN, PECI Agent N) over a week of hourly averages. A sensor is
ruled out if it falls as the CPU warms (r <= -0.5) or holds within 0.5 °C
while the CPU moved 10 °C or more. A judgment needs at least 72 shared hours.

The host temperature leaves judged sensors out at ingest. The host page
lists them under "Not counted", with each reason given once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:24:34 -04:00
bvandeusenandClaude Opus 5.5 72e754b580 fix: error-pages graded healthy, unwired sensors listed apart, Maintenance failed reads 0 (#5780, #5781, #5782)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m23s
CI / unit (push) Successful in 1m29s
CI / publish (push) Successful in 18s
- #5780 "The error-pages service is graded on other routers' errors". The
  Traefik scrape reads which services an errors middleware sends to, and
  grades them healthy: their 4xx and 5xx are the codes of the errors they
  cover for, already graded on the routers that failed.
- #5781 "The Maintenance failed rules sit at no data until a host has had
  a maintenance job". Every host with an agent reads 0 until a job fails.
- #5782 "VDNT-Util01's host page lists SYSTIN at 115 °C in red". The host
  page lists sensors that counts_as_hottest leaves out (unwired board
  inputs, 0 °C or below) on one muted line, not as heat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:15:44 -04:00
bvandeusenandClaude Opus 5.5 0f604ecb4a test(traefik): the 502 card's wording is read as rendered, apostrophe escaped (#5712)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m24s
CI / unit (push) Successful in 1m32s
CI / publish (push) Successful in 1m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:11:56 -04:00
bvandeusenandClaude Opus 5.5 7c424a0398 fix(traefik): a problem card works out its kind when it wasn't graded on the page (#5712)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m28s
CI / unit (push) Failing after 1m29s
CI / publish (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:08:19 -04:00
bvandeusenandClaude Opus 5.5 685850e681 feat(traefik): each router problem says what it means, whether it's real, and what to do (#5712)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m22s
CI / unit (push) Failing after 1m33s
CI / publish (push) Skipped
#5712 "Each router problem says what it means, whether it's real, and what
to do". A problem card now names its kind and reads its codes:
- Server errors: 500 means the app is failing; 502/503/504 means Traefik
  couldn't reach it or it timed out.
- Client errors: 404 scanners, a 401/403 login wall, 429 rate limiting, or
  499 clients giving up on a slow app.
- Slow: p95 against p50.
Each card says whether the problem is likely real, and ends with one next
step. A fault links to the swarm service behind the router, matched by
name, and traffic links to Settings → Thresholds. The wording lives in
plugins/traefik/meaning.py.

The plugin manager now hands out a loaded plugin's submodule
(loaded_plugin_submodule), so Traefik and host_agent reach Docker's
models the same way (#5775).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:04:39 -04:00
bvandeusenandClaude Opus 5.5 31a6cf09a9 fix(host_agent): a node the operator keeps drained stays drained through its maintenance (#5777)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m23s
CI / unit (push) Successful in 1m28s
CI / publish (push) Successful in 17s
#5777 "Maintenance would make a node the operator keeps drained active after
its reboot". A swarm node already set to drain or pause, such as a manager
kept free of work, is no longer drained before its reboot or made active
after it. Its job only updates and reboots it, and back-in-service needs it
ready but not active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:45:22 -04:00
bvandeusenandClaude Opus 5.5 c6b15b1cc6 fix(host_agent): maintenance reads the swarm through the loaded Docker plugin, not by importing its models again (#5768)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m22s
CI / unit (push) Successful in 1m34s
CI / publish (push) Successful in 1m9s
Importing plugins.docker.models by path defines its tables a second time,
because the plugin manager loaded them as _steward_plugin_docker.models.
Inside the app that raises InvalidRequestError, not ImportError. The cadence
integration test hit it, and #5767's _swarm (used when a job is planned)
carried the same import. Both now go through maintenance.docker_models().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:03:59 -04:00
bvandeusenandClaude Opus 5.5 200384e52d 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>
2026-10-14 18:59:07 -04:00
bvandeusenandClaude Opus 5.5 78581c843e fix(host_agent): the maintenance-jobs migration waits for maintenance_windows, and the guards know the new table and action (#5767)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m21s
CI / unit (push) Successful in 1m28s
CI / publish (push) Successful in 1m10s
CI 9146 on 22b0be2:
- integration: host_agent_007 ran before core 0010 created maintenance_windows
  (its window_id foreign key), so the schema never built. It now
  depends_on 0010_maintenance_windows.
- unit: host_maintenance_jobs is named in the backup's EXCLUDED (run
  history, like ansible_runs and host_downtime), and the
  host_agent.maintenance audit action has its wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:45:07 -04:00
bvandeusenandClaude Opus 5.5 22b0be2b6c feat(host_agent): Steward hands a host a maintenance job and follows it through the reboot (#5767)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m28s
CI / unit (push) Failing after 1m31s
CI / publish (push) Skipped
#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
bvandeusenandClaude Opus 5.5 6a68bd75d9 feat(host_agent): a root helper the agent can start, for updates, reboots and drains only (#5766)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m22s
CI / unit (push) Successful in 1m24s
CI / publish (push) Successful in 1m12s
#5766 "A root helper the agent can start, for a fixed set of maintenance
actions and nothing else", milestone 514.

The agent runs unprivileged under NoNewPrivileges, so it can't update or
reboot its host. It now leaves a request in /var/lib/steward-agent/requests,
the one path its unit lets it write; steward-maint.path starts maint.py as
root, which reads it without following links, accepts only update, reboot,
drain or undrain, deletes it, and writes the outcome to
/var/lib/steward-maint/results. The agent (1.17.0) reports each new result
state once as a `maintenance` event, re-checks pending updates when an update
finishes, and says in its metadata whether the helper is installed.

The units and the helper are served from one copy at
/plugins/host_agent/files/<name> (a fixed list), and install.sh plus the
install, provision and update playbooks all fetch them, replacing three
inline copies of the unit. Update agent installs the helper on existing
hosts; the Agent panel says under Manage agent whether it is there.

Steward handing out jobs is #5767; the cadence is #5768.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:34:28 -04:00
bvandeusenandClaude Opus 5.5 210a35c956 feat(host_agent): a host says what updates it has waiting and whether it needs a reboot (#5765)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m22s
CI / unit (push) Successful in 1m26s
CI / publish (push) Successful in 1m20s
Step 1 of milestone 514 "Updates and reboots on a cadence, carried out by
each host's agent…": #5765 "The agent reports pending updates and whether
a reboot is required".

- Agent 1.16.0: an hourly background check (apt-check, apt list, or dnf -C
  with updateinfo and needs-restarting), each command bounded at 120 s, no
  root and no network. Debian's reboot-required marker and its packages
  are read every sample.
- Ingest records updates_pending, updates_security and reboot_required for
  rules, and keeps the newest on the registration (host_agent_005_updates).
- The Agent card leads with "Reboot required since …", then the updates
  waiting or "Up to date", with when the check ran. list_agents carries
  them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:23:44 -04:00
bvandeusenandClaude Opus 5.5 b1db1e8b0d test(host_agent): the first log read is the one without since=, now that every read has a tail (#5764)
CI / integration (push) Successful in 1m20s
CI / unit (push) Successful in 1m26s
CI / publish (push) Successful in 1m4s
CI / lint (push) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:04:41 -04:00
bvandeusenandClaude Opus 5.5 a805e1d539 fix(host_agent): every Docker read is bounded, and the agent reports its own footprint (#5764)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m20s
CI / unit (push) Failing after 1m32s
CI / publish (push) Skipped
#5764 "The agent reads a container's whole log backlog into memory, on its
main loop, with no overall deadline". Docker03's 1.13.0 agent went silent
for a day at 2.1 GB and 46 h of CPU, then ignored SIGTERM.

- One shared reader, _docker_get: each answer has an overall deadline and a
  byte cap, not only a per-recv timeout. A trickling or endless body is cut
  off (15 s / 32 MiB for JSON, 5 s / 4 MiB for a log read).
- Container logs: since the cursor but only the newest 2,000 lines, all
  within 10 s. A note says when older lines were skipped, and a container
  whose answer is too big skips ahead to now instead of re-reading its
  backlog every interval.
- The agent reports its own RSS and CPU; Steward records agent_rss_bytes
  and agent_cpu_pct, so a runaway agent shows before it goes silent.

Agent 1.15.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:01:45 -04:00
bvandeusenandClaude Opus 5.5 82a2a3d41f fix(host_agent): SYSTIN and other board inputs past 100 °C don't set the temperature (#5759)
CI / unit (push) Successful in 1m26s
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m21s
CI / publish (push) Successful in 17s
#5759 "VDNT-Util01 reads 114 °C: its "hottest sensor" is a Super-I/O input
with nothing wired to it". b3885c3 left out AUXTIN*, but Util01's SYSTIN
reads 114 °C too, so temp_c_max stayed at 114. A board's Super-I/O inputs
(SYSTIN, CPUTIN, AUXTIN*, PCH_*) now count only below 100 °C: a wired one
reads the board, far below that, and still counts on other boards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:26:02 -04:00
bvandeusenandClaude Opus 5.5 b6c4ce510d fix(host_agent): a stalled NFS mount no longer freezes the agent (#5760)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m21s
CI / unit (push) Successful in 1m33s
CI / publish (push) Successful in 1m3s
#5760 "One stalled NFS mount freezes the whole agent: statvfs has no
deadline, so the host goes silent while it still answers ping". The agent
read every mount inline in its only loop; a hard-mounted share whose server
stops blocks statvfs in the kernel with no timeout. Each mount is now read
in a daemon thread under one 5 s deadline. One that misses it is sent as
stalled, and isn't probed again while its last probe is still blocked. A
mount that errors (a stale handle) is left out instead of emptying the list.

Steward counts them as mounts_stalled (alertable: "an NFS share stopped"),
and skips the fullest-disk figure when every mount stalled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:12:27 -04:00
bvandeusenandClaude Opus 5.5 7feb2b1ce2 fix(backup): host_downtime is history, left out of the backup (#5756)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 1m27s
CI / integration (push) Successful in 1m22s
CI / publish (push) Successful in 1m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:10:06 -04:00
bvandeusenandClaude Opus 5.5 b3885c3b81 fix(host_agent): an unwired sensor no longer sets a host's temperature (#5759)
#5759 "VDNT-Util01 reads 114 °C: its "hottest sensor" is a Super-I/O input
with nothing wired to it". AUXTIN0/1 on the board's Nuvoton chip read
110-112 °C flat at every load while the CPU package moved 49-87, so
temp_c_max read critical all week. The max now counts only readings that
can be real: not an AUXTIN input, above 0 and below 125 °C. Each sensor
keeps its own temp_c row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:09:48 -04:00
bvandeusenandClaude Opus 5.5 c7ff5bc556 feat(host_agent): a reboot reads as a reboot, and lost contact as its span (#5756)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m24s
CI / unit (push) Failing after 1m29s
CI / publish (push) Skipped
#5756 "The agent says when its host rebooted and when it lost contact, so
downtime is recorded as what it was". Agent 1.14.0 posts events with the
next post that gets through: started (with the host's boot time), stopping
(on SIGTERM, with whether systemd says the host is going down) and
lost_contact (from its first unanswered post to the one carrying it).

Steward keeps them as the host's downtime (host_downtime): reboot,
agent_stop, unclean (a boot after the agent was last heard that nobody
announced) or lost_contact. Retried posts apply once.

An announced stop explains the silence for reboot_grace_seconds (Settings
-> Plugins -> host_agent, 10 minutes): seconds_since_seen reads 0 until
then, and the host's other rules go quiet with it (agents_away beside
#5721's registry). Past the grace, agent offline fires.

The host's Agent card lists the last 7 days of downtime. Rows are pruned
at data.retention_days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:08:16 -04:00
bvandeusenandClaude Opus 5.5 5dd9956f35 feat(host_agent): a disk out of inodes reads as full (#5745)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m19s
CI / unit (push) Successful in 1m27s
CI / publish (push) Successful in 1m13s
VDNT-Util01's / ran out of inodes (956 free of 60.8M) with 274G of space
left; every file create failed and Steward's page read healthy at 66%.

- Agent 1.13.0 reports each mount's inodes (statvfs; a filesystem reporting
  none, like btrfs, is left without).
- Ingest records inodes_used_pct per mount, and disk_used_pct_worst is the
  fullest mount by space or inodes, so existing disk-full rules and the
  overview see it.
- The host's health strip, the Hosts widget and the host glance grade / by
  whichever is fuller and name inodes when they are ("Disk / 66% · inodes
  100%", "Inodes on / ... past its critical threshold").

Hosts pick up the inode figures once their agent is updated to 1.13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:51:46 -04:00
bvandeusenandClaude Opus 5.5 1b82d8ad83 fix(docker): a swarm service back to all its replicas leaves the overview and dashboard (#5722)
Nine services that lost tasks in one node's bad three minutes stayed on the
overview as warnings for a day, 17 hours after they recovered. Against the
UI ruling (only what is current on the dashboard), a service running all its
replicas is now history: its failed tasks stay on the Docker page, not on
the overview or the dashboard's failed count. Services still short of
replicas that started failing within five minutes of each other read as one
finding naming them, dated from the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:50:31 -04:00
bvandeusenandClaude Opus 5.5 d0c563abd0 fix(alerts): one silent agent is one alert and one finding (#5721)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m19s
CI / unit (push) Successful in 1m29s
CI / publish (push) Successful in 1m8s
VDNT-Docker03's silent agent read as three overview findings (agent offline,
agent stale, the disk rule's no-data) and every drop emailed the disk rule's
silence and return on top of agent offline.

- The host agent registers its heartbeat (seconds_since_seen) and the
  sources it carries (host_agent, docker). While a host's agent is past its
  offline window, its other rules' no_data is recorded "while its agent is
  offline" and not emailed, nor is their quiet return; a return past a
  threshold still fires.
- The overview folds those rules under the host's agent-offline finding
  ("also silent with it: ..."), and a stale agent whose agent-offline alert
  is active is that alert's finding, not a second one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:49:29 -04:00
bvandeusenandClaude Opus 5.5 76d75961f8 fix(alerts): a silence ends when Steward hears of it, once (#5720)
An agent's flushed backlog brings samples hours old. The resume was stamped
at the first old sample's time, inside the silence it ended, and silence was
still judged by the backlog's sample times, so the series was marked silent
again between posts and resumed a second time. A resumed series now counts as
heard at ingest time, its events from that batch are stamped no earlier, and
the rest of the backlog reads as late samples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:47:09 -04:00
400 changed files with 33622 additions and 1522 deletions
+15 -2
View File
@@ -228,18 +228,31 @@ jobs:
fi
# A backup made by the image restores into an empty database, and
# Steward starts on it (milestone 507). It carries configuration
# (the marker setting) and leaves metrics' rows out.
# Steward starts on it (milestone 507). It carries settings (the
# marker setting) and leaves metrics and the audit log out.
sql() { docker exec "$SMOKE-pg" psql -U steward -v ON_ERROR_STOP=1 -tA "$@"; }
sql -d steward -c "INSERT INTO app_settings (key, value_json, updated_at) VALUES ('smoke.backup', '\"kept\"', now())"
sql -d steward -c "INSERT INTO plugin_metrics (id, source_module, resource_name, metric_name, value, recorded_at) VALUES ('smoke-metric', 'smoke', 'smoke', 'smoke', 1, now())"
sql -d steward -c "INSERT INTO audit_events (id, timestamp, username, action) VALUES ('smoke-audit', now(), 'smoke', 'smoke.backup')"
docker exec -u app "$SMOKE-app" steward backup --to /tmp/backups
# Checked against the database it was made from, it matches (#5823):
# a real pg_restore read, and what a running Steward writes by itself
# (last-seen times, backup history) left out of the comparison.
docker exec -u app "$SMOKE-app" sh -c 'steward check-backup /tmp/backups/steward-*.tar' | tee /tmp/smoke-check.txt
grep -q "Matches what is in place" /tmp/smoke-check.txt
# Put back in place over a changed setting (#5815): the setting comes
# back, and history the backup leaves out (the audit log) stays.
sql -d steward -c "UPDATE app_settings SET value_json = '\"changed\"' WHERE key = 'smoke.backup'"
docker exec -u app "$SMOKE-app" sh -c 'steward restore --in-place /tmp/backups/steward-*.tar'
test "$(sql -d steward -c "SELECT value_json FROM app_settings WHERE key = 'smoke.backup'")" = '"kept"'
test "$(sql -d steward -c "SELECT count(*) FROM audit_events WHERE id = 'smoke-audit'")" = "1"
sql -d steward -c "CREATE DATABASE restored"
docker exec -u app \
-e "STEWARD_DATABASE_URL=postgresql+asyncpg://steward:steward@$SMOKE-pg:5432/restored" \
"$SMOKE-app" sh -c 'steward restore /tmp/backups/steward-*.tar'
test "$(sql -d restored -c "SELECT value_json FROM app_settings WHERE key = 'smoke.backup'")" = '"kept"'
test "$(sql -d restored -c "SELECT count(*) FROM plugin_metrics")" = "0"
test "$(sql -d restored -c "SELECT count(*) FROM audit_events")" = "0"
test "$(sql -d restored -c "SELECT count(*) FROM alembic_version")" -ge 1
docker run -d --name "$SMOKE-restored" --network "$SMOKE-net" \
-e "STEWARD_DATABASE_URL=postgresql+asyncpg://steward:steward@$SMOKE-pg:5432/restored" \
+1 -1
View File
@@ -52,7 +52,7 @@ RUN useradd -m -u 1000 app \
&& mkdir -p /data/ansible/sources /data/ansible/runs /data/ansible/playbooks /data/plugins \
&& chown -R app:app /data /app
EXPOSE 5000
EXPOSE 5000 5514/udp 5514/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
CMD ["steward", "--host", "0.0.0.0", "--port", "5000"]
+5 -1
View File
@@ -53,7 +53,7 @@ cp .env.example .env
docker compose -f compose.deploy.yml up -d
```
Open `http://<server>:5000` and create the admin account on first run.
Open `http://<server>:5000` and create the admin account on first run. Devices send syslog to `<server>:5514` (UDP or TCP); see `docs/plugins/syslog.md`.
**Update to the latest dev build:**
@@ -120,9 +120,13 @@ See `docs/plugins/` for a full plugin development guide.
| `docs/core/alerting.md` | Alert rules, state machine, notification channels, template variables |
| `docs/core/ansible.md` | Playbook sources, run lifecycle, SSE streaming |
| `docs/core/backups.md` | Steward's daily backup to an S3 bucket (Fabled Archives), what it holds, and `steward restore` |
| `docs/core/timeline.md` | The timeline: what it records from each source, areas and kinds, Settings → Timeline |
| `docs/core/tickets.md` | Tickets: known problems, linked to alerts and anomalies, shown as known issues, resolved onto the timeline |
| `docs/plugins/overview.md` | Plugin system: how loading works, plugin.yaml schema, required exports |
| `docs/plugins/writing-a-plugin.md` | Step-by-step plugin development guide |
| `docs/plugins/traefik.md` | Traefik plugin: config, metrics, alert rule examples |
| `docs/plugins/prometheus.md` | Prometheus plugin: scraping any /metrics endpoint, how series are stored, the Fabled Archives recipe |
| `docs/plugins/homeassistant.md` | Home Assistant plugin: waiting updates, backup age, and installing updates after a confirmed backup |
| `docs/plugins/syslog.md` | Syslog plugin: sources, patterns and alerts, publishing port 5514 (ingress or host mode under Swarm) |
| `docs/plugins/prometheus-widgets.md` | Prometheus widget templates: writing, sharing and copying one in, every block, the format reference |
| `docs/reference/code-map.md` | Where every key function, model, and route lives in the codebase |
+8
View File
@@ -16,6 +16,14 @@ services:
pull_policy: always
ports:
- "5000:5000"
# Syslog (plugins/syslog): devices send here. Steward runs unprivileged,
# so it listens above 1024; publish 514 to it instead ("514:5514/udp")
# if a device can't choose its port. Under Docker Swarm's ingress every
# device arrives from one internal address and is told apart by the name
# its lines carry; the long form with `mode: host` keeps each device's
# own address but ties Steward to that node.
- "5514:5514/udp"
- "5514:5514/tcp"
networks:
- backend
volumes:
+2
View File
@@ -8,6 +8,8 @@ services:
command: ["steward", "--host", "0.0.0.0", "--port", "5000", "--debug"]
ports:
- "5000:5000"
- "5514:5514/udp" # syslog (plugins/syslog)
- "5514:5514/tcp"
volumes:
- app_data:/data
# Live-edit core + first-party plugins without rebuilding. PYTHONPATH=/app
+5 -5
View File
@@ -105,7 +105,7 @@ A two-minute spike raises the peak and leaves the p95 low; an hour spent at that
A metric a plugin registers with `learn_normal()` gets a normal per hour of the day, then per hour of the week once three weeks of history exist (`metric_norms`, relearned hourly by `metric_norms`; `steward/core/norms.py`).
Every minute, `metric_anomalies` averages each learned key's last 15 minutes and compares it with the normal for that hour. A level above the usual hold ×1.25, and above the metric's floor, opens an anomaly (`steward/core/anomalies.py`):
Every minute, `metric_anomalies` averages each learned key's last 15 minutes and compares it with the normal for that hour. A level above the usual hold ×1.25, and above the metric's floor, has departed. It opens an anomaly once it is also higher than the metric usually goes, or held: the median of the last hour is over the same line. How high it usually goes is the greater of the usual peak for that hour and how high it has gone in most hours of the day, so bursts that land at any hour are told from a job with a time of its own. A burst of a few minutes within that lifts the 15-minute average and opens nothing. Height is judged with the window's highest reading left out, so one slow request can't carry a window past it, and a held level needs at least three readings behind its median, so a lone reading never opens an anomaly (`steward/core/anomalies.py`):
- `metric_anomalies` holds one row per item to review. A repeat while the row still waits joins it as another occurrence (`metric_anomaly_spans`).
- A row is `to_review` until someone marks it `normal` or `real` (Alerts → Anomalies, or MCP `review_anomaly`). An ended row nobody reviews within `anomalies.review_days` (14) becomes `lapsed`.
- Learning skips every hour an occurrence covers unless its anomaly was marked `normal`, so a new level is never learned without review.
@@ -118,14 +118,14 @@ What learns a normal, and the floor each departure must clear (Settings → Thre
| Host CPU | `host_agent` / `cpu_pct` | 10 points |
| Host memory | `host_agent` / `mem_used_pct` | 5 points |
| Host load per core | `host_agent` / `load_pct` (load_1m ÷ cores × 100, recorded at ingest) | 10 points |
| Container CPU | `docker` / `cpu_pct` (100% is one core) | 10 points of one core |
| Container memory | `docker` / `mem_pct` (of its limit, or of host RAM when it has none) | 5 points |
| Container CPU | `docker` / `cpu_pct` (100% is one core; a swarm service's under `swarm/<service>`, shared by its tasks) | 10 points of one core |
| Container memory | `docker` / `mem_used_pct` (its processes' own memory, file cache left out, of its limit or of host RAM when it has none) | 5 points |
| Monitor response time | `<type>` / `response_ms` | 100 ms |
| Router 4xx share, p95 | `traefik` / `error_rate_4xx_pct`, `latency_p95_ms` | 2 points, 50 ms |
Host metrics learn with `parts=False`: the host learns, its cores (`<host>:core<N>`) don't. Disk space, a monitor that is down, an agent gone quiet and a router's 5xx share are failures and keep their fixed cutoffs.
Host metrics learn with `parts=False`: the host learns, its cores (`<host>:core<N>`) don't. Disk space, a monitor that is down, an agent gone quiet and a router's 5xx share are failures and keep their fixed cutoffs (a router's shares are judged only over enough requests).
Pages grade by the normal through `anomalies.graded()` (hosts' `host_grades` and `worst_reading(normals=)`, `docker.service.usage(thresholds=, norms=)`, `monitors.service.latency_grade`): a reading past its cutoff but within its normal reads healthy and says what is usual at this hour. The host, monitor, container and Traefik pages list their anomalies with `_anomaly_card.html`.
Pages grade by the normal through `anomalies.graded()` (hosts' `host_grades` and `worst_reading(normals=)`, `docker.service.usage(thresholds=, norms=)`, `monitors.service.latency_grade`): a reading past its cutoff but within its normal reads healthy and says what is usual at this hour. The host, monitor, container and Traefik pages each carry a one-line `_anomaly_card.html`. It says how many anomalies wait for review and names any that are out of their normal now. Its Review button opens Alerts → Anomalies narrowed to that resource (`?source=&resource=`), where they are reviewed.
---
+3
View File
@@ -73,6 +73,7 @@ Alert rules are created in the UI at `/alerts/`. Each rule requires:
- **Metric name** — the metric key (e.g. `response_time_ms`, `up`, `error_rate_5xx_pct`)
- **Operator** — `>`, `<`, `>=`, `<=`, `==`, `!=`
- **Threshold** — numeric value
- **rose by** — instead of a line to cross, fires when a reading is at least the threshold above the one before it, for a count that only grows (a disk's bad sectors). A fall doesn't fire. A reading that hasn't risen counts as OK, so the checks to clear set how long the alert stays up, and rises inside that are one alert
- **Consecutive failures required** — how many consecutive breaches before FIRING (default 1)
The form offers only what has reported, read from `plugin_metric_latest`.
@@ -138,6 +139,7 @@ failed agent posts, a gap in watching, or open files near the limit.
| `ingest_slowest_ms` | The slowest post in the last minute; agents give up at 10 s |
| `db_roundtrip_ms` | A `SELECT 1`, connection checkout included |
| `unwatched_secs` | The gap before this tick when it exceeds `watch.GAP_S` (a deploy or an outage); 0 when unbroken |
| `restart_gap_secs` | Recorded once, on a new process's first tick: the time since the last process's last sample, however short |
| `cleanup_secs` | How long the last hourly retention and rollup run took |
| `open_fds` / `open_fds_pct` | Steward's open files, and as a share of its limit (absent without `/proc`) |
@@ -212,6 +214,7 @@ Configured at `/settings/` under Webhook. The body is a Jinja2 template rendered
| `alert.threshold` | float | Configured threshold |
| `alert.resource` | str | Resource name |
| `alert.source_module` | str | `ping`, `dns`, `traefik`, etc. |
| `alert.which` | str | For a figure that counts things, what it counts when it fires, joined by ", " (`web_app (0 of 2 running), …`); `raw.which` is the list. Empty for other figures. |
| `alert.timestamp` | str | ISO 8601 UTC |
Default template (Discord-compatible):
+116 -13
View File
@@ -1,8 +1,9 @@
# Backups: Steward backing itself up, and coming back
Steward sends a backup of itself to an S3 bucket once a day. Fabled Archives
is the intended target. A backup is one file, and `steward restore` brings
Steward back from it into an empty database.
is the intended target. A backup is one file. Settings → Backups restores it
on a running Steward, first-run setup restores it on a new one, and
`steward restore` brings Steward back from it when Steward can't start.
## What a backup holds
@@ -14,18 +15,28 @@ Steward back from it into an empty database.
| `secret.key` | The key Steward's stored passwords and tokens are sealed with |
| `manifest.json` | Format, Steward version, migration heads, time, and the tables whose rows were left out |
A backup is for **system recovery**: it holds what you would otherwise have
to set up again, and no logs or history.
**Kept:**
- configuration: settings, users, API tokens, hosts, monitors, alert rules, maintenance windows, Ansible inventory and schedules, dashboards, SNMP devices, Prometheus targets, UniFi client names;
- the audit log;
- alert history.
- settings, with the passwords and keys they hold;
- users and API tokens;
- hosts and their agent enrolments, so agents don't need enrolling again;
- monitors, alert rules, maintenance windows and maintenance cadences;
- Ansible inventory and schedules;
- dashboards and share links;
- SNMP devices, Prometheus targets and widget templates, UniFi client names.
**Left out** (the tables come back empty and fill again as Steward runs):
- metrics and their rollups;
- the audit log;
- alert history and alert state. Each rule starts inactive and is watched
again within a minute;
- metric anomalies and their reviews;
- metrics, their rollups and learned normals;
- monitor check results;
- Docker, Traefik and UniFi history;
- Ansible run records;
- the email queue;
- password-reset links.
- Ansible run records and maintenance job history;
- the email queue, pending invites and password-reset links.
The list is `steward/backup/contents.py`. A test fails when a new table
appears in neither list, so whoever adds a table decides.
@@ -69,6 +80,47 @@ While backups are on, Steward records two metrics under the source
A rule such as `backup_age_secs > 93600` (26 hours) fires when a daily backup
has been missed.
An agent on the MCP sees the same without a rule. `get_overview` names a
scheduled backup that failed, is two hours past its slot with none made, or has
no bucket set up. `list_backups`, `check_backup` and `make_backup` are this
page's admin tools (see [MCP](mcp.md)); restoring isn't among them.
## Checking a backup
**Settings → Backups → Check** compares a backup with what is in place, without
changing anything. It shows what a restore would bring back, change or remove.
Each row under Recent backups has its own Check button, and **Check a file**
takes a file downloaded from the bucket. From a shell:
```sh
docker exec -u app <steward container> steward check-backup /tmp/steward-20261015T030000Z.tar
```
The check reports:
- for each area (Settings, Users and API tokens, Hosts and agents, Monitors,
Alert rules, Ansible, Dashboards, Plugins), how many entries are the same,
differ, are only in the backup or are only here;
- each setting that differs, with both values. A password or key shows only
"differs": sealed values are opened with their own key and compared, never
shown;
- whether the backup's secret key is the one in use, and whether it opens the
backup's own sealed values. A backup whose key can't open them fails the
check, because its secrets would not come back;
- whether the backup was made by this Steward's schema, an older one or a
newer one.
What Steward records as it runs is left out of the comparison: when an agent
or monitor was last seen, a schedule's last run, the backup history. These
change by the minute. The list is `ACTIVITY` and `STATE_SETTINGS` in
`steward/backup/contents.py`.
Each backup Steward sends is checked once it is written, and its row under
Recent backups says so. A backup is sent even when its check can't run.
Checking a backup on the bucket reads it back from there. An append-only key
may not be allowed to; the page then says AccessDenied, and the file can be
downloaded and checked with **Check a file** instead.
## Making a backup by hand
```sh
@@ -80,8 +132,57 @@ as Steward itself.
## Restoring
### From the web UI
**On a running Steward: Settings → Backups.** Press **Restore** on one of
the recent backups, or choose a file on the "Check or restore a file" card.
Steward first shows what the backup would change: the same comparison as
Check. Then it says what a restore does and asks you to type `restore`.
- **A backup of this Steward's schema goes back in place.** Settings,
users, hosts, monitors, alert rules and dashboards become the backup's.
The audit log and the history of everything still here stay. History of
anything the backup doesn't have goes with it. It is one transaction: if
anything fails part-way, nothing changes, and Backups says why.
- **A backup from an older Steward replaces everything**, history included,
and you tick "Replace everything" as well. Steward brings it up to date
as it starts.
- **A backup from a newer Steward isn't offered.** Update Steward first.
Before anything changes, Steward makes a **safety backup** of itself as it
stands. It goes to `/data/backups` (the newest five are kept) and to the
bucket, and it is listed under Recent backups as "before restore". A
restore can be undone by restoring that.
While the restore runs:
- every page says Steward is restoring;
- agents get a 503 and keep their readings until Steward is back;
- scheduled work holds.
Then Steward restarts (it exits with code 3 so Swarm, compose or systemd
starts it again), and you sign in as a user from the backup. If an older
backup's full replace fails after the database was emptied, Steward starts
at first-run setup, where the safety backup can be uploaded.
**On a new Steward: first-run setup.** Next to "Create the initial admin
account" is **Or restore from a backup**: upload a backup file instead,
and Steward comes up with its users, settings and key. There is nothing to
keep yet, so no safety backup is made.
The backup's secret key is put in place as `/data/secret.key`. If
`STEWARD_SECRET_KEY` is set instead, it must equal the backup's key, or the
restore stops before anything changes.
### From the command line
`steward restore` writes into an **empty** database. Pass `--wipe` to drop
everything in the database's public schema first.
everything in the database's public schema first. Use it when Steward
can't start at all.
`steward restore --in-place` does what Settings → Backups does for a backup
of the same schema, from the shell: it replaces what the backup carries and
keeps the audit log and history, in one transaction. Restart Steward after
it so the restored settings load.
1. Stop Steward (scale its service to 0) so nothing writes during the restore.
2. Download the backup file from Archives.
@@ -105,7 +206,7 @@ everything in the database's public schema first.
then data, then constraints and indexes. Between the data and the
constraints, a kept row that points at a left-out row is put right by its
foreign key's ON DELETE:
- an alert event's link to an Ansible run is cleared;
- a SET NULL link is cleared and the row kept;
- a row whose foreign key would cascade or block is removed.
pg_restore 17 writes each section as a script for psql. It runs against
@@ -113,5 +214,7 @@ Postgres 16 with one setting line dropped (`transaction_timeout`, which 16
doesn't know).
CI checks the whole path on every build. The built image backs up its own
database, restores into a second one, checks a setting came back and the
metrics table is empty, then starts Steward on it.
database and checks the backup against it. It changes a setting and puts
the backup back in place: the setting returns and the audit log stays.
Then it restores into a second, empty database, checks a setting came back
and the metrics table and audit log are empty, and starts Steward on it.
+47 -8
View File
@@ -52,7 +52,7 @@ authentication. The AI access page shows the recipes for both.
| Bearer tokens only, no OAuth | `steward/core/api_tokens.py`. Only a hash is stored, and the token is shown once. |
| A token never acts above its user | Effective role = min(token role, user's **current** role). Lowering a user lowers every token they hold; disabling the user stops them all. |
| Default deny | A tool is reachable only if it was registered with a role (`register_tool(fn, role=..., read_only=...)`). `tools/list` shows a caller only the tools their role allows. A call above the role is refused, and the refusal names the role it needs. |
| Plugin tools follow the plugin | A disabled plugin's tools are hidden and refused. |
| Plugin tools follow the plugin | A disabled plugin's tools are hidden and refused. The host agent is built in and always on (it can't be disabled), so its tools are always there. |
| Dry run first | `run_playbook`, `create_schedule` and `set_alert_action` default to Ansible check mode (`--check --diff`). Changes are applied only when the caller passes `check=false`. A playbook its author flagged `# steward:confirm: true` also needs `confirmed=true`. `prune_docker` previews unless `check=false`. |
| Secrets stay put | Playbook variables Steward detects as secret reach the run but are never stored, audited or returned. Schedules and alert actions refuse them, since they would have to store them. Inventory variables are listed by name only, and HTTP monitor header values are redacted. |
| Every change is audited | Each write records an audit event as `<user> via MCP`, with the token id in its detail. `list_audit_events(user="<user>")` includes those events. |
@@ -75,11 +75,19 @@ Each role includes the tools of the roles above it in this list.
| Hosts and metrics | `list_hosts`, `get_host`, `get_host_metrics`, `query_metrics` |
| Monitors | `list_monitors`, `get_monitor` |
| Alerts | `list_alerts`, `get_alert_history`, `list_alert_rules`, `get_alert_rule_options`, `list_maintenance_windows` |
| Metric anomalies | `list_anomalies` (metrics that left their normal for the hour, to review first) |
| Metric anomalies | `list_anomalies` (metrics that left their normal for the hour, to review first), `get_anomaly` (one anomaly with each occurrence against what is usual at that hour, the metric around it, what else happened on its host — alerts, Ansible runs, container deaths and starts, maintenance, reboots, other anomalies — and how earlier ones on the same metric were judged), `get_metric_normal` (the learned normal slot by slot, and the level that would be flagged), `get_anomaly_settings` (the review window and each floor) |
| Thresholds | `get_thresholds` (the warn and crit cutoffs CPU, memory, disk, load and temperature are graded by on the pages) |
| Automation | `list_playbooks`, `get_playbook`, `list_inventory`, `list_ansible_runs`, `get_ansible_run`, `wait_for_run`, `list_schedules` |
| Audit | `list_audit_events` |
| Timeline | `get_timeline` (everything that happened, newest first, in one list: alerts firing and clearing, playbook runs and what they changed, maintenance, reboots, deploys and crash-loops, reviewed anomalies, changes recorded by hand, changes to Steward's settings; by host, area or kind, and kept after the records it came from age out) |
| Tickets | `list_tickets` (known problems someone recorded, open first; by host, subject or area), `get_ticket` (one ticket: its description, the alert rules and anomalies it links, its resolution and its log). `list_alerts` and `list_anomalies` mark what an open ticket covers as `known_issue`; `get_overview`, `get_host`, `get_monitor` and `get_container` carry `open_tickets` |
| Docker plugin | `list_containers`, `get_container`, `list_swarm_services`, `search_container_logs`, `get_docker_disk_usage` |
| Host agent plugin | `list_agents` |
| Host agent (built in, always on) | `list_agents`, `list_maintenance_plans` (each plan's group, weekly slot, next run and latest run: how far it got, who it skipped and why), `get_maintenance_job` (a host's update jobs or one job: state, packages updated, whether it rebooted, why it failed), `get_host_senders` (who sent or received a host's network traffic over a window: its containers and NFS mounts ranked at the host's peak, and what none of them accounts for) |
| Traefik plugin | `list_routers` (each router graded as the Traefik page grades it, with the status codes behind a problem, what they mean and the swarm service behind it), `get_router` (one router's history, codes, normal and anomalies), `list_certificates` (served certificates, days left, the routers they cover), `get_traffic` (the access log's clients, countries and routers, and Traefik's own figures) |
| UniFi plugin | `get_network` (the WAN, clients, Wi-Fi networks, speed tests and devices by status), `list_devices`, `list_clients` (everyone seen, or the newest poll's top talkers), `list_network_events`, `list_alarms`, `get_network_config` (networks and VLANs, port forwards, firewall rules) |
| SNMP plugin | `list_snmp_devices` (each device's state, newest readings in their units and the readings it didn't return), `get_snmp_device` (one device's readings over a range). Never the community or v3 keys |
| Syslog plugin | `list_syslog_sources` (the devices sending syslog: added, offered or ignored, each with its rate, last heard and newest line), `search_syslog` (an added source's kept lines, or a host's, filtered by text, severity and worse, app and time, newest first), `list_syslog_patterns` (the phrases and severities counted, the metric each writes and the ready-made ones not added), `add_syslog_preset` (admin: add a ready-made one) |
| Prometheus plugin | `list_prometheus_targets` (each target's state and its newest scrape: series kept, dropped and over the cap), `get_prometheus_target` (a target's stored series by metric, with labels and newest values). Never the token, and a password in a URL is masked |
### Operator: run playbooks, change monitoring
@@ -88,12 +96,38 @@ Each role includes the tools of the roles above it in this list.
| Automation | `run_playbook`, `cancel_ansible_run`, `create_schedule`, `update_schedule`, `delete_schedule`, `update_group_members` (put hosts into an inventory group or take them out; a group alert rule follows within a minute) |
| Monitors | `create_monitor`, `update_monitor`, `set_monitor_enabled` |
| Alerts | `create_alert_rule`, `update_alert_rule`, `acknowledge_alert`, `send_test_alert`, `create_maintenance_window`, `end_maintenance_window` |
| Metric anomalies | `review_anomaly` (mark one normal, so its hours are learned, or real) |
| Metric anomalies | `review_anomaly` (mark one or several normal, so their hours are learned, or real, with a note saying why) |
| Timeline | `log_change` (record a change Steward can't see happen — a config edited by hand, a disk swapped — on the hosts it was done on, under an area; shown as by "<you> via MCP"), `update_change` and `delete_change` (correct or take back a recorded change: its author or an admin) |
| Tickets | `open_ticket` (a known problem, on its hosts and what it is about, linked to alert rules and anomalies; goes on the timeline), `update_ticket` (its fields and links), `comment_ticket`, `resolve_ticket` (with what fixed it, which goes on the timeline), `reopen_ticket` |
### Admin: destructive and remediation
`set_alert_action` (the playbook a rule runs when it fires), `delete_monitor`,
`delete_alert_rule`, `prune_docker`.
`set_anomaly_settings` (the anomaly review window and each normal's floor, as
Settings → Thresholds sets them), `set_thresholds` (the warn and crit cutoffs host
readings are graded by, as the same page sets them), `set_alert_action` (the playbook a rule runs when it fires), `delete_monitor`,
`delete_alert_rule`, `prune_docker`, `run_maintenance` (update a host now, rebooting
only if the update needs it, as the host page's Update now does).
`save_playbook` and `delete_playbook` write this instance's own playbooks into
`steward-local`, the same as Steward's editor (#5748). That is automation for
these hosts that isn't part of Steward itself; Steward's built-in playbooks are
changed in its repository, and a git source by its repo, so both are refused.
A save must parse as a list of plays or nothing is written; it won't overwrite
an existing playbook without `replace=true`; and it then runs
`ansible-playbook --syntax-check` (no host contacted) and says what that found.
The loop for building one is save → `run_playbook` in check mode on one target
→ `get_ansible_run` → fix and save again → apply with `check=false`. The audit
row records the size, never the content.
`list_backups`, `check_backup` and `make_backup` are Settings → Backups for an
agent (#5836), admin only like the page, since a backup holds every secret
Steward keeps. `list_backups` gives the schedule, the next run and the last 30
runs with the check each one got after it was sent (`bucket=true` also lists the
bucket, which an append-only key may refuse). `check_backup` fetches one and
compares it with what is in place, changing nothing. `make_backup` starts one in
the background and is audited. Restoring stays in the browser, behind its typed
confirmation. `get_overview` reports, for every role, a scheduled backup that
failed, is overdue, or has no bucket set up.
The client sees each tool's full contract: arguments, what an empty value
means, and what comes back. That contract is the tool's docstring, so read the
@@ -128,7 +162,12 @@ Every tool follows these conventions (`steward/mcp/tools/_shape.py`):
Core tools: add an `async def` to a module in `steward/mcp/tools/` and list it
in that module's `register()` with its minimum role and `read_only`. A plugin
exposes `register_mcp_tools(register)`, and optionally `mcp_overview(db)` to
contribute findings to `get_overview`. See
exposes `register_mcp_tools(register)`, optionally `mcp_overview(db)` to
contribute findings to `get_overview`, and optionally
`mcp_events(db, host_id, host_name, since, until)`: what it knows happened on a
host in a window, as a list of `steward.mcp.tools._events.event(...)`, which
`get_anomaly` shows beside an anomaly; for a resource with no host, optionally
`mcp_resource_events(db, source, resource, since, until)`. Core adds the
stretches Steward itself wasn't recording to every anomaly. See
[Writing a plugin](../plugins/writing-a-plugin.md). A tool that changes
anything calls `audit_mcp(...)` from `steward/mcp/tools/_audit.py`.
+55
View File
@@ -0,0 +1,55 @@
# Tickets
A ticket records a known problem that takes more than a moment to fix: a disk failing slowly, a service that keeps restarting, a backup that times out at night. It says what is wrong, what has been tried and, once resolved, what fixed it. Acknowledging an alert only quiets it. A ticket keeps the story, and while it is open the alerts and anomalies it covers read as a **known issue**.
Open the list from **Tickets** in the sidebar. A host's page, a monitor's page and a container's page each show a **Known issues** card with their open tickets. MCP clients use `list_tickets`, `get_ticket`, `open_ticket`, `update_ticket`, `comment_ticket`, `resolve_ticket` and `reopen_ticket`.
**Source:** `steward/tickets/` (`service.py` for every write, `routes.py` for the pages), `steward/mcp/tools/tickets.py`
**Tables:** `tickets`, `ticket_links`, `ticket_notes` (`steward/models/tickets.py`)
---
## What a ticket holds
| Field | |
|---|---|
| Number | `#12`, in order of opening |
| Title | What is wrong, one line (up to 200 characters) |
| Area | One of the timeline's areas (Settings → Timeline) |
| Hosts | The hosts it is on, by name; none for a problem not on any one host |
| Subject | What it is about when that is not a host as a whole: an alert rule's resource (`web-1:/`), a container (`web-1/nginx`), a monitor's name |
| Description | What is seen, since when, what has been tried |
| Links | Alert rules and metric anomalies it covers |
| Log | Opened, comments, edits (which fields changed, what was linked or unlinked), resolved, reopened, each with who and when |
A ticket is **open** or **resolved**. Resolving one needs a resolution: what fixed it, or why it no longer matters. Reopening clears it, with an optional reason.
## Opening one
- **From an alert:** **Open a ticket** on an active alert (Alerts page) fills in the title, area, hosts and subject from the rule, and links the rule.
- **From an anomaly:** **Open a ticket** on an anomaly to review, or one reviewed as real, does the same and links the anomaly.
- **From a page:** the **Known issues** card on a host, monitor or container opens one about it.
- **From scratch:** **Open a ticket** on the Tickets page.
- **Through MCP:** `open_ticket(title, area, description, hosts, subject, alert_rules, anomalies)`.
Opening, editing, commenting, resolving and reopening need an operator or admin. Viewers read everything.
## Known issues
An alert or anomaly is a known issue while an open ticket:
- links its alert rule (or the covering group rule it came from), or the anomaly;
- names its resource as its subject; or
- is about its host as a whole (no subject), when the resource is the host itself.
The Alerts page and the anomaly lists show a **Known issue** badge with the ticket. `list_alerts` and `list_anomalies` return the same as `known_issue`. `get_overview` lists up to ten open tickets in `open_tickets` and counts them all in `counts.tickets_open`. `get_host`, `get_monitor` and `get_container` carry their own `open_tickets`.
## On the timeline
Opening, resolving and reopening a ticket each write a timeline entry under the ticket's area, on its hosts and subject (`ticket.opened`, `ticket.resolved`, `ticket.reopened`, kind Ticket). The resolution is the resolved entry's note, so the timeline keeps what fixed it. Comments and edits stay in the ticket's own log.
Every step is audited (`ticket.opened`, `ticket.edited`, `ticket.commented`, `ticket.resolved`, `ticket.reopened`). Those audit rows stay off the timeline, which holds the ticket's own entries.
## Backups
Tickets are not in Steward's backups yet, the same as the timeline itself (`steward/backup/contents.py`, `EXCLUDED`).
+66
View File
@@ -0,0 +1,66 @@
# Timeline
The timeline is one list of everything that happened to the infrastructure, newest first: alerts firing and clearing, playbook runs and what they changed, maintenance runs and reboots, deploys and crash-loops, anomalies once reviewed, changes recorded by hand, and changes to Steward's own settings. Open it from **Timeline** in the sidebar, from a host's **History** card, or through the `get_timeline` MCP tool.
Each entry stands on its own. An alert event, an Ansible run, a maintenance job or an audit row is pruned after its own retention, but the entry for it stays. Its link then reads "Details no longer kept".
**Source:** `steward/timeline/`
**Table:** `timeline_entries` (`steward/models/timeline.py`)
---
## What is recorded
Every source writes its entry in the same transaction as its own record, through `steward/timeline/record.py`:
| Source | Written by | Entry types |
|---|---|---|
| Alert events | `core/alerts._add_event` | `alert.fired`, `alert.resolved`, `alert.acknowledged`, `alert.silent`, `alert.resumed` |
| Ansible runs, when they finish | `ansible/executor.start_run`, and the startup sweep of interrupted runs | `ansible.changed`, `ansible.unchanged`, `ansible.failed`, `ansible.check` |
| Audit rows | `core/audit.log_audit` | `steward.config`, `steward.access`, `steward.signin`, `steward.backup` |
| Metric anomalies: one entry each, whose type follows its review | `core/anomalies` (opened, ended, marked, lapsed) | `anomaly.to_review`, `anomaly.real`, `anomaly.normal`, `anomaly.lapsed` |
| Maintenance jobs, when they end (and again if the host comes back late) | host agent: `maintenance`, `cadence` | `maintenance.done`, `maintenance.failed` |
| A host away: a reboot, a crash, lost contact, the agent restarting; kept up to date until it ends | host agent: `downtime.record_events` | `host.rebooted`, `host.lost_contact`, `host.agent_restarted` |
| Container snapshots: a new image, a recreated container, a restart or death | Docker: `ingest.persist_host_docker` → `timeline.from_snapshot` | `docker.deployed`, `docker.crash_loop`, `docker.restarted` |
| A change recorded by hand, from the Timeline page, a host's History card or MCP `log_change` | `timeline/changes.py` | `change.recorded` |
| Tickets: opened, resolved (the resolution is the entry's note) and reopened | `tickets/service.py` | `ticket.opened`, `ticket.resolved`, `ticket.reopened` |
A source whose record changes after it is written keeps one entry for it (`record.record_or_update`): a downtime gets its length when it ends, an anomaly changes type when it is reviewed.
Docker's units are swarm services where a container has one, so a service's tasks, replaced on every deploy and crash, read as one subject. A swarm task moving to another host is not a deploy. A crash loop uses the same rule as the Docker pages: Settings → Thresholds' deaths within an hour. The death that reaches it opens one `docker.crash_loop` entry, and each later one extends it until the container has been quiet for an hour. The deaths before it stay as hidden `docker.restarted` entries.
Some audit actions are left to another writer, because the event itself is recorded there: starting a run, acknowledging an alert, starting maintenance, and recording a change by hand. A person's own preferences (time zone, email settings) are not recorded. `catalog.audit_type` holds that mapping.
On its first start, Steward fills the timeline from the last 90 days of records that predate it (`service.backfill`): core's alert events, finished runs and audit rows, and each loaded plugin's own through its `timeline_backfill(session, since)` (the host agent's maintenance jobs and downtime). It fills from each source once and records which in `timeline.filled`, so a plugin loaded later fills on the next daily turn. An entry for a record that is already covered is skipped. Docker has no fill: its events can't tell a deploy from a restart after the fact.
`get_anomaly`'s "what else was going on" reads the timeline for the host or resource, hidden types included, beside each occurrence.
## Recording a change by hand
Steward can't see every change happen: a config edited on a host, a disk swapped, a service moved. Whoever made it records it, with **Record a change** on the Timeline page or a host's History card, or the `log_change` MCP tool. It says what was done, the area it is about, when, the hosts it was done on (none for a change not on any one host), optionally what it was done to (a container, a file) and a longer detail.
An operator or admin can record one. Its author can edit or delete it from its **Edit** link, or with the `update_change` and `delete_change` MCP tools, and so can an admin. One recorded through MCP is shown as by "<user> via MCP" and is editable by that token's user. Recording, editing and deleting are audited (`timeline.change_recorded`, `…_edited`, `…_deleted`), and those audit rows stay off the timeline, which holds the change itself.
## Areas and kinds
An entry has a **kind**, which says how it came about: Change, Automation, Alert, Anomaly, Ticket, Observed (Steward saw it happen with no rule or person behind it: a reboot, a crash-loop) or Steward. Kinds are fixed in `catalog.KINDS`.
An entry also has an **area**, which says what part of the infrastructure it is about. The areas start as Service, Host OS, Hardware, Storage, Network, Security and Monitoring.
Each entry is written with an area of its own. An alert's area comes from what its rule watches (`catalog.area_of_metric`): drive health is Hardware, a mount is Storage, an HTTP monitor is Service. A change recorded by hand has the area its author picked. Other entries use their type's area.
## Settings → Timeline
| Setting | Key | Default |
|---|---|---|
| Areas, in order: rename, add, or remove one and move its entries to another | `timeline.areas`, `timeline.area_moved` | the seven above |
| Each type's area ("As recorded" keeps each entry's own) and whether it is shown | `timeline.types` | from the catalog |
| Keep for, in days (0 keeps everything) | `timeline.retention_days` | 0 |
These are applied when the timeline is read, never written onto an entry. A change re-sorts the whole history. A hidden type is still recorded, so showing it again brings its entries back.
Hidden by default: dry runs, playbook runs that changed nothing, sign-ins, single container restarts, the agent restarting, and anomalies not yet reviewed, accepted as normal or lapsed.
## Adding a type
A plugin registers its types in `setup()` with `catalog.register_type(key, label, kind, area, shown, about)`, and the records its entries point to with `catalog.register_ref(ref_type, model)`, so an entry whose record has aged out reads "Details no longer kept". It writes entries with `record.record(session, type=…, at=…, summary=…, hosts=…, ref=(ref_type, id))`, or `record.record_or_update` for a record that changes. It may add `timeline_backfill(session, since)` to its `__init__.py` for records that predate the timeline. Settings → Timeline lists the new type at once. `plugins/host_agent/timeline.py` and `plugins/docker/timeline.py` are worked examples.
+54
View File
@@ -0,0 +1,54 @@
# Home Assistant plugin
Keeps a Home Assistant instance running: shows whether backups are happening and whether the system has a problem, and installs Core, OS and add-on updates, on a click or a weekly schedule, each only after a fresh backup is confirmed. It looks after Home Assistant itself and doesn't read your devices. Each update goes on the timeline. Nothing is added to Home Assistant: you sign in, and Steward uses its standard API.
## Setting it up
1. In Home Assistant, turn on automatic backups (Settings → System → Backups).
2. Make a user for Steward (Settings → People → Add person), as an administrator if Steward should install updates.
3. On Steward's Home Assistant page, choose Connect, enter Home Assistant's address, and continue to its sign-in page. Sign in as that user.
Your browser and Steward both need to reach Home Assistant at that address. Home Assistant sends your browser back to Steward's Public base URL (Settings → General), so open Steward at that address.
## What Steward holds
**Steward stores a credential with the same access as the user who signed in.** Home Assistant has no narrower permission, so an administrator's credential can do anything that administrator can, including controlling every device. Steward only uses it to read status, take backups and install updates. It is stored encrypted.
We recommend a service account, a user made just for Steward, rather than your own. What Steward does then appears under its own name in Home Assistant's logs, so it's easy to tell apart from what people do.
Installing needs an administrator, because Home Assistant only lets administrators take backups. With any other user, Steward shows status only. Steward learns which it has by asking once after sign-in, and a user who isn't an administrator is refused that one request, which Home Assistant may show as a failed login notification.
Removing the connection ends the sign-in on Home Assistant. You can also end it yourself under Security on that user's profile, and Steward then stops asking until you sign in again. Home Assistant bans an address after a few refused requests, so Steward stops after one refusal until you sign in again or choose Try again.
## Installing an update
Install asks once, then Steward:
1. Asks for a backup and waits until Home Assistant's last successful backup time moves. If it doesn't, nothing is installed.
2. Asks for the update and follows it until the new version is installed, or until a deadline: 20 minutes for an add-on, 30 for Core, 45 for the OS.
One runs at a time, and it carries on if Steward restarts. Firmware and custom integrations are left to Home Assistant, and Supervisor updates itself.
An OS update takes effect only after the machine reboots, and Install leaves that to you. Steward records the update as done once Home Assistant reports the new version, which it does before the reboot. Home Assistant then raises a repair asking for the reboot, which appears under System problems until you do it.
## System problems
Each read also asks Home Assistant whether the system itself has a problem, and the page and the overview show what it says:
- open repairs, from Settings → System → Repairs
- the Supervisor calling the system unhealthy or unsupported
- less than 10% of the disk free
- starting in recovery mode, when its configuration didn't load
- an add-on set to start on its own that isn't running
Repairs need an administrator's sign-in. Disk space and add-ons come from the Supervisor, so an install without one doesn't report them, and the page says so.
## Scheduled updates
Set a day and time under Scheduled updates on the page. It's off until you do. At that time Steward installs what's waiting, add-ons first, then Core, then the OS, one at a time and each after its own confirmed backup. It starts nothing new more than 4 hours after the slot. The first failure ends the run and the rest wait, and the page, the overview and `update_failed` say so. A Core update restarts Home Assistant with nobody there to watch, and an update can change how automations behave.
An OS update installed by the schedule also reboots the machine, with nobody there to watch. It counts as done once the new version is running. If that hasn't happened 20 minutes after the reboot, for example because the machine didn't come back or fell back to the old version, the update is marked failed.
## Metrics
Under source `homeassistant`, resource `homeassistant`, read every 5 minutes: `is_up`, `updates_waiting`, `backup_age_hours`, `problems_open`, `disk_free_pct` and `update_failed`. Useful alert rules: `backup_age_hours` above 26 for a missed daily backup, `problems_open` above 0, and `update_failed` above 0. A rule's no-data warning covers Home Assistant going quiet.
+299 -88
View File
@@ -58,9 +58,13 @@ Give Steward a fleet-glance view of remote Linux hosts — current resource usag
### File layout on the target host
```
/usr/local/lib/steward-agent/agent.py # the script, target ~300 lines
/usr/local/lib/steward-agent/agent.py # the agent (runs as steward-agent)
/usr/local/lib/steward-agent/maint.py # the maintenance helper (runs as root, #5766)
/etc/steward-agent.conf # key=value config, 0640 root:steward-agent
/etc/systemd/system/steward-agent.service # unit file
/etc/systemd/system/steward-agent.service # the agent's unit
/etc/systemd/system/steward-maint.{path,service} # starts the helper on a request
/var/lib/steward-agent/requests/ # the one place the agent writes (0700 steward-agent)
/var/lib/steward-maint/results/ # the helper's outcomes (root, readable)
# dedicated system user: steward-agent
```
@@ -113,9 +117,291 @@ Python 3.8+ stdlib only. `urllib.request` for the POST, plus `json`, `os`, `shut
### Signals and lifecycle
- **SIGHUP** → re-read config file without restart (lets admins change interval/mounts cleanly).
- **SIGTERM** (from systemd) → finish current poll cycle → flush buffer one last time → exit.
- **SIGTERM** (from systemd) → finish current poll cycle → flush buffer one last time, with a `stopping` event saying whether the host is going down too (`systemctl is-system-running` says `stopping`) → exit.
- **Systemd restart policy** → `Restart=on-failure`, `RestartSec=10`.
### Mounts that don't answer (1.14.0, #5760)
Each mount is read in a daemon thread under one 5 s deadline. A hard-mounted NFS share whose server stops blocks `statvfs` in the kernel with no timeout; read inline, that froze the whole agent. A mount that misses the deadline is sent as `{"mount", "stalled": true}`, and isn't probed again while its last probe is still blocked. Steward counts them as `mounts_stalled`.
### NFS delay per mount (1.22.0, #5996)
`mounts_stalled` says a mount is stuck; it can't say a share was slow before it stuck, or how slow it was when it came back. Each sample also reads `/proc/self/mountstats`, the kernel's own cumulative count of every NFS request each mount has finished. Reading it never touches the share, so it answers while one is stuck. The agent sends each NFS mount's requests since the last sample as `nfs: [{mount, ops_per_sec, retrans, timeouts, rtt_ms, exe_ms, read_ms, write_ms}]`: `rtt_ms` is the network and server's round trip, `exe_ms` that plus the time queued on the host, which is what a program waits. The delays are averages over the requests that finished, and are left out when none did. A mount new since the last sample, or whose counters went back (remounted), waits for the next one.
**Bytes per mount (1.25.0, #6115).** Each op's line also counts the bytes sent and received on the wire, so each mount's row carries `tx_bps` and `rx_bps` too: the share's part of the host's network traffic, which belongs to no container. A large copy to the NAS shows here rather than as traffic nobody sent.
Steward stores them per mount (`nfs_*` on `<host>:<mount>`), and the slowest mount's `exe_ms` on the host as `nfs_exe_max_ms`, for a rule ("NFS slow": over 1000 ms for 3 checks) and for the host page's NFS chart beside `mounts_stalled`. Each mount's bar on the host page says what a request took and how many were sent again or timed out. A request is counted when it finishes, so a stuck share shows nothing until it comes back, then its long requests all at once.
### A container's memory is its processes' own (1.23.0, #6030)
Each running container sends `mem_used_bytes` and `mem_used_pct` (of its limit, or of the host's RAM when it has none) and, beside them, `mem_cache_bytes`. Used is anon plus shmem from the container's cgroup memory stats (v1: rss plus shmem). It is what the processes hold and can't give back without swap, and it counts Postgres's shared buffers, which live in shmem. Cache is the file cache charged to the container (v2's `file`, v1's `cache`, shmem taken out). The kernel keeps what the container read or wrote for as long as nothing else needs the memory, and gives it back when something does.
Before this, the agent sent Docker's working set (usage less `inactive_file`) as `mem_usage_bytes`. That figure still counts recently used file cache. On 2026-10-17 a file-heavy container with no memory limit read 191 GB that way, while its processes held 2.2 GB. The host's own figure (`MemTotal − MemAvailable`) already left cache out, so the two disagreed. The new names keep the two meanings apart in history and in learned normals.
### Every Docker read is bounded (1.15.0, #5764)
One Docker answer must arrive within 15 s and fit in 32 MiB, and one log answer within 5 s and 4 MiB. Past either limit the read is cut off, not waited on. A per-read timeout alone never ends a body that keeps trickling in. Container logs are read since each container's cursor, but only its newest 2,000 lines, all containers within 10 s. A container that writes more than that gets a `_steward` note saying older lines were skipped. A container whose answer is too big skips ahead to now. Before this, a 1.13.0 agent re-read a growing backlog every interval and went silent for a day at 2.1 GB. Each sample also carries the agent's own `agent` footprint (`rss_bytes`, `cpu_pct`), which Steward records as `agent_rss_bytes` and `agent_cpu_pct`.
### What the host has waiting (1.16.0, #5765)
Once an hour, and when the agent starts, a background thread asks the package manager what it would upgrade, so a slow apt never holds up a sample. On apt hosts it uses `/usr/lib/update-notifier/apt-check` (pending;security), or `apt list --upgradable` when that's missing. On dnf hosts it uses `dnf -C check-update`, `dnf -C updateinfo list --security`, and `needs-restarting -r` for whether to reboot; yum hosts the same through `yum -C` (1.21.0, #5803). On zypper hosts it uses `zypper --no-refresh list-updates` (`--dup` on Tumbleweed, which upgrades by distribution), `list-patches --category security` for the needed security patches (Tumbleweed has none to list), and `zypper needs-rebooting`. On pacman hosts it uses `checkupdates` from pacman-contrib, which install-deps.sh installs; pacman says nothing of security, and a reboot is required once the running kernel's `/usr/lib/modules/<release>` is gone, i.e. the kernel was upgraded under it. None of this needs root, and apart from `checkupdates` none of it needs the network: the commands read lists the system's own timers already fetched. `checkupdates` syncs a copy of the package lists into its own temporary directory, so it reaches the host's mirrors once an hour but never touches pacman's real database. Each command has 120 s. The last answer rides every sample as `updates`. Every sample also reads Debian's `/var/run/reboot-required` (and `.pkgs`) as `reboot`. Steward records `updates_pending`, `updates_security` and `reboot_required` (1/0) for rules, and keeps the newest on the registration. The host page's Agent card leads with "Reboot required since …", then "N updates waiting (M security)" or "Up to date", with when the check ran. `state["updates_due"] = True` asks for a check on the next sample; a maintenance job uses it once it has updated the host.
### A root helper for updates and reboots (1.17.0, #5766)
The agent can't update or reboot its host: it runs as `steward-agent` under
`NoNewPrivileges` and `ProtectSystem=strict`, so no sudo is possible, and that
is how it should stay. A separate helper, `maint.py`, runs as root, started by
systemd when the agent leaves it a request:
- The agent writes `<id>.json` = `{"id", "action", ["node"]}` into
`/var/lib/steward-agent/requests`, the one path its unit lets it write
(`ReadWritePaths=-…`; the `-` keeps an old host without the directory
starting). It writes a dot-file first and renames it, so the helper never
sees half a request.
- `steward-maint.path` (`PathExistsGlob=…/requests/*.json`) starts
`steward-maint.service`, a oneshot running `maint.py` as root.
- The helper opens each request with `O_NOFOLLOW`, wants a regular file under
4 KiB, and accepts only `check`, `update`, `reboot`, `drain` or `undrain`, an id of
letters, digits and dashes, and a node id in the swarm's own 25-character
form. It deletes every request, valid or not, and writes the outcome to
`/var/lib/steward-maint/results/<id>.json` (root's; kept a week). A request
whose id already has a result is not run again.
- **check** (1.24.0, #6098): what the update would upgrade, changing nothing
but the package lists: `apt-get update` then `apt-get -s dist-upgrade`;
`dnf`/`yum check-update`; `zypper list-updates` (`dup --dry-run` on
Tumbleweed); `pacman -Sy` then `-Qu`. It says whether Docker is among it
(`docker`, `docker-ce`, `docker.io`, `docker-engine`, `moby-engine`,
`containerd`, `containerd.io`: the packages whose install scripts restart
the daemon), and lists the running containers that aren't swarm tasks,
each with its restart policy (at most 40).
- **update**: `apt-get update` then `dist-upgrade` keeping existing config
files, with `NEEDRESTART_MODE=l` so needrestart only lists services rather
than restarting them. A package's own install script still restarts its
service: Docker's does, stopping every container on the host, which is
what the check is for;
`dnf -y upgrade --refresh` (or yum's); `zypper refresh` then
`zypper update` (`zypper dup` on Tumbleweed), counting zypper's 102/103
"reboot / restart needed" exits as success; or `pacman -Syu`
(#5804). The result says how many packages changed and whether a reboot is
now required.
- **reboot**: writes `rebooting`, syncs, `systemctl reboot`. Any request after
it waits for the next boot.
- **drain / undrain**: `docker node update --availability drain|active`, on
the given node or this host's own. Only a manager can; a worker's helper says
so in its result.
The agent reports each result's new state once, as a `maintenance` event with
its next post that gets through (results older than a day are not news). When
an update finishes it re-checks what's waiting straight away rather than at
the next hourly check. Its metadata says `maint_helper: true` once the path
unit is installed and it can write its request directory; the host's Agent
panel shows that under Manage agent. **Update agent** installs the helper on a
host that has an older agent.
So a stolen agent token can make its host update, reboot or drain, and nothing
else. Steward handing a host a job and following it through the reboot is
#5767 (below); the cadence that does it one host at a time is #5768.
### A maintenance job, followed through the reboot (1.18.0, #5767)
Steward keeps one `host_maintenance_jobs` row per job and moves it on only from
what the agents post, so a restart of Steward, or a reboot of the host Steward
runs on, loses nothing. `plugins/host_agent/maintenance.py` has the states and
transitions (model-free, unit-tested) and the database side.
```
queued → [checking →] [draining → settling →] updating
→ [draining → settling →] [rebooting →] [undraining →] [recovering →] done
any state → failed (a step's result said so) | timed_out (its deadline passed)
draining / settling / updating / rebooting, once drained → undraining → failed | timed_out
```
**A job never ends leaving its drain behind (#6387).** On 2026-10-25 a job
drained VDNT-Docker02, which ran Traefik and Steward. Its agent's result
reaches Steward through Traefik, and Traefik took 11.5 minutes to start on
Docker03 (its image had to be pulled). The job gave up at the 10-minute drain
deadline and finished `timed_out` where it stood, leaving the node drained
for hours. Now a job that fails or times out while its node may be drained
(the drain was handed out, or went through and wasn't undone) goes to
**undraining** first, with the outcome kept in `ends_as`, and finishes as that
once the node is active again. If the undrain goes wrong too, `detail` says
both. The drain deadline is 30 minutes, so a result held up by the proxy
moving still lands.
**Docker upgraded, drained first (1.24.0, #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: every container on the node stopped, and
portal_app was down until it was redeployed by hand. A job for an agent of
1.24.0 or later now starts with a **check**. When the update upgrades Docker,
a swarm node is drained before it; otherwise it is drained only before a
reboot, and a node is drained at most once a job. After a drain, **settling**
gives the node's tasks 5 minutes (`DRAIN_SETTLE`) to move before the update
or reboot goes ahead. The containers the check found running outside the
swarm are kept on the job (`checked`); once the work is over, **recovering**
waits up to 10 minutes (`RECOVER_LIMIT`) for each to be seen running again
in the Docker plugin's stored containers, and fails the job naming any that
aren't, marking those with no restart policy. settling and recovering are
Steward's own waits, moved on by the minute tick. An older agent gets no
check, and its job runs as before.
- **Hand-off.** Steward's answer to an ingest post carries
`"maintenance": [{"id", "action", ["node"]}]`, the step that host's agent
should carry out now. It is repeated on every post until a result for that
step id arrives; the agent leaves each id for the helper once
(`take_maintenance`), and the helper refuses an id it already has a result
for, so a repeat, or a re-hand after the agent restarts, never runs twice.
A step id is `<job id>-<action>`.
- **Results** are the `maintenance` events of #5766, matched to the job by step
id, so a drain reported by a manager reaches the job of the node it drained.
- **The reboot completes** when the host's agent sends `started` with a
`boot_at` after the reboot was handed out (60 s of clock slack). An agent
restart on a host that stayed up has an old `boot_at` and changes nothing.
- **Reboot only when required**: the update's result says whether a reboot is
needed; a job made with "never reboot" stops after the update.
- **Swarm nodes** are drained first and made active after (see above for
when). The node is found
by hostname in the Docker plugin's swarm nodes; a manager drains itself, a
worker is drained by a manager whose agent has the helper. With no such
manager the job is refused rather than rebooting an undrained node.
- **Deadlines** (`DEADLINES`): queued 1 h, checking 30 min, draining 30 min,
settling 15 min, updating 90 min, rebooting 30 min, undraining 10 min,
recovering 15 min. A minute tick (`host_agent_maintenance`) times out a job
past its deadline.
- **A maintenance window** scoped to the host's agent metrics
(`host_agent/<host>`) opens when the job starts, lasts as long as the rest of
the job could take, and ends when it does.
- Ingest applies all of this in a savepoint of its own: a job going wrong is
logged and never costs the host its samples.
On the host's Agent panel: a running job leads what the host has waiting, with
since-when; a finished one says what it did ("Done: 12 packages updated,
rebooted"). Under Manage agent, **Update now** (with "never reboot") starts a
job, or the panel says why one can't start.
### A maintenance cadence: a group, one host at a time (#5768)
`plugins/host_agent/cadence.py`, driven by the same minute tick as the jobs.
A **plan** (`host_maintenance_plans`) names an inventory group, a weekday, a
time in a time zone (the creator's, by default), a window and a reboot policy:
`if_required` (the default), `always` or `never`. The page is **Hosts →
Maintenance** (`/plugins/host_agent/maintenance`).
- **When.** A plan's slot is the latest weekday-and-time at or before now, in
its zone. A slot fires once (`last_slot_at`) and only while its window is
open, so a Steward that was down over the slot starts late, not a day late.
A new or resumed plan starts from the next slot. **Run now** starts a run
without touching the schedule (the Ansible ruling on Run now).
- **A run** (`host_maintenance_runs`) fixes its hosts at the start, by name:
the group's hosts through their linked Ansible targets. A host with no agent,
or no maintenance helper, is skipped and listed.
- **One at a time.** Each host gets a #5767 job (`run_id` on the job). The next
host starts only when the last is **back in service**: its agent posting in
the last 3 min and, for a swarm node, the node `ready`/`active` and every
replicated service at its replicas, judged only from swarm reports made
after the job finished. A host not back within 30 min fails the run, and
its job is marked failed with the reason.
- **Stops.** A failed or timed-out job fails the run. No host is started after
the window; the run then stops and says how many weren't reached.
- **Alerting** is an ordinary alert rule on `host_agent/<host>/maintenance_failed`:
1 when the host's latest job (in 30 days) failed, timed out or didn't come
back into service, 0 once a later one succeeds.
- The page leads with a running run and its hosts, then failed runs from the
last week, then each plan with its schedule, next slot and last five runs.
Each host's Agent panel shows its last maintenance job.
#### Spreading swarm services back out (1.26.0, `rebalance.py`)
A run drains each swarm node in turn, and the swarm never moves a task back
by itself, so every run used to end with the services on the node drained
first. A plan with **Spread swarm services back out** (on by default) ends a
run that drained a node with a run-level step, once its last host is back in
service:
- **What moves.** While the fullest active node has two or more tasks than
the emptiest, a replicated service whose running tasks are all on the
fullest moves; one with several replicas there goes first. A service is
left where it is, and the run says why, when the plan keeps it in place
(**Keep in place**, service names), when it is below its replicas, when its
image isn't pinned to a digest, or when the emptier node doesn't hold that
digest. Only what kept the nodes from evening out is reported.
- **The image check.** Agent 1.26.0 reports each service's pinned digest and
every `repo@sha256:…` its host holds (from `/system/df`, every 10 min; a
list older than 30 min isn't trusted). A service moves only to a node that
already holds its exact image, so a pruned image or a digest the registry
has since deleted can't strand it.
- **Who moves them.** A swarm manager's helper, through a fifth step action,
`rebalance`, carrying the service names (each checked against Docker's name
alphabet, at most 48). It force-updates each in turn (`docker service update
--force --detach=false`, spec unchanged, no registry lookup), skipping one a
constraint or preference places, gives each 5 min to settle and starts no
move after 40 min. The swarm starts each on its emptiest node. A stolen
manager's agent token can therefore also restart named services in place.
- **How it ends.** The run stays `running` until the result arrives, then
ends `done` with what moved, or `failed` if a move didn't settle or the
manager didn't answer within 60 min. A window over the `docker` figures
covers the restarts, and the step goes on the timeline
(`maintenance.rebalanced`). With no manager on 1.26.0, or nothing to move,
the run ends `done` and says so.
### The swarm's health, alertable (1.19.0, #5788)
Every manager's agent reports the whole swarm. From each report the docker plugin (`plugins/docker/swarm_health.py`) records three swarm-wide figures on resource `swarm`, source `docker`:
- `swarm_services_short`: services running fewer tasks than they want. A service scaled to 0 is never short.
- `swarm_nodes_down`: nodes not `ready`.
- `swarm_managers_unreachable`: managers the swarm reports as not `reachable`. Agent 1.19.0 sends `ManagerStatus.Reachability`; before that, a manager whose status isn't `ready` counts.
Drained nodes are left out of the last two. A node is drained on purpose (VDNT-Util01 always, any node during its maintenance), so its going away is expected, and its host's own "Agent offline" rule still covers it.
The figures are recorded at most once every 30 s, whichever manager sent them, so three managers make one series and losing one doesn't silence it.
A maintenance job on a swarm node opens a second window over `docker` / `swarm` beside its host's (`maintenance._swarm_window`). An alert the drain or reboot causes then waits for the job, and one still firing when the window ends is sent then.
### Drive health (1.20.0, #5789; 1.20.1, #5806)
Reading SMART needs root, so the root helper does it, as the one action on its fixed list that only reads: `drives`. Hourly, and at start, the agent leaves a `drives` request. The helper runs `smartctl --scan-open` to find the drives itself (the request names none), reads each with `smartctl --json -a -d <type> <dev>`, and writes a summary to `/var/lib/steward-maint/drives.json` (0644). It writes no job result, because a drive read is not a maintenance job. The agent's newest copy rides every sample, as updates do.
smartctl ≥ 7.0 answers in JSON, the same on every distribution and for SATA, SAS and NVMe drives. A missing or older smartctl is reported (`smartctl not installed`, `… too old`) and its text output is never parsed. The helper reports figures only:
- `passed` and `temp_c` for every drive;
- `critical_warning`, `life_used_pct`, `media_errors` and spare % against threshold for NVMe;
- reallocated / pending / offline-uncorrectable sectors for ATA, and life used where the drive reports it;
- grown defects for SCSI.
A drive with no SMART (a virtual disk, a bridge that hides it) reads `available: false`, and one smartctl couldn't open carries `error`. Disks behind a RAID controller share one path (`/dev/bus/0`) and differ by the slot in their type (`megaraid,3`), so the slot is part of the name: `bus0-megaraid3` (1.20.1, #5806). Steward also gives a repeated name an ordinal (`0#2`), so two drives never share a series.
Steward judges the figures in `drives.py`, so the judgment can change without an agent update:
- **failing**: the drive's self-assessment fails, an NVMe critical warning is raised, or spare blocks fall under threshold;
- **worn**: at least 90% of its rated life is used;
- **watch**: it has bad sectors or media errors but otherwise passes. Old disks carry a few for years, so a count alone isn't a failure.
The figures become metrics on `<host>:drive:<device>`: `drive_failing`, `drive_life_used_pct`, `drive_bad_sectors` and `drive_temp_c`. The host gets `drives_failing` and `drive_life_used_max_pct` for covering rules; they are recorded only when at least one drive was read, so a VM's virtual disks don't claim "0 failing". The registration keeps the newest report (`drive_health`), and the host page lists it under **Drive health**, what needs attention first.
**Installing smartctl on any common distribution.** `files/install-deps.sh` installs smartmontools with whichever package manager the host has: apt (without recommends, which would pull in a mail server), dnf, yum, zypper, pacman or apk. install.sh and the install / provision / update playbooks all fetch and run that one copy. A package that can't be installed is reported and never fails the install.
### What happened to the host (1.14.0, #5756)
A post may carry `events`, said once each, with the next post that gets through:
- `started` — the agent starting, with `boot_at` (from `/proc/uptime`).
- `stopping` — on SIGTERM, with `host_shutdown`: true, false, or null when systemd can't say.
- `lost_contact` — `from` the first post that got no answer (or a proxy's 5xx) `to` the post carrying it.
Steward turns them into the host's downtime (`host_downtime`): **reboot** (a stop, then a boot after it), **agent_stop** (the host stayed up), **unclean** (a boot after the agent was last heard that nobody announced: power loss or a crash) and **lost_contact**. An announced stop explains the silence for `reboot_grace_seconds` (Settings → Plugins → host_agent, 10 minutes): `seconds_since_seen` reads 0 until then, and the host's other rules go quiet with it, as when it is offline. Past the grace, agent offline fires. The events live in memory, so a span the agent saw is lost if it restarts mid-outage; the boot time still reads the restart as unclean.
### Which temperature sensors are thermometers (#5782, #5783)
A board's spare thermistor inputs (a Nuvoton's SYSTIN, AUXTIN*) are often wired to nothing, and the kernel reports them anyway, at whatever the open input reads. The host's temperature (`temp_c_max`) leaves a sensor out when:
- **its value is impossible** (at or below 0 °C, or at or above 125 °C; `counts_as_hottest`), or
- **it doesn't behave like a thermometer.** Once an hour, `sensors.refresh` judges each sensor against the host's CPU (Intel `Package id N`, AMD `Tctl`/`Tdie`/`TccdN`, else `PECI Agent N`), using the last 7 days of hourly averages. It needs at least 72 shared hours and a CPU that moved at least 10 °C. A sensor is ruled out when it **falls as the CPU warms** (Pearson r ≤ −0.5, the shape of an open input reading the chip's own reference) or **holds within 0.5 °C** while the CPU moved. A wired board or drive sensor that barely follows the CPU (r near 0) stays in.
The verdicts are kept in memory and rebuilt at startup, so ingest and the host page read them without a query. The host page lists the sensors left out under "Not counted", each with its reason. A host with no CPU reading, or too little history, falls back to the value rule alone.
`temp_c_max` can drive an alert rule (#5787), e.g. "Host running hot": all hosts, over 85 °C for 10 consecutive checks. So can each sensor's `temp_c`, and the per-interface and per-disk throughput. `tests/core/test_metric_catalog.py` holds every metric ingest writes to the alert catalog, except the few `NOT_ALERTABLE` names in `plugins/host_agent/__init__.py`.
### Logging
To stderr only (systemd captures to journal). No file logging, no log rotation. Levels:
@@ -130,7 +416,7 @@ The agent's self-reported hostname in the payload is advisory. The real identity
### Failure behavior
- POST fails → push sample to ring buffer (max 20), exponential backoff `30s → 60s → 120s → 300s cap`, resets on first success.
- On next success → flush all buffered samples in a single batched POST.
- On next success → flush the buffer oldest first, five samples a POST (`MAX_SAMPLES_PER_POST`). The new sample queues behind the backlog, so Steward always receives a host's samples in the order they were taken (1.22.0, #5995): a rule counting checks in a row then sees a stall's run of breaching checks unbroken. Before, each post carried the newest sample too, and after the 2026-10-17 NFS stall its healthy reading ended every post's run, so "Mount not answering" (10 in a row) never fired.
- Ring buffer full → drop oldest, keep newest. Logged at DEBUG (expected during extended outages).
- Malformed payload rejection (400) → drop the sample (don't re-buffer, it'll just be rejected again). Indicates agent/server version skew.
- Token rejection (401) → log ERROR with remediation hint, continue retry loop at backoff cadence. Admin fixes via UI + conf file edit; no restart needed.
@@ -264,7 +550,7 @@ Content-Type: application/json
### Design points
- **`samples` is always a list**, even for a single sample. This is how the ring buffer drains after a failure — agent POSTs `[current, ...buffered]` in a single request. One-path server code.
- **`samples` is always a list**, even for a single sample. This is how the ring buffer drains after a failure — agent POSTs the oldest buffered samples, with the current one only once the backlog is through. One-path server code.
- **`ts` is agent-reported, ISO-8601 UTC.** Server trusts it for `PluginMetric.recorded_at`. Host clocks drift but NTP is reliable enough for our purposes.
- **If `|ts - now| > 5 minutes`**, server logs a WARN but still accepts. Don't reject, don't lose data over clock skew.
- **`metadata` is sent on every POST**, not just on change. Server-side diff detects actual changes and only writes on change. Cost per POST is one dict — negligible. Benefit: server can cleanly detect agent restarts.
@@ -340,92 +626,17 @@ sudo sh install.sh
### Rendered script structure (Jinja template `install.sh.j2`)
```sh
#!/bin/sh
# Steward host agent installer
# Generated for: {{ host_name }} ({{ host_address }})
# Steward URL: {{ url }}
set -e
STEWARD_URL="{{ url }}"
AGENT_TOKEN="{{ token }}"
AGENT_VERSION="{{ agent_version }}"
AGENT_USER="steward-agent"
AGENT_DIR="/usr/local/lib/steward-agent"
CONF_FILE="/etc/steward-agent.conf"
UNIT_FILE="/etc/systemd/system/steward-agent.service"
# ── preflight ────────────────────────────────────────────────────────────────
[ "$(id -u)" = "0" ] || { echo "must run as root (use sudo)"; exit 1; }
command -v systemctl >/dev/null 2>&1 || { echo "systemd not found — unsupported init system"; exit 1; }
command -v python3 >/dev/null 2>&1 || { echo "python3 not found — install python3 first"; exit 1; }
# Handle --uninstall
if [ "${1:-}" = "--uninstall" ]; then
systemctl disable --now steward-agent.service 2>/dev/null || true
rm -f "$UNIT_FILE" "$CONF_FILE"
rm -rf "$AGENT_DIR"
systemctl daemon-reload
# keep the system user — removing it risks orphaned files elsewhere
echo "uninstalled"
exit 0
fi
# ── create system user ───────────────────────────────────────────────────────
if ! id "$AGENT_USER" >/dev/null 2>&1; then
useradd --system --no-create-home --shell /usr/sbin/nologin "$AGENT_USER"
fi
# ── drop the agent file ──────────────────────────────────────────────────────
mkdir -p "$AGENT_DIR"
curl -sSL "${STEWARD_URL}/plugins/host_agent/agent.py" -o "$AGENT_DIR/agent.py"
chmod 0755 "$AGENT_DIR/agent.py"
chown root:root "$AGENT_DIR/agent.py"
# ── write config ─────────────────────────────────────────────────────────────
cat > "$CONF_FILE" <<EOF
url = $STEWARD_URL
token = $AGENT_TOKEN
interval_seconds = 30
EOF
chown "root:$AGENT_USER" "$CONF_FILE"
chmod 0640 "$CONF_FILE"
# ── write systemd unit ───────────────────────────────────────────────────────
cat > "$UNIT_FILE" <<EOF
[Unit]
Description=Steward host agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$AGENT_USER
ExecStart=/usr/bin/python3 $AGENT_DIR/agent.py
Restart=on-failure
RestartSec=10
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadOnlyPaths=/proc /sys
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now steward-agent.service
echo
echo "Steward host agent $AGENT_VERSION installed and running."
echo "Check status: systemctl status steward-agent"
echo "Logs: journalctl -u steward-agent -f"
```
`plugins/host_agent/templates/install.sh.j2` is the script; it is not copied
here, because a copy drifts. It checks it runs as root with systemd and
python3, handles `--uninstall`, creates the `steward-agent` user, fetches
`agent.py`, writes the config, then fetches the maintenance helper and the
three units from `/plugins/host_agent/files/` (the same files the playbooks
install), creates the maintenance directories, and enables
`steward-maint.path` and the agent.
### Design points
- **One copy of every file a host installs.** The units and the maintenance helper live in `plugins/host_agent/files/` and are served at `/plugins/host_agent/files/<name>` (a fixed list; anything else is 404). install.sh and the install, provision and update playbooks all fetch them, so the unit can't differ between a host installed by hand and one installed by Ansible (#5766).
- **Agent binary served by Steward itself.** `GET /plugins/host_agent/agent.py` serves the script bundled with the plugin, so version drift between install-script and agent is impossible — re-running the installer picks up whatever agent the currently-running Steward ships.
- **Systemd hardening is cheap and correct.** `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`, `ReadOnlyPaths=/proc /sys`. The agent only needs read access to `/proc`, `/sys`, and mount points; denying everything else narrows blast radius.
- **Uninstall is a first-class flag**, not a separate script. Same one-liner with `--uninstall` appended.
+2
View File
@@ -46,6 +46,8 @@ Each target also records four metrics about itself, under its own name:
A value's unit is read from Prometheus naming: `_bytes` as bytes, `_seconds` as a span, `_timestamp_seconds` as a moment ("3h ago"), `_per_sec` as a rate.
**A refused token stops the scraping.** When an endpoint answers 401 or 403, Steward stops asking and says so on the target's page and in the overview, because asking again won't change the answer and some services, Home Assistant among them, ban an address after a few refusals. Saving the target, usually with a new token, or pausing and resuming it, starts it again. Any other failure is retried at the next scrape.
A **state set** is one series per possible state, the current one 1 and the rest 0, such as `fabledarchives_backup_target_state{target, state}`. The target page shows it as one row per target (or job), naming the current state; the other states are listed on hover. A metric is read as a state set when its name ends in `_state` and every series has a `state` label reading 0 or 1. The OpenMetrics form, where the label is named after the metric (`foo{foo="a"} 1`), counts too. Each state is still stored as its own series, so an alert rule can watch one state.
**Only what the endpoint reports now is current.** When a scrape succeeds, any series it no longer has loses its newest value. That happens when the endpoint stopped reporting it, for example an old `build_info` after an upgrade, or when a filter or the cap now leaves it out. Its history stays on the chart until retention removes it. A failed scrape leaves the newest values as they were.
+88
View File
@@ -0,0 +1,88 @@
# Syslog plugin
Receives syslog from NASes, routers and other devices over UDP and TCP
(RFC 3164 and RFC 5424). The plugin keeps the lines of the sources you add and
puts them beside Steward's other readings.
## What it does
- **Sources.** A device that starts sending is *offered* on the Syslog page.
Its lines are counted there but not stored until an admin adds it. Ignored
senders are still counted. Each added source keeps its lines in a ring:
the age limit (`retention_days`, 14 by default) and the size cap
(`max_bytes_per_source`) are set in Settings → Plugins → Syslog.
- **Reading.** Each source has its own page, with lines filtered by severity,
app, text and time. A host's page and an SNMP device's page show the newest
lines of the sources mapped to them. A source maps to a host by its binding,
or, when it has none, by sharing the host's or device's address.
- **Patterns.** Syslog → Patterns counts each source's lines matching some
phrases (case-insensitive) or a severity over a window. The count is written
every minute as the metric `lines_matching_<key>` under source module
`syslog`, zeros included, so an ordinary alert rule fires on the first match
and clears once the window has passed. Ready-made patterns cover disk I/O
errors, disk resets, NFS server trouble, filesystem errors, degraded arrays
and "errors and worse". "Alert on it" opens the rule form aimed at the count.
- **Beside the readings.** `get_anomaly` includes the warnings and worse logged
by the anomaly's host or SNMP device. Each anomaly row has "Syslog around
it". Host metrics charts and SNMP device charts mark each error with a
dashed red line.
- **MCP.** `list_syslog_sources`, `search_syslog`, `list_syslog_patterns` and `add_syslog_preset`.
## Deploying
Steward runs as an unprivileged user, so it listens on **5514** (UDP and TCP),
not 514. Publish that port from the container:
```yaml
# docker compose
ports:
- "5514:5514/udp"
- "5514:5514/tcp"
```
Under **Docker Swarm**, either publishing mode works.
**Ingress** (Swarm's default) lets Steward move between nodes. Devices send to
any node's address, or to an address shared over the nodes (a keepalived VIP):
```yaml
ports:
- { target: 5514, published: 5514, protocol: udp }
- { target: 5514, published: 5514, protocol: tcp }
```
Through ingress every line arrives from Docker's own address on the ingress
network, so the address no longer tells the devices apart. For such an address
Steward keys the source by the hostname each line carries instead (#6096): each
device is its own source. A device must put its hostname in its lines (NASes
and routers do); lines with none at Docker's address all fold into one source,
and the page says so. Docker's address changes when Steward's task moves to
another node; a named source stays the same source, added or not, and takes the
new address.
Such a source's own address, which places its lines on its SNMP device's and
host's pages (chart marks, the lines around an anomaly), is found by name: the
SNMP device whose reported name (sysName, asked with every poll) or given name
matches, else the host of that name. Where nothing matches, set **Device
address** on the source's edit page; a value set there always wins.
**Host mode** keeps each device's own address, but only the node running the
task answers, so devices must be pointed at that node:
```yaml
ports:
- { target: 5514, published: 5514, protocol: udp, mode: host }
- { target: 5514, published: 5514, protocol: tcp, mode: host }
```
To let devices use 514 instead, publish `514:5514` instead of `5514:5514`.
## Pointing a device at it
The Syslog page's "Point a device here" section gives the address and port,
and where the setting is on common devices (Synology, ASUSTOR, QNAP, TrueNAS,
OpenWrt, UniFi, rsyslog). The address is the plugin's `send_to` setting
(Settings → Plugins → Syslog): Steward's web address is often a name only its
proxy answers, so it isn't guessed. Both formats are read, BSD (RFC 3164) and
IETF (RFC 5424); choose IETF where it is offered, as its lines carry their time
zone. ASUSTOR's ADM offers no choice and sends BSD.
+21 -3
View File
@@ -14,13 +14,26 @@ On each scrape:
Request rates and error rates are computed as deltas between the current and previous scrape, divided by elapsed time. Latency percentiles (p50, p95, p99) are approximated via linear interpolation over histogram buckets — these are estimates, not exact percentiles. They cover the responses since the previous scrape (each bucket's rise, summed over every status code and method), not every response since Traefik started. A scrape in which a router answered nothing records no latency, shown as "—"; one past the last bucket (5 s by default) reads as that bucket's edge.
When the metrics URL can reach more than one Traefik (a swarm service's replicas behind the routing mesh or its VIP, a Kubernetes Service's pods, a keepalived pair after a failover, or the same Traefik after a restart), each process keeps its own counters. Steward tells them apart by `process_start_time_seconds` and compares each scrape only with the same process's previous one. The first scrape from a process it hasn't seen in the last hour sets that process's baseline and counts nothing. When `metrics_url`'s host name resolves to several addresses, Steward scrapes each one and adds the replicas' requests, bytes and latency buckets together, so the figures are the whole proxy's. Each replica still counts only against its own last scrape; one that doesn't answer is left out with a warning in the log. Certificates are listed once however many replicas serve them; Traefik's own figures add open connections and memory and show the newest reload.
Through a name with one address (a swarm service's VIP, a Kubernetes ClusterIP, a load balancer) you get whichever replica answered, which can be one that serves no traffic at all. Point `metrics_url` at a name that lists every replica instead:
| Where Traefik runs | `metrics_url` |
|---|---|
| One container | its address, e.g. `http://traefik:8080/metrics` |
| Docker Swarm service (Steward on a network it shares with Traefik) | `http://tasks.<service>:8080/metrics`, e.g. `http://tasks.traefik_router:8080/metrics` |
| Kubernetes | a headless Service's name |
| A pair behind a keepalived VIP | a name that resolves to both nodes |
`api_url` can stay on one address: every replica serves the same configuration.
---
## Configuration
| Key | Default | Description |
|---|---|---|
| `metrics_url` | `http://localhost:8080/metrics` | Traefik Prometheus endpoint |
| `metrics_url` | `http://localhost:8080/metrics` | Traefik Prometheus endpoint. A host name with several addresses is scraped address by address and added up (see above) |
| `scrape_interval_seconds` | `60` | How often to scrape |
Enable the plugin by setting `enabled: true` in the app settings (Settings UI or directly in `app_settings` DB table under key `plugin.traefik`).
@@ -84,12 +97,17 @@ The full Traefik page at `/plugins/traefik/` reads top to bottom, for the time r
1. **The verdict.** It names the routers that need a look, says why, and says when the problem started ("api, 3.0% of responses are 5xx, started 2h ago"). When a router has been a problem for the whole range, it says so instead. Otherwise it reads "All N routers healthy".
2. **The request chart.** Requests per second across all routers and the share answered 5xx, the same chart as the dashboard widget.
3. **A card for each router that needs a look:** why, since when, the 4xx and 5xx status codes it answered in the last 15 minutes, most first ("404 ×1,812, 502 ×40"), and its request rate, bandwidth, p95/p99 latency and 4xx/5xx shares, each with its history. The code is what separates scanners probing for pages (404), a login wall (401/403) and an app that isn't answering (502/503/504). Steward records codes from the version that added them on; until then a card says they aren't recorded yet.
Each card then says what its problem usually means and what to do (`plugins/traefik/meaning.py`, #5712):
- **Server errors (5xx)** are almost always real. Mostly 500: the app is failing requests. Mostly 502/503/504: Traefik couldn't reach the app, or it timed out.
- **Client errors (4xx)** are usually traffic rather than a fault. Mostly 404: scanners and bots. 401/403: a login wall. 429: rate limiting. Mostly 499 is the exception: clients giving up on a slow app.
- **Slow responses** compare p95 with p50. When p95 is 4× p50 or more, a few requests are slow (one endpoint or a heavy page). When p50 is close behind, the whole app is slow.
- **Next:** a fault links to the swarm service behind the router (its tasks and logs) when Steward can match the name ("codex@swarm" → `codex_app`, "radarr@swarm" → `arr_radarr`), otherwise to Docker → Swarm. Traffic links to Settings → Thresholds.
4. **Traefik's own figures:** when its config last reloaded, open connections, memory.
5. **The healthy routers,** folded into one table, then the routers Traefik no longer reports, then TLS certificates by soonest expiry, then Traffic Origin when the access log is on.
5. **The healthy routers,** folded into one table, then the routers Traefik no longer reports, then TLS certificates by soonest expiry, then Traffic Origin when the access log is on. A certificate Traefik has renewed is listed as old, not by its expiry, though a Traefik that hasn't restarted since keeps reporting it: one whose every name a later-expiring certificate covers.
A router is graded on its last 15 minutes against Settings → Thresholds. When a problem started is read from the router's history over the range.
Once a router has 5 days of history, its 4xx share and p95 latency are judged against its own normal for that hour (see [Normals and anomalies](../architecture.md)) instead of the fixed cutoffs. One over the cutoff but within its normal is healthy, and the healthy table says it is usual for that router (an error-pages service that answers 4xx all day, a login wall's steady 401s). One out of its normal is a warning that says what is usual, and a metric anomaly to review; the Traefik page lists the routers' anomalies of the last 30 days. How far out counts is set on Settings → Thresholds → Normals (2 points of 4xx share, 50 ms of p95 by default). A 4xx share over fewer requests in the 15 minutes than Settings → Thresholds allows (30 by default) isn't judged at all. The 5xx share is a failure and always keeps its fixed cutoff. Latency is learned only from #5755's per-scrape figures, so its normal arrives 5 days after that release.
Once a router has 5 days of history, its 4xx share and p95 latency are judged against its own normal for that hour (see [Normals and anomalies](../architecture.md)) instead of the fixed cutoffs. One over the cutoff but within its normal is healthy, and the healthy table says it is usual for that router (an error-pages service that answers 4xx all day, a login wall's steady 401s). One out of its normal is a warning that says what is usual, and a metric anomaly to review; the Traefik page lists the routers' anomalies of the last 30 days. How far out counts is set on Settings → Thresholds → Normals (2 points of 4xx share, 50 ms of p95 by default). A 4xx or 5xx share over fewer requests in the 15 minutes than Settings → Thresholds allows (30 by default) isn't judged at all: two 500s of sixteen requests would otherwise read 12% and critical. A 5xx share past its cutoff is failing only once the router answered at least 3 server errors in those 15 minutes (Settings → Thresholds, "Fewest server errors to fail"): one 502 among 70 requests is 1.4%, but it is one error. The 5xx share is a failure and keeps its fixed cutoff whatever the router's history. Latency is learned only from #5755's per-scrape figures, so its normal arrives 5 days after that release.
---
+19
View File
@@ -391,6 +391,25 @@ async def mcp_overview(db) -> list[dict]:
- `severity` is `"critical"` (something is down or about to be) or `"warning"` (degraded, or heading that way). Report problems only; a healthy plugin returns `[]`.
- It is called only while the plugin is enabled, with its own session. If it raises, the overview still returns, and names the plugin under `unavailable`.
### What happened on a host
`get_anomaly` shows, beside a metric anomaly, what else happened on its host around each occurrence. A plugin that records events on a host adds them by exporting an optional `mcp_events(db, host_id, host_name, since, until)` that returns events built with `steward.mcp.tools._events.event`:
```python
# plugins/myplugin/__init__.py
async def mcp_events(db, host_id, host_name, since, until) -> list[dict]:
from steward.mcp.tools._events import event
return [event(r.at, "backup_job", f"Backup {r.name}: {r.state}", until=r.finished_at)
for r in await _jobs_on(db, host_id, since, until)]
```
- Return what happened between `since` and `until`, oldest first: something that lasted gives `until`. Bound your query (a few hundred rows); Steward keeps the 40 nearest the occurrence's start and tells the caller how many it left out (`around_left_out`).
- It is called only while the plugin is enabled, with its own session. If it raises, the anomaly is still returned without the plugin's events. The host agent (maintenance jobs, reboots) and Docker (container starts, deaths, OOM kills) are the examples to read.
A resource with no host (a Traefik router, say) has no host to read events by. A plugin that knows what happened to one of its own resources exports `mcp_resource_events(db, source, resource, since, until)` instead, same return shape, and returns `[]` for a source that isn't its own. Traefik's is the example: it gives a router's swarm service's container events, on whichever hosts they ran.
Core adds, beside every anomaly, the stretches Steward itself wasn't recording (kind `"steward"`: a restart, a deploy, a stall), read from its own once-a-minute health sample; a restart is shown however quick, from the `restart_gap_secs` its new process records. A plugin doesn't need to report those.
---
## Shipping a Plugin
+47 -10
View File
@@ -44,16 +44,32 @@ def setup(app: "Quart") -> None:
required_role=UserRole.viewer,
)
# Each container's CPU (100% is one core) and memory share learn their
# normal (#5742), each with its floor from Settings → Thresholds.
# normal (#5742), each with its floor from Settings → Thresholds. Memory
# is its processes' own, file cache left out (#6030).
from steward.core.norms import floor_setting, learn_normal
learn_normal("docker", "cpu_pct", floor_setting(app, "container_cpu_pct"))
learn_normal("docker", "mem_pct", floor_setting(app, "container_mem_pct"))
learn_normal("docker", "mem_used_pct", floor_setting(app, "container_mem_pct"))
# A host's agent carries its containers' figures: they go silent with it
# (#5721). A swarm service's aren't tied to one host.
from steward.core.alerts import agent_fed
from .service import SERVICE_RESOURCE
agent_fed("docker", lambda resource: None if resource.startswith(SERVICE_RESOURCE)
else resource.split("/", 1)[0])
# An alert on a swarm figure names the services or nodes it counts (#6400).
from steward.core.alerts import names_behind
from .swarm_health import NAMERS
for metric, names in NAMERS.items():
names_behind("docker", metric, names)
# Deploys and crash-loops go on the timeline.
from .timeline import register as register_timeline
register_timeline()
def get_scheduled_tasks() -> list:
# Collection is now agent-driven (pushed to the host_agent ingest); the
# central socket scrape was removed. No periodic task to register.
return []
# Collection is agent-driven (pushed to the host_agent ingest). The one
# task here moves task-named history to its service.
from .rekey import make_rekey_task
return [make_rekey_task(_app)]
def get_blueprint():
@@ -71,15 +87,36 @@ async def mcp_overview(db) -> list[dict]:
return await findings(db)
async def mcp_events(db, host_id: str, host_name: str, since, until) -> list[dict]:
from .mcp_tools import mcp_events as events
return await events(db, host_id, host_name, since, until)
async def mcp_resource_events(db, source: str, resource: str, since, until) -> list[dict]:
from .service import SERVICE_RESOURCE
if source != "docker" or not resource.startswith(SERVICE_RESOURCE):
return []
from .mcp_tools import service_events
return await service_events(db, resource[len(SERVICE_RESOURCE):], since, until)
def get_nav() -> list[dict]:
# Surfaces in the sidebar's Infrastructure group (fleet-wide container view).
return [{"label": "Docker", "href": "/plugins/docker/"}]
def get_alert_metrics() -> dict[str, list[str]]:
"""What ingest.py records per container (resource "<host>/<container>"):
restart_count is cumulative (alert on crash-looping); is_healthy is 1.0 /
0.0 from the container's HEALTHCHECK, only for containers that define one.
Per host (resource "<host>"), what Docker keeps on its disk (#5304)."""
"""What ingest.py records per container (resource "swarm/<service>" for a
swarm task, else "<host>/<container>"): restart_count is cumulative (alert
on crash-looping); is_healthy is 1.0 / 0.0 from the container's HEALTHCHECK,
only for containers that define one. net_rx_bps / net_tx_bps are the bytes
a second it receives and sends, under "<host>/<service or container>"
(#6115): "a container flooding the uplink" is net_tx_bps over a level.
Per host (resource "<host>"), what Docker keeps on its disk (#5304).
Swarm-wide (resource "swarm"), from any manager's report: services short
of their replicas, nodes down and managers unreachable, drained nodes
aside (#5788, swarm_health.py)."""
from .disk_view import DISK_METRICS
return {"docker": ["cpu_pct", "mem_pct", "restart_count", "is_healthy", *DISK_METRICS]}
from .swarm_health import SWARM_METRICS
return {"docker": ["cpu_pct", "mem_used_pct", "restart_count", "is_healthy", "net_rx_bps", "net_tx_bps",
*DISK_METRICS, *SWARM_METRICS]}
+120 -19
View File
@@ -30,6 +30,41 @@ def _parse_started_at(value) -> datetime | None:
return None
def net_rates(before: dict, snapshots: list) -> list:
"""(recorded_at, name, rx_bps, tx_bps) for each running container in each
snapshot, from its cumulative network counters against the last ones seen
for it: `before` is {name: (at, container_id, rx, tx)} from the stored rows,
and each snapshot moves it on. A container new since then, recreated under
the same name, or whose counters went back (restarted) has no rate until
its next snapshot (#6115)."""
last = dict(before)
out = []
for at, containers in snapshots:
for c in containers:
name, rx, tx = c.get("name"), c.get("net_rx_bytes"), c.get("net_tx_bytes")
if not name or c.get("status") != "running" or rx is None or tx is None:
continue
cid = c.get("container_id") or ""
prev = last.get(name)
last[name] = (at, cid, rx, tx)
if prev is None or prev[1] != cid or prev[2] is None or prev[3] is None:
continue
dt = (at - prev[0]).total_seconds()
if dt <= 0 or rx < prev[2] or tx < prev[3]:
continue
out.append((at, name, (rx - prev[2]) / dt, (tx - prev[3]) / dt))
return out
def _crash_loop_deaths() -> int:
from quart import current_app
from .service import CRASH_LOOP_DEATHS, crash_loop_deaths
try:
return crash_loop_deaths(current_app.config)
except RuntimeError: # outside the app (a unit test)
return CRASH_LOOP_DEATHS
def _derive_events(old_state: dict, new_containers: list) -> list:
"""Diff a fresh snapshot against stored per-container state → lifecycle events.
@@ -147,12 +182,36 @@ async def _persist_logs(session, host, log_batches) -> None:
if not await get_setting(session, "docker.logs.enabled"):
return
exclude = set(await get_setting(session, "docker.logs.exclude") or ())
for row in _log_rows(log_batches, host.id):
if row["container_name"] in exclude:
rows = [r for r in _log_rows(log_batches, host.id) if r["container_name"] not in exclude]
stored = await _stored_lines(session, host.id, rows)
for row in rows:
if (row["container_name"], row["ts"], row["stream"], row["line"]) in stored:
continue
session.add(DockerLog(**row))
async def _stored_lines(session, host_id: str, rows: list) -> set:
"""The lines of `rows` this host already has, as (container, ts, stream,
line). A restarted agent has lost its cursors and re-sends each
container's last lines (#5809); a push of new lines overlaps nothing, so
the one query, bounded per container to the batch's own span, finds none."""
from sqlalchemy import and_, or_, select
from .models import DockerLog
spans: dict[str, list] = {}
for r in rows:
span = spans.setdefault(r["container_name"], [r["ts"], r["ts"]])
span[0], span[1] = min(span[0], r["ts"]), max(span[1], r["ts"])
if not spans:
return set()
found = await session.execute(
select(DockerLog.container_name, DockerLog.ts, DockerLog.stream, DockerLog.line)
.where(DockerLog.host_id == host_id, or_(*(
and_(DockerLog.container_name == name, DockerLog.ts.between(lo, hi))
for name, (lo, hi) in spans.items()))))
return {tuple(r) for r in found}
async def _persist_swarm(session, host, swarm: dict) -> None:
"""Upsert this manager's swarm topology; drop rows no longer reported.
@@ -180,6 +239,8 @@ async def _persist_swarm(session, host, swarm: dict) -> None:
row.desired = int(s.get("desired") or 0)
row.running = int(s.get("running") or 0)
row.image = s.get("image")
digest = s.get("image_digest")
row.image_digest = digest[:80] if isinstance(digest, str) else None
row.placement_json = json.dumps(s.get("placement") or [])
row.tasks_json = json.dumps(s.get("tasks") or [])
row.updated_at = now
@@ -239,6 +300,9 @@ async def _persist_disk(session, host, disk: dict) -> None:
summary.images_active = int(disk.get("images_active") or 0)
summary.containers_count = int(disk.get("containers_count") or 0)
summary.volumes_count = int(disk.get("volumes_count") or 0)
digests = disk.get("digests")
summary.digests_json = json.dumps(
[d[:512] for d in digests if isinstance(d, str)][:400] if isinstance(digests, list) else [])
summary.updated_at = now
# The totals as metrics too (#5304): a history of each host's growth, and
# something an alert rule can watch. Resource: the host's name.
@@ -292,6 +356,20 @@ async def _persist_status(session, host, status: dict) -> None:
row.reported_at = status["at"]
async def _record_swarm_health(session, swarm: dict) -> None:
"""The swarm-wide figures alert rules watch (#5788), at most once every
RECORD_EVERY whichever manager reported."""
from datetime import timezone
from steward.core.alerts import MetricBatch
from . import swarm_health
if not swarm_health.due(datetime.now(timezone.utc)):
return
batch = MetricBatch()
for metric, value in swarm_health.figures(swarm).items():
batch.add(source_module="docker", resource_name=swarm_health.RESOURCE, metric_name=metric, value=value)
await batch.record(session)
async def _expire_swarm(session, host) -> None:
"""Drop the swarm rows a host reported while it was a manager."""
from .models import DockerSwarmNode, DockerSwarmService
@@ -307,8 +385,9 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None,
`snapshots` is a list of (recorded_at: datetime, containers: list[dict]) —
one entry per ingested sample carrying docker data (usually one; more when
the agent flushes a backlog). Every snapshot's running containers record
cpu_pct, mem_pct and mem_usage_bytes into the one metric store, at the
snapshot's time; the newest snapshot drives current container state and
cpu_pct, mem_used_pct, mem_used_bytes and mem_cache_bytes into the one
metric store, at the snapshot's time, and net_rx_bps / net_tx_bps from
their counters (net_rates); the newest snapshot drives current container state and
lifecycle-event derivation. `swarm` is the newest
sample's swarm object (or None off managers) — persisted when present.
`disk` is the newest sample's /system/df summary (or None on Docker-less
@@ -325,6 +404,7 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None,
"""
from steward.core.alerts import MetricBatch
from .models import DockerContainer, DockerEvent
from .service import container_resource, traffic_resource
async def mark(name: str) -> None:
if phases is not None:
@@ -338,6 +418,7 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None,
await mark("status")
if swarm is not None:
await _persist_swarm(session, host, swarm)
await _record_swarm_health(session, swarm)
await mark("swarm")
if disk is not None:
await _persist_disk(session, host, disk)
@@ -352,27 +433,39 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None,
latest_at, latest_containers = ordered[-1]
# Time series for every snapshot (running containers with a CPU read), in
# the one metric store (#5752): resource "<host>/<container>", so
# containers of the same name on different hosts don't collide. Recorded
# with the alert batch below, at each snapshot's own time.
# the one metric store: resource "swarm/<service>" for a swarm task, so its
# history survives the task being replaced or moved, else
# "<host>/<container>", so containers of the same name on different hosts
# don't collide. Recorded with the alert batch below, at each snapshot's
# own time.
# Snapshot of stored per-container state BEFORE the upsert overwrites it —
# the network counters each rate counts from, and the lifecycle-event diff.
# Skip event derivation on the host's first snapshot (empty state) so we
# don't emit a start per pre-existing container.
old_rows = (await session.execute(
select(DockerContainer).where(DockerContainer.host_id == host.id)
)).scalars().all()
batch = MetricBatch()
for recorded_at, containers in ordered:
for c in containers:
if c.get("status") != "running" or c.get("cpu_pct") is None or not c.get("name"):
continue
resource = f"{host.name}/{c['name']}"
for metric in ("cpu_pct", "mem_pct", "mem_usage_bytes"):
resource = container_resource(host.name, c["name"])
for metric in ("cpu_pct", "mem_used_pct", "mem_used_bytes", "mem_cache_bytes"):
if c.get(metric) is not None:
batch.add(source_module="docker", resource_name=resource,
metric_name=metric, value=c[metric], recorded_at=recorded_at)
# Bytes a second each container receives and sends, so a host's traffic
# can be put down to the container behind it (#6115).
before = {r.name: (r.scraped_at, r.container_id, r.net_rx_bytes, r.net_tx_bytes) for r in old_rows}
for recorded_at, name, rx_bps, tx_bps in net_rates(before, ordered):
resource = traffic_resource(host.name, name)
batch.add(source_module="docker", resource_name=resource,
metric_name="net_rx_bps", value=round(rx_bps, 1), recorded_at=recorded_at)
batch.add(source_module="docker", resource_name=resource,
metric_name="net_tx_bps", value=round(tx_bps, 1), recorded_at=recorded_at)
await mark("metrics")
# Snapshot of stored per-container state BEFORE the upsert overwrites it —
# used to diff lifecycle events. Skip event derivation on the host's first
# snapshot (empty state) so we don't emit a start per pre-existing container.
old_rows = (await session.execute(
select(DockerContainer).where(DockerContainer.host_id == host.id)
)).scalars().all()
old_state = {
r.name: {"status": r.status, "health": r.health,
"oom_killed": r.oom_killed, "exit_code": r.exit_code,
@@ -384,12 +477,19 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None,
# the sample that noticed it was taken.
finished = {c.get("name"): _parse_started_at(c.get("finished_at"))
for c in latest_containers}
derived = []
for name, event, detail in _derive_events(old_state, latest_containers):
at = finished.get(name) if event in ("die", "stop") else None
session.add(DockerEvent(
host_id=host.id, container_name=name,
event=event, detail=detail, at=at or latest_at,
))
derived.append((name, event, detail, at or latest_at))
# Deploys and crash-loops go on the timeline, judged against the
# containers as they were before this snapshot.
from . import timeline
await timeline.from_snapshot(session, host, old_rows, latest_containers, derived, latest_at,
_crash_loop_deaths())
await mark("events")
# Current state, and the restart and health alerts, from the newest snapshot only.
@@ -405,9 +505,10 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None,
existing.image = c.get("image", "") or ""
existing.status = c.get("status", "unknown") or "unknown"
existing.cpu_pct = c.get("cpu_pct")
existing.mem_usage_bytes = c.get("mem_usage_bytes")
existing.mem_used_bytes = c.get("mem_used_bytes")
existing.mem_limit_bytes = c.get("mem_limit_bytes")
existing.mem_pct = c.get("mem_pct")
existing.mem_used_pct = c.get("mem_used_pct")
existing.mem_cache_bytes = c.get("mem_cache_bytes")
existing.ports_json = json.dumps(c.get("ports") or [])
existing.started_at = _parse_started_at(c.get("started_at"))
existing.scraped_at = latest_at
@@ -426,7 +527,7 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None,
existing.blk_read_bytes = c.get("blk_read_bytes")
existing.blk_write_bytes = c.get("blk_write_bytes")
resource = f"{host.name}/{name}"
resource = container_resource(host.name, name)
# Restart count is alertable regardless of state (crash-looping matters
# most while the container is down/restarting).
batch.add(
+162 -44
View File
@@ -12,12 +12,13 @@ from datetime import datetime, timedelta
from sqlalchemy import select
from steward.mcp.tools.tickets import open_tickets_for
from steward.ansible.runner import trigger_run
from steward.ansible.sources import BUILTIN_SOURCE_NAME
from steward.mcp._context import current_caller, mcp_app
from steward.mcp.tools._audit import audit_mcp
from steward.mcp.tools._shape import (
age_s, bounded, clamp_limit, db_session, iso, lookback, now_utc, pick, rnd, summarize,
age_s, bounded, clamp_limit, cut_middle, db_session, iso, lookback, now_utc, pick, rnd, summarize,
)
from steward.mcp.tools.overview import finding
from steward.models.ansible_inventory import AnsibleTarget
@@ -27,23 +28,14 @@ from steward.models.users import UserRole
from . import service as docker_svc
from .service import PROBLEM_SAYS, PROBLEM_SEVERITY, PROBLEMS, container_problems, problem_since
from .dedup import dedup_by_container_id
from .models import DockerContainer, DockerDiskUsage, DockerSwarmNode, DockerSwarmService
from .models import DockerContainer, DockerDiskUsage, DockerEvent, DockerSwarmNode, DockerSwarmService
from .swarm_view import build_swarm_services, replaced_tasks, task_time
CRASH_LOOP_DEATHS = docker_svc.CRASH_LOOP_DEATHS
LOG_LINE_CHARS = 2000
# ── pure helpers ─────────────────────────────────────────────────────────────
def cut_middle(text: str, limit: int = LOG_LINE_CHARS) -> str:
"""Keep both ends of an over-long line and say how much of the middle went."""
if text is None or len(text) <= limit:
return text
keep = limit // 2
return f"{text[:keep]} [… {len(text) - 2 * keep} characters omitted …] {text[-keep:]}"
def container_row(c, host_names: dict, problems: list[str], now: datetime,
replaced: bool = False, since: datetime | None = None) -> dict:
running = (c.status or "").lower() == "running"
@@ -62,8 +54,8 @@ def container_row(c, host_names: dict, problems: list[str], now: datetime,
"oom_killed": bool(c.oom_killed),
"restart_count": c.restart_count,
"cpu_pct": rnd(c.cpu_pct),
"mem_pct": rnd(c.mem_pct),
"mem_usage_bytes": c.mem_usage_bytes,
"mem_used_pct": rnd(c.mem_used_pct),
"mem_used_bytes": c.mem_used_bytes,
"mem_limit_bytes": c.mem_limit_bytes,
"started_at": iso(c.started_at),
"uptime_s": age_s(c.started_at, now) if running and c.started_at else None,
@@ -107,10 +99,17 @@ def task_error_text(err: dict | None) -> str:
return f"{why}{code}{where}"
# Services that started failing within this long of each other read as one
# finding: they are usually one node's event (#5722).
TOGETHER = timedelta(minutes=5)
def service_findings(services: list[dict]) -> list[dict]:
"""Swarm services that are failing: crash-looping, under-replicated, or that
lost a task to an error in the last 24h. Each names Swarm's last task error."""
out = []
"""Swarm services failing now: crash-looping, or short of replicas, each
naming Swarm's last task error. One back to all its replicas is history,
kept on the Docker page, not a finding (#5722). Several short of replicas
since within TOGETHER of each other are one finding naming them all."""
out, short = [], []
for s in services:
replicas = f"{s['running']} of {s['desired']} replicas running"
last = task_error_text(s.get("last_error"))
@@ -124,20 +123,40 @@ def service_findings(services: list[dict]) -> list[dict]:
f"last hour, {replicas}{tail}", since=since, ids=ids,
next="list_swarm_services()"))
elif s["state"] != "healthy":
short.append((s, replicas, tail))
for group in _together(short):
if len(group) == 1:
s, replicas, tail = group[0]
out.append(finding(
"critical" if s["state"] == "down" else "warning", f"swarm_service_{s['state']}",
s["name"], f"swarm service {s['name']} has {replicas}{tail}",
since=since, ids=ids, next="list_swarm_services()"))
elif s.get("failures_24h"):
n = s["failures_24h"]
out.append(finding(
"warning", "swarm_task_failed", s["name"],
f"swarm service {s['name']} had {n} failed task{'s' if n != 1 else ''} in the last "
f"24h and is now {replicas}{tail}", since=since, ids=ids,
since=s.get("trouble_since"), ids={"service_name": s["name"]},
next="list_swarm_services()"))
continue
names = [s["name"] for s, _r, _t in group]
down = any(s["state"] == "down" for s, _r, _t in group)
out.append(finding(
"critical" if down else "warning", "swarm_services_short", f"{len(group)} swarm services",
f"{len(group)} swarm services started failing together and are short of replicas: "
+ ", ".join(f"{s['name']} ({r})" for s, r, _t in group),
since=min(s["trouble_since"] for s, _r, _t in group),
ids={"service_names": names}, next="list_swarm_services()"))
return out
def _together(short: list[tuple]) -> list[list[tuple]]:
"""Short services grouped by when they started failing: each group's
starts lie within TOGETHER of the one before. One without a start stands alone."""
timed = sorted((x for x in short if x[0].get("trouble_since")), key=lambda x: x[0]["trouble_since"])
groups: list[list[tuple]] = []
for x in timed:
if groups and x[0]["trouble_since"] - groups[-1][-1][0]["trouble_since"] <= TOGETHER:
groups[-1].append(x)
else:
groups.append([x])
return groups + [[x] for x in short if not x[0].get("trouble_since")]
# ── loading ──────────────────────────────────────────────────────────────────
async def _host_names(db) -> dict[str, str]:
@@ -173,12 +192,63 @@ async def _swarm(db, now: datetime):
return built["services"], nodes, host_names
def unique_nodes(nodes: list) -> list:
"""Each swarm node once: every manager reports them all, so keep the
newest report of each."""
seen, out = set(), []
for n in sorted(nodes, key=lambda n: n.updated_at, reverse=True):
if n.node_id not in seen:
seen.add(n.node_id)
out.append(n)
return out
async def _maintaining(db) -> dict[str, str]:
"""{host name, lower case: job state} for hosts with a maintenance job
running (#5835): a node drained or rebooting for one is expected. The
host agent is always loaded; None-safe all the same."""
from steward.core.plugin_manager import loaded_plugin_submodule
models = loaded_plugin_submodule("host_agent", "models")
maint = loaded_plugin_submodule("host_agent", "maintenance")
if models is None or maint is None:
return {}
job = models.HostMaintenanceJob
rows = await db.execute(select(Host.name, job.state).join(Host, Host.id == job.host_id)
.where(job.state.in_(maint.ACTIVE)))
return {name.lower(): state for name, state in rows}
NODE_AVAILABILITY_SAYS = {"drain": "drained", "pause": "paused"}
def node_findings(nodes: list, maintaining: dict[str, str]) -> list[dict]:
"""Swarm nodes down now (#5835): status not ready. A node whose host has
a maintenance job running is rebooting on purpose and isn't named.
Drained alone is not a finding: the operator keeps a manager drained on
purpose (#5777), and a drain a failed maintenance job left behind is that
job's finding."""
out = []
for n in unique_nodes(nodes):
name = n.hostname or n.node_id
if n.status == "ready" or name.lower() in maintaining:
continue
drained = NODE_AVAILABILITY_SAYS.get(n.availability)
out.append(finding(
"critical", "swarm_node_down", name,
f"swarm {n.role} {name} is {n.status}" + (f" and {drained}" if drained else ""),
ids={"node_id": n.node_id}, next="list_swarm_services()"))
return out
async def mcp_overview(db) -> list[dict]:
"""get_overview findings: failing containers and under-replicated swarm services."""
"""get_overview findings: failing containers, under-replicated swarm
services, and swarm nodes that are down."""
now = now_utc()
rows = await _rows(db, now)
services, _, _ = await _swarm(db, now)
return container_findings(rows) + service_findings(services)
services, nodes, _ = await _swarm(db, now)
return (container_findings(rows) + service_findings(services)
+ node_findings(nodes, await _maintaining(db)))
# ── tools ────────────────────────────────────────────────────────────────────
@@ -310,9 +380,10 @@ async def get_container(container: str, host: str = "", range: str = "24h", even
a name also works (swarm task names are long).
host: the host's name or id. Needed only when several hosts run a
container with this name.
range: the window for `cpu_pct_history` and `mem_pct_history` (min, avg,
max, p95, last and a ≤60-point series) — "1h", "6h", "24h" (default),
"7d" or "30d". `cpu_pct` and `mem_pct` are the latest reported values.
range: the window for `cpu_pct_history` and `mem_used_pct_history` (min,
avg, max, p95, last and a ≤60-point series) — "1h", "6h", "24h"
(default), "7d" or "30d". `cpu_pct` and `mem_used_pct` are the latest
reported values.
events: how many lifecycle events (start, restart, stop, die, oom, health_change)
to return, newest first (default 20, max 100).
@@ -320,7 +391,14 @@ async def get_container(container: str, host: str = "", range: str = "24h", even
still resolves by its exact name, or by a part of it that matches one
removed container, while Steward retains its logs or events: the reply
has `removed: true`, `last_seen_at`, its history and its events, and no
live state. A live container has `removed: false`.
live state. A live container has `removed: false`. `open_tickets` are the
open tickets about it (list_tickets).
Memory (`mem_used_bytes`, `mem_used_pct`, of its limit or of the host's
RAM when it has none) is what its processes hold: anon + shmem.
`mem_cache_bytes` is the file cache charged to it, which the kernel
gives back when the host needs the memory, so a large figure there is
not memory the container needs.
"""
since, key = lookback(range)
events = clamp_limit(events, 20, 100)
@@ -334,9 +412,11 @@ async def get_container(container: str, host: str = "", range: str = "24h", even
db, now - timedelta(hours=24))).get((c.host_id, c.name), [])
host_names = await _host_names(db)
cpu = await docker_svc.container_points(db, c.host_id, c.name, "cpu_pct", since)
mem = await docker_svc.container_points(db, c.host_id, c.name, "mem_pct", since)
mem = await docker_svc.container_points(db, c.host_id, c.name, "mem_used_pct", since)
evs = await docker_svc.container_events(db, c.host_id, c.name, events)
sick = await docker_svc.unhealthy_since(db, [(c.host_id, c.name)])
tickets = await open_tickets_for(
db, subject=docker_svc.container_resource(host_names.get(c.host_id, c.host_id), c.name))
hour_ago = now - timedelta(hours=1)
recent = sum(1 for e in failures if e.at >= hour_ago)
try:
@@ -352,10 +432,12 @@ async def get_container(container: str, host: str = "", range: str = "24h", even
"ports": ports,
"net_rx_bytes": c.net_rx_bytes, "net_tx_bytes": c.net_tx_bytes,
"blk_read_bytes": c.blk_read_bytes, "blk_write_bytes": c.blk_write_bytes,
"mem_cache_bytes": c.mem_cache_bytes,
"range": key,
"cpu_pct_history": summarize(cpu),
"mem_pct_history": summarize(mem),
"mem_used_pct_history": summarize(mem),
"events": _event_rows(evs, now),
"open_tickets": tickets,
"next": f'search_container_logs(container="{c.name}", host="{c.host_id}")',
}
@@ -370,7 +452,7 @@ async def _removed_container(db, c: RemovedContainer, since, key: str, events: i
"""get_container for a container Docker has removed: what Steward kept."""
host_names = await _host_names(db)
cpu = await docker_svc.container_points(db, c.host_id, c.name, "cpu_pct", since)
mem = await docker_svc.container_points(db, c.host_id, c.name, "mem_pct", since)
mem = await docker_svc.container_points(db, c.host_id, c.name, "mem_used_pct", since)
evs = await docker_svc.container_events(db, c.host_id, c.name, events)
return {
"container_name": c.name, "host_id": c.host_id, "host_name": host_names.get(c.host_id),
@@ -378,7 +460,7 @@ async def _removed_container(db, c: RemovedContainer, since, key: str, events: i
"last_seen_at": iso(c.last_seen), "last_seen_age_s": age_s(c.last_seen, now),
"range": key,
"cpu_pct_history": summarize(cpu),
"mem_pct_history": summarize(mem),
"mem_used_pct_history": summarize(mem),
"events": _event_rows(evs, now),
"next": f'search_container_logs(container="{c.name}", host="{c.host_id}", range="24h")',
}
@@ -411,21 +493,25 @@ async def list_swarm_services(limit: int = 100) -> dict:
Swarm keeps few old tasks, so a long outage reads as a lower bound.
Services with a problem come first. `nodes` lists every swarm node once,
with its role (manager/worker), availability and status.
with its role (manager/worker, `leader`), availability (active, drain,
pause) and status (ready, down, unknown); `host_id` is the Steward host
of that name (get_host, get_maintenance_job), and `maintenance` the state
of a maintenance job running on it, which drains and reboots it on
purpose (null when none).
limit: default 100, max 500.
"""
limit = clamp_limit(limit, 100, 500)
now = now_utc()
async with db_session() as db:
services, nodes, host_names = await _swarm(db, now)
seen, node_rows = set(), []
for n in sorted(nodes, key=lambda n: n.updated_at, reverse=True):
if n.node_id in seen:
continue
seen.add(n.node_id)
node_rows.append({"node_id": n.node_id, "hostname": n.hostname, "role": n.role,
"leader": n.leader, "availability": n.availability, "status": n.status,
"reported_by": host_names.get(n.host_id)})
maintaining = await _maintaining(db)
host_ids = {name.lower(): hid for hid, name in host_names.items()}
node_rows = [{"node_id": n.node_id, "hostname": n.hostname, "role": n.role,
"leader": n.leader, "availability": n.availability, "status": n.status,
"host_id": host_ids.get((n.hostname or "").lower()),
"maintenance": maintaining.get((n.hostname or "").lower()),
"reported_by": host_names.get(n.host_id)}
for n in unique_nodes(nodes)]
node_rows.sort(key=lambda n: n["hostname"].lower())
order = {"down": 0, "degraded": 1, "healthy": 2}
@@ -454,7 +540,7 @@ async def list_swarm_services(limit: int = 100) -> dict:
{"ghost": True, "host": r["host"], "count": r["count"]} if r["ghost"] else
{"ghost": False, "host": r["host"], "host_id": r["host_id"], "container_name": r["name"],
"status": r["status"], "health": r["health"], "restart_count": r["restart_count"],
"cpu_pct": rnd(r["cpu_pct"]), "mem_pct": rnd(r["mem_pct"])}
"cpu_pct": rnd(r["cpu_pct"]), "mem_used_pct": rnd(r["mem_pct"])}
for r in s["replicas"]
],
}
@@ -669,6 +755,38 @@ async def prune_docker(host: str, what: str, check: bool = True) -> dict:
"next": f'wait_for_run(run_id="{run.id}")'}
# Deploys, restarts, deaths and OOM kills are on the timeline (timeline.py),
# which get_anomaly reads; these are the container events it doesn't hold.
_UNTIMELINED = ("start", "stop", "health_change")
async def mcp_events(db, host_id: str, host_name: str, since, until) -> list[dict]:
"""Container events on a host in a window (get_anomaly) that aren't on
the timeline: starts, stops and health changes."""
from steward.mcp.tools._events import event
rows = (await db.execute(select(DockerEvent).where(
DockerEvent.host_id == host_id, DockerEvent.event.in_(_UNTIMELINED),
DockerEvent.at >= since, DockerEvent.at <= until)
.order_by(DockerEvent.at).limit(500))).scalars()
return [event(e.at, "container", f"{e.container_name} {e.event}" + (f" ({e.detail})" if e.detail else ""),
subject=f"{host_name}/{e.container_name}") for e in rows]
async def service_events(db, service: str, since, until) -> list[dict]:
"""What happened to a swarm service's containers on every host in a window
(get_anomaly): starts with their image, deaths, OOM kills, health changes.
A service has no one host to read events by."""
from steward.mcp.tools._events import event
from steward.models.hosts import Host
rows = (await db.execute(
select(DockerEvent, Host.name).join(Host, Host.id == DockerEvent.host_id)
.where(DockerEvent.container_name.startswith(f"{service}.", autoescape=True),
DockerEvent.at >= since, DockerEvent.at <= until)
.order_by(DockerEvent.at).limit(500))).all()
return [event(e.at, "container", f"{e.container_name} {e.event}" + (f" ({e.detail})" if e.detail else ""),
subject=f"{host}/{e.container_name}") for e, host in rows]
def register_tools(register) -> None:
for fn in (list_containers, get_container, list_swarm_services,
search_container_logs, get_docker_disk_usage):
@@ -0,0 +1,36 @@
"""A container's memory is its processes' own, with file cache beside it
Renames docker_containers.mem_usage_bytes / mem_pct to mem_used_bytes /
mem_used_pct and adds mem_cache_bytes (#6030). The old figure was Docker's
working set (usage less idle file cache), which still counted recently used
file cache: a container that reads a lot of files showed hundreds of GB it
didn't need. The new figure is anon + shmem, what the processes hold and
can't hand back without swap, read by agent 1.23.0. The old values are
cleared rather than carried under the new names; each container's next
report from an updated agent fills them.
Revision ID: docker_013_mem_used
Revises: docker_012_drop_metrics
Create Date: 2026-10-18
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "docker_013_mem_used"
down_revision: Union[str, None] = "docker_012_drop_metrics"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.alter_column("docker_containers", "mem_usage_bytes", new_column_name="mem_used_bytes")
op.alter_column("docker_containers", "mem_pct", new_column_name="mem_used_pct")
op.add_column("docker_containers", sa.Column("mem_cache_bytes", sa.BigInteger, nullable=True))
op.execute("UPDATE docker_containers SET mem_used_bytes = NULL, mem_used_pct = NULL")
def downgrade() -> None:
op.drop_column("docker_containers", "mem_cache_bytes")
op.alter_column("docker_containers", "mem_used_pct", new_column_name="mem_pct")
op.alter_column("docker_containers", "mem_used_bytes", new_column_name="mem_usage_bytes")
@@ -0,0 +1,30 @@
"""Which image a swarm service is pinned to, and which images each host holds
Adds docker_swarm_services.image_digest (the sha256 digest a stack deploy
pinned) and docker_disk_usage.digests_json (every "repo@sha256:…" the host
holds), both from agent 1.26.0. A maintenance run spreads services back out
only onto a node holding the service's image. Older agents leave them empty.
Revision ID: docker_014_image_digests
Revises: docker_013_mem_used
Create Date: 2026-10-25
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "docker_014_image_digests"
down_revision: Union[str, None] = "docker_013_mem_used"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("docker_swarm_services", sa.Column("image_digest", sa.String(80), nullable=True))
op.add_column("docker_disk_usage", sa.Column("digests_json", sa.Text, nullable=False,
server_default="[]"))
def downgrade() -> None:
op.drop_column("docker_disk_usage", "digests_json")
op.drop_column("docker_swarm_services", "image_digest")
+14 -3
View File
@@ -29,10 +29,14 @@ class DockerContainer(Base):
# running | stopped | paused | exited | dead
cpu_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
# BigInteger: a container's memory usage/limit routinely exceeds 2^31 bytes
# (~2.1 GB), which overflows int4 and fails the insert.
mem_usage_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
# (~2.1 GB), which overflows int4 and fails the insert. Used is what its
# processes hold (anon + shmem); file cache is kept apart, since the kernel
# gives it back when the host needs it (#6030). used_pct is of the limit,
# which is the host's RAM when none is set.
mem_used_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
mem_limit_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
mem_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
mem_used_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
mem_cache_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
restart_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
ports_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
# JSON: [{"host_port": 8080, "container_port": 80, "protocol": "tcp"}]
@@ -155,6 +159,9 @@ class DockerSwarmService(Base):
desired: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
running: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
image: Mapped[str | None] = mapped_column(String(512), nullable=True)
# The digest a stack deploy pinned ("sha256:…"), agent ≥ 1.26.0; None when
# the spec names a tag only.
image_digest: Mapped[str | None] = mapped_column(String(80), nullable=True)
# task→node placement of running replicas: [{"node_id", "running"}], JSON.
# A manager sees every task, so this captures cross-node placement that the
# local docker_containers rows (this host only) can't.
@@ -192,6 +199,10 @@ class DockerDiskUsage(Base):
images_active: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
containers_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
volumes_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Every image the host holds by repository digest, ["repo@sha256:…"], JSON
# (agent ≥ 1.26.0): what a swarm service's pinned image is checked against
# before a maintenance run moves the service here.
digests_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
default=lambda: datetime.now(timezone.utc),
+198
View File
@@ -0,0 +1,198 @@
# plugins/docker/rekey.py
"""Moves history kept under a swarm task's own name, "<host>/<service>.<slot>.
<task>", onto its service's key, "swarm/<service>": samples, hourly rollups,
anomalies and the alert rules and maintenance windows that name it.
The raw table holds millions of rows, too many to move in a migration that the
app has to wait for. A small task moves what it can in a short pass in the
background instead, newest history first, and does nothing once none are left. Network
rates stay where they are: they are kept per host, and age out with retention.
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections import Counter
from typing import TYPE_CHECKING
from sqlalchemy import delete, select, text, update
from steward.core.scheduler import ScheduledTask
from steward.models.alerts import AlertRule, MaintenanceWindow
from steward.models.metrics import PluginMetric, PluginMetricHourly, PluginMetricLatest
from steward.models.norms import MetricAnomaly, MetricAnomalySpan
from steward.models.tickets import TicketLink
from steward.models.timeline import TimelineEntry
from steward.timeline.record import anomaly_changed
from .service import container_resource, task_service
if TYPE_CHECKING:
from quart import Quart
logger = logging.getLogger(__name__)
BATCH = 1000 # raw samples moved in one go: each rewrites its row and every index on it
EVERY_S = 15
PASS_S = 2 # a key with only a few rows takes milliseconds, and there can be many
REST = 4 # after a pass, wait this many times as long as it took, so ingest keeps the disk
TRAFFIC = ("net_rx_bps", "net_tx_bps")
_TASK_KEY = r"^[^/]+/.+\.([0-9]+|[a-z0-9]{25})\.[a-z0-9]{25}$"
_NOT_TRAFFIC = "metric_name NOT IN ('net_rx_bps', 'net_tx_bps')"
# Hours the service has no row for change name; hours it has are combined, as
# two tasks of a service can share an hour.
_RENAME_HOURS = text(f"""
UPDATE plugin_metrics_hourly o SET resource_name = :new
WHERE o.source_module = 'docker' AND o.resource_name = :old AND o.{_NOT_TRAFFIC}
AND NOT EXISTS (SELECT 1 FROM plugin_metrics_hourly n
WHERE n.source_module = 'docker' AND n.resource_name = :new
AND n.metric_name = o.metric_name AND n.bucket = o.bucket)
""")
_COMBINE_HOURS = text("""
UPDATE plugin_metrics_hourly n SET
value_avg = COALESCE((n.value_avg * n.sample_count + o.value_avg * o.sample_count)
/ NULLIF(n.sample_count + o.sample_count, 0), n.value_avg),
value_max = GREATEST(n.value_max, o.value_max),
value_p95 = GREATEST(n.value_p95, o.value_p95),
sample_count = n.sample_count + o.sample_count
FROM plugin_metrics_hourly o
WHERE o.source_module = 'docker' AND o.resource_name = :old
AND n.source_module = 'docker' AND n.resource_name = :new
AND n.metric_name = o.metric_name AND n.bucket = o.bucket
""")
_DROP_HOURS = text(f"""
DELETE FROM plugin_metrics_hourly
WHERE source_module = 'docker' AND resource_name = :old AND {_NOT_TRAFFIC}
""")
def service_key(old: str) -> str:
"""The key a task-named resource belongs under now."""
return container_resource("", old.split("/", 1)[1])
async def old_keys(session) -> list[str]:
"""Every task-named docker resource still holding something to move, to be
taken from the end: those with raw samples last, as they are what a chart
and a normal read first. The latest-value table stands for the raw samples,
as a key stays in it until its samples have aged out."""
async def names(column, *where) -> set[str]:
found = (await session.execute(select(column).distinct().where(*where, column.op("~")(_TASK_KEY)))).scalars()
return {k for k in found if task_service(k.split("/", 1)[1])}
recent = await names(PluginMetricLatest.resource_name, PluginMetricLatest.source_module == "docker",
PluginMetricLatest.metric_name.notin_(TRAFFIC))
hourly = await names(PluginMetricHourly.resource_name, PluginMetricHourly.source_module == "docker",
PluginMetricHourly.metric_name.notin_(TRAFFIC))
named = (await names(MetricAnomaly.resource_name, MetricAnomaly.source_module == "docker")
| await names(AlertRule.resource_name, AlertRule.source_module == "docker")
| await names(MaintenanceWindow.scope_resource, MaintenanceWindow.scope_source == "docker"))
logger.info("Swarm history under task names: %d keys with raw samples, %d with hourly rows, "
"%d named by anomalies, rules or windows", len(recent), len(hourly), len(named))
by_service = Counter(task_service(k.split("/", 1)[1]) for k in hourly - recent)
logger.info("Swarm history under task names, hourly only, most keys first: %s; for example %s",
by_service.most_common(10), sorted(hourly - recent)[:3])
return sorted(hourly - recent) + sorted((named - hourly) - recent) + sorted(recent)
async def _merge_anomalies(session, new: str) -> None:
"""One row per metric still to review, as for any key: the rows waiting on
the service become the newest of them, with all their occurrences."""
rows = (await session.execute(
select(MetricAnomaly).where(
MetricAnomaly.source_module == "docker", MetricAnomaly.resource_name == new,
MetricAnomaly.review == "to_review")
.order_by(MetricAnomaly.last_started_at.desc()))).scalars().all()
keep: dict[str, MetricAnomaly] = {}
for row in rows:
into = keep.setdefault(row.metric_name, row)
if into is row:
continue
await session.execute(update(MetricAnomalySpan).where(
MetricAnomalySpan.anomaly_id == row.id).values(anomaly_id=into.id))
into.occurrences += row.occurrences
into.started_at = min(into.started_at, row.started_at)
if row.ended_at is None:
into.ended_at = None
if row.peak_level > into.peak_level:
into.peak_level, into.peak_at = row.peak_level, row.peak_at
into.usual_hold, into.usual_peak = row.usual_hold, row.usual_peak
linked = (await session.execute(select(TicketLink.ticket_id).where(
TicketLink.ref_type == "metric_anomaly", TicketLink.ref_id == into.id))).scalars().all()
await session.execute(update(TicketLink).where(
TicketLink.ref_type == "metric_anomaly", TicketLink.ref_id == row.id,
TicketLink.ticket_id.notin_(linked)).values(ref_id=into.id))
await session.execute(delete(TicketLink).where(
TicketLink.ref_type == "metric_anomaly", TicketLink.ref_id == row.id))
await session.execute(delete(TimelineEntry).where(
TimelineEntry.ref_type == "metric_anomaly", TimelineEntry.ref_id == row.id))
await session.delete(row)
async def move_key(session, old: str) -> bool:
"""Move the next batch of `old`, and the rest of what it holds once its raw
samples are done. Returns whether it is finished. Runs inside the caller's
transaction."""
new = service_key(old)
batch = select(PluginMetric.id).where(
PluginMetric.source_module == "docker", PluginMetric.resource_name == old,
PluginMetric.metric_name.notin_(TRAFFIC)).limit(BATCH)
moved = await session.execute(
update(PluginMetric).where(PluginMetric.id.in_(batch)).values(resource_name=new)
.execution_options(synchronize_session=False))
if moved.rowcount >= BATCH:
return False
keys = {"old": old, "new": new}
for statement in (_RENAME_HOURS, _COMBINE_HOURS, _DROP_HOURS):
await session.execute(statement, keys)
await session.execute(delete(PluginMetricLatest).where(
PluginMetricLatest.source_module == "docker", PluginMetricLatest.resource_name == old,
PluginMetricLatest.metric_name.notin_(TRAFFIC)))
await session.execute(update(AlertRule).where(
AlertRule.source_module == "docker", AlertRule.resource_name == old).values(resource_name=new))
await session.execute(update(MaintenanceWindow).where(
MaintenanceWindow.scope_source == "docker", MaintenanceWindow.scope_resource == old)
.values(scope_resource=new))
renamed = await session.execute(update(MetricAnomaly).where(
MetricAnomaly.source_module == "docker", MetricAnomaly.resource_name == old)
.values(resource_name=new).execution_options(synchronize_session=False))
if not renamed.rowcount:
return True
session.expire_all()
await _merge_anomalies(session, new)
await session.flush()
for row in (await session.execute(select(MetricAnomaly).where(
MetricAnomaly.source_module == "docker", MetricAnomaly.resource_name == new))).scalars().all():
await anomaly_changed(session, row)
return True
def make_rekey_task(app: "Quart") -> ScheduledTask:
remaining: list[str] | None = None
busy = asyncio.Lock()
resume_at = 0.0
async def rekey() -> None:
nonlocal remaining, resume_at
started = time.monotonic()
if remaining == [] or busy.locked() or started < resume_at:
return
async with busy, app.db_sessionmaker() as session:
if remaining is None:
async with session.begin():
remaining = await old_keys(session)
deadline = time.monotonic() + PASS_S
while remaining and time.monotonic() < deadline:
async with session.begin():
finished = await move_key(session, remaining[-1])
if not finished: # a full batch of samples is enough for one pass
break
remaining.pop()
if not remaining:
logger.info("Swarm history under task names: all moved")
now = time.monotonic()
resume_at = now + REST * (now - started)
return ScheduledTask(name="docker_rekey", coro_factory=rekey, interval_seconds=EVERY_S)
+24 -3
View File
@@ -237,6 +237,10 @@ async def widget():
{c.host_id for c in all_containers}
| {n.host_id for n in nodes} | {s.host_id for s in services},
)
deaths = await docker_svc.recent_failures_by_container(db, now - timedelta(hours=24))
sick = await docker_svc.unhealthy_since(
db, [(c.host_id, c.name) for c in all_containers
if not c.service_name and (c.health or "") == "unhealthy"])
# Swarm tasks collapse into per-service rows (each replica tagged with its
# host); non-swarm containers stay grouped by host.
@@ -245,7 +249,11 @@ async def widget():
swarm_services = services_problems_first(build_swarm_services(
all_containers, services, node_hostname, host_name,
crash_loop_deaths=docker_svc.crash_loop_deaths(current_app.config))["services"])
failures = docker_svc.failures_24h(events, swarm_services, host_name)
# The dashboard shows what is wrong now: a service back to all its
# replicas has its failed tasks on the Docker page, not here (#5722).
still = {s["name"] for s in swarm_services if s["state"] != "healthy" or s.get("crash_looping")}
failures = [f for f in docker_svc.failures_24h(events, swarm_services, host_name)
if f["kind"] != "service" or f["name"] in still]
standalone = [c for c in all_containers if not c.service_name]
running = [c for c in standalone if c.status == "running"]
@@ -256,7 +264,19 @@ async def widget():
# The widget-shape order (UI & Design ruling): what is wrong first, then
# the healthy rest one line each, collapsing past HEALTHY_SHOWN.
bad_containers = sorted((c for c in standalone if troubled(c)), key=lambda c: c.name)
# Worst problem first, each with since when, as the Docker page has them.
hour_ago, crash = now - timedelta(hours=1), docker_svc.crash_loop_deaths(current_app.config)
trouble = {}
for c in standalone:
if troubled(c):
died = deaths.get((c.host_id, c.name), [])
problems = docker_svc.container_problems(
c, sum(1 for e in died if e.at >= hour_ago), bool(died), crash)
trouble[(c.host_id, c.name)] = (
docker_svc.PROBLEMS.index(problems[0]) if problems else len(docker_svc.PROBLEMS),
docker_svc.problem_since(problems, died, hour_ago, sick.get((c.host_id, c.name))))
bad_containers = sorted((c for c in standalone if troubled(c)),
key=lambda c: (trouble[(c.host_id, c.name)][0], c.name))
healthy = ([{"kind": "service", "s": s} for s in swarm_services if s["state"] == "healthy"]
+ [{"kind": "container", "c": c} for c in sorted(running, key=lambda c: c.name)
if not troubled(c)]
@@ -267,6 +287,7 @@ async def widget():
"docker/widget.html",
bad_services=[s for s in swarm_services if s["state"] != "healthy"],
bad_containers=bad_containers,
bad_since={key: since for key, (_rank, since) in trouble.items()},
healthy=healthy[:HEALTHY_SHOWN], healthy_more=max(0, len(healthy) - HEALTHY_SHOWN),
services_total=len(swarm_services),
services_healthy=sum(s["state"] == "healthy" for s in swarm_services),
@@ -461,7 +482,7 @@ async def container_detail(host_id: str, name: str):
resource = docker_svc.container_resource(host.name, name) if host is not None else None
anomalies = await anom.views(db, await anom.list_anomalies(
db, source="docker", resource=resource,
since=datetime.now(timezone.utc) - timedelta(days=30), limit=20),
since=datetime.now(timezone.utc) - timedelta(days=30), limit=100),
links=False) if resource else []
if c is None:
+51 -27
View File
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from steward.core import metric_series
from steward.core.like import escape_like
from steward.models.hosts import Host
from steward.models.metrics import PluginMetric
@@ -161,9 +162,10 @@ def usage(c, cpus: int | None, mem_total: int | None, cpu_pct: float | None = No
Docker's CPU% is per core (100% = one core busy), so it is shown as cores
and graded as a share of the host's CPUs when the host's core count is
known. Docker's memory limit is the host's RAM when none is set, so memory
is shown in bytes, against a limit only when one is actually set; mem_pct
is then a share of that limit, otherwise of the host's memory. `cpu_pct`
known. Memory is what the container's processes hold, file cache left out
(#6030). Docker's memory limit is the host's RAM when none is set, so
memory is shown in bytes, against a limit only when one is actually set;
mem_pct is then a share of that limit, otherwise of the host's memory. `cpu_pct`
replaces the container's last sample (an average, say) when given.
With `thresholds`, cpu_grade and mem_grade are the grades to show: past
@@ -178,9 +180,9 @@ def usage(c, cpus: int | None, mem_total: int | None, cpu_pct: float | None = No
"cores": None if cpu is None else cpu / 100.0,
"cpu_share": None if cpu is None or not cpus else cpu / cpus,
"cpus": cpus,
"mem_bytes": getattr(c, "mem_usage_bytes", None),
"mem_bytes": getattr(c, "mem_used_bytes", None),
"mem_limit": limit if limited else None,
"mem_pct": getattr(c, "mem_pct", None),
"mem_pct": getattr(c, "mem_used_pct", None),
"mem_of": "its limit" if limited else ("the host's memory" if mem_total else None),
"cpu_grade": None, "cpu_usual": None, "mem_grade": None, "mem_usual": None,
}
@@ -191,7 +193,7 @@ def usage(c, cpus: int | None, mem_total: int | None, cpu_pct: float | None = No
# Container figures graded against their own normal (#5742): the metric that
# learns it and its floor's setting (norms.floor.*).
NORMED = {"cpu": ("cpu_pct", "container_cpu_pct"), "mem": ("mem_pct", "container_mem_pct")}
NORMED = {"cpu": ("cpu_pct", "container_cpu_pct"), "mem": ("mem_used_pct", "container_mem_pct")}
def _grade(u: dict, cpu: float | None, thresholds: dict, norms: dict) -> None:
@@ -209,7 +211,7 @@ def _grade(u: dict, cpu: float | None, thresholds: dict, norms: dict) -> None:
u["cpu_grade"] = grade
if u["mem_of"]:
u["mem_grade"], u["mem_usual"] = graded(
u["mem_pct"], "mem", thresholds, norms.get("mem_pct"),
u["mem_pct"], "mem", thresholds, norms.get("mem_used_pct"),
floor_of(thresholds, "container_mem_pct"), "%")
else:
u["mem_grade"] = "dim"
@@ -225,7 +227,8 @@ async def container_norms(db, keys, now: datetime | None = None) -> dict:
now or datetime.now(timezone.utc))
out: dict = {}
for (_s, resource, metric), norm in got.items():
out.setdefault(resources[resource], {})[metric] = norm
for key in resources[resource]:
out.setdefault(key, {})[metric] = norm
return out
@@ -273,11 +276,6 @@ async def host_map(db, host_ids: set[str]) -> dict[str, Host]:
}
def escape_like(text: str) -> str:
"""Make `text` match literally inside a LIKE/ILIKE pattern (escape char \\)."""
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
async def list_containers(db, host_id: str | None = None) -> list[DockerContainer]:
"""Containers, running first then by name; one host's when host_id is given."""
stmt = select(DockerContainer)
@@ -318,21 +316,46 @@ async def query_logs(db, host_id: str, name: str, stream: str, query: str,
return (await db.execute(stmt)).all()
CONTAINER_SERIES = ("cpu_pct", "mem_pct", "mem_usage_bytes")
CONTAINER_SERIES = ("cpu_pct", "mem_used_pct", "mem_used_bytes", "mem_cache_bytes")
# A container's series live in the one metric store (#5752): source "docker",
# resource "<host name>/<container name>", recorded at ingest.
# A swarm task's container: "<service>.<slot or node id>.<task id>".
_TASK = re.compile(r"^(?P<service>.+?)\.(?:\d+|[a-z0-9]{25})\.[a-z0-9]{25}$")
SERVICE_RESOURCE = "swarm/"
def task_service(name: str) -> str | None:
"""The swarm service a container is a task of, from its name, or None."""
m = _TASK.match(name or "")
return m["service"] if m else None
# A container's series live in the one metric store: source "docker", recorded
# at ingest. A swarm service's are under "swarm/<service>", which stays the same
# when a task is replaced or moves host, and which all its tasks share. Any
# other container's are under "<host name>/<container name>".
def container_resource(host_name: str, name: str) -> str:
return f"{host_name}/{name}"
service = task_service(name)
return f"{SERVICE_RESOURCE}{service}" if service else f"{host_name}/{name}"
async def container_resources(db, keys) -> dict[str, tuple[str, str]]:
"""{resource: (host_id, container_name)} for (host_id, container_name)
keys whose host still exists."""
def traffic_resource(host_name: str, name: str) -> str:
"""Where a container's network rates are kept: "<host name>/<service or
container>", as they say who on that host is carrying its traffic."""
return f"{host_name}/{task_service(name) or name}"
async def container_resources(db, keys) -> dict[str, list[tuple[str, str]]]:
"""{resource: [(host_id, container_name), ...]} for (host_id,
container_name) keys whose host still exists. A swarm service's tasks share
one resource."""
keys = set(keys)
hosts = await host_map(db, {h for h, _ in keys})
return {container_resource(hosts[h].name, n): (h, n) for h, n in keys if h in hosts}
out: dict[str, list[tuple[str, str]]] = {}
for h, n in keys:
if h in hosts:
out.setdefault(container_resource(hosts[h].name, n), []).append((h, n))
return out
async def container_points(db, host_id: str, name: str, metric: str, since,
@@ -379,7 +402,7 @@ async def average_cpu(db, keys, since: datetime) -> dict[tuple[str, str], float]
PluginMetric.metric_name == "cpu_pct",
PluginMetric.recorded_at >= since)
.group_by(PluginMetric.resource_name))).all()
return {resources[r]: float(v) for r, v in rows if v is not None}
return {key: float(v) for r, v in rows if v is not None for key in resources[r]}
# Busiest Containers bars (#5214): the recent past in BARS equal slices.
@@ -397,17 +420,18 @@ def bar_slots(now: datetime, hours: int, bars: int = BARS) -> tuple[list[datetim
async def recent_usage(db, keys, since: datetime, width: int) -> dict:
"""{(host_id, container_name): {bucket start: (cpu_pct, mem_usage_bytes)}}
"""{(host_id, container_name): {bucket start: (cpu_pct, mem_used_bytes)}}
averaged per bucket since `since`, for the given containers, in one query."""
resources = await container_resources(db, keys)
if not resources:
return {}
out: dict = {}
for resource, at, metric, value in await _samples(
db, resources, ("cpu_pct", "mem_usage_bytes"), since, width):
cpu, mem = out.setdefault(resources[resource], {}).get(at, (None, None))
db, resources, ("cpu_pct", "mem_used_bytes"), since, width):
v = None if value is None else float(value)
out[resources[resource]][at] = (v, mem) if metric == "cpu_pct" else (cpu, v)
for key in resources[resource]:
cpu, mem = out.setdefault(key, {}).get(at, (None, None))
out[key][at] = (v, mem) if metric == "cpu_pct" else (cpu, v)
return out
@@ -595,7 +619,7 @@ async def service_history(db, containers, since: datetime) -> list:
return []
joined: dict[tuple[str, datetime], list] = {}
for resource, at, metric, value in await _samples(
db, resources, ("cpu_pct", "mem_usage_bytes"), since):
db, resources, ("cpu_pct", "mem_used_bytes"), since):
pair = joined.setdefault((resource, at), [None, None])
pair[0 if metric == "cpu_pct" else 1] = value
rows = [(resource, at, cpu, mem) for (resource, at), (cpu, mem) in
+111
View File
@@ -0,0 +1,111 @@
"""The swarm's health as three figures an alert rule can watch (#5788).
Every manager's agent reports the whole swarm, so the figures are swarm-wide,
on one resource, `swarm`, and are recorded at most once every RECORD_EVERY
whichever manager sent them: three managers then read as one series, and one
manager going silent doesn't silence it.
- swarm_services_short: services running fewer tasks than they want. A
service scaled to 0 wants none and is never short.
- swarm_nodes_down: nodes whose status isn't ready, other than drained ones.
A drained node is one taken out of service on purpose (VDNT-Util01 always;
any node during its maintenance), so its going away is expected. Its host's
own "Agent offline" rule still covers it.
- swarm_managers_unreachable: managers the swarm can't reach (agent 1.19.0
sends ManagerStatus.Reachability; before that, a manager whose status
isn't ready), drained ones aside for the same reason. With three managers
one can be lost and the swarm keeps working; two, and it can't change.
A maintenance job on a swarm node holds these rules' notifications in its
window (host_agent maintenance._swarm_window), since draining and rebooting a
node is exactly what moves them.
"""
from __future__ import annotations
from datetime import datetime, timedelta
RESOURCE = "swarm"
SWARM_METRICS = ("swarm_services_short", "swarm_nodes_down", "swarm_managers_unreachable")
RECORD_EVERY = timedelta(seconds=30)
_last_recorded: datetime | None = None
def short_services(services: list[dict]) -> list[str]:
"""The services swarm_services_short counts, each with how many of its
tasks run: "web_app (0 of 2 running)"."""
return [f"{s.get('service_name')} ({int(s.get('running') or 0)} of {int(s.get('desired') or 0)} running)"
for s in sorted(services, key=lambda s: s.get("service_name") or "")
if int(s.get("desired") or 0) > 0 and int(s.get("running") or 0) < int(s.get("desired") or 0)]
def _in_service(nodes: list[dict]) -> list[dict]:
return sorted((n for n in nodes if (n.get("availability") or "active") != "drain"),
key=lambda n: n.get("hostname") or "")
def down_nodes(nodes: list[dict]) -> list[str]:
"""The nodes swarm_nodes_down counts, by hostname, with their status."""
return [f"{n.get('hostname') or n.get('node_id')} ({n.get('status') or 'unknown'})"
for n in _in_service(nodes) if (n.get("status") or "unknown") != "ready"]
def unreachable_managers(nodes: list[dict]) -> list[str]:
"""The managers swarm_managers_unreachable counts, by hostname."""
return [n.get("hostname") or n.get("node_id") for n in _in_service(nodes)
if n.get("role") == "manager"
and (n["reachability"] != "reachable" if n.get("reachability")
else (n.get("status") or "unknown") != "ready")]
def figures(swarm: dict) -> dict[str, float]:
"""The three figures from one manager's swarm report."""
services = swarm.get("services") or []
nodes = swarm.get("nodes") or []
return {"swarm_services_short": float(len(short_services(services))),
"swarm_nodes_down": float(len(down_nodes(nodes))),
"swarm_managers_unreachable": float(len(unreachable_managers(nodes)))}
async def _stored(session) -> tuple[list[dict], list[dict]]:
"""The swarm's services and nodes as stored, the newest report of each
when more than one manager reported it, in the shape figures() reads.
A stored node has no reachability, so its status stands in for it."""
from sqlalchemy import select
from .mcp_tools import unique_nodes
from .models import DockerSwarmNode, DockerSwarmService
services: dict[str, DockerSwarmService] = {}
for s in (await session.execute(select(DockerSwarmService))).scalars():
if s.service_name not in services or s.updated_at > services[s.service_name].updated_at:
services[s.service_name] = s
nodes = unique_nodes(list((await session.execute(select(DockerSwarmNode))).scalars()))
return ([{"service_name": s.service_name, "desired": s.desired, "running": s.running}
for s in services.values()],
[{"node_id": n.node_id, "hostname": n.hostname, "role": n.role,
"availability": n.availability, "status": n.status} for n in nodes])
async def name_short_services(session, resource: str) -> list[str]:
return short_services((await _stored(session))[0])
async def name_down_nodes(session, resource: str) -> list[str]:
return down_nodes((await _stored(session))[1])
async def name_unreachable_managers(session, resource: str) -> list[str]:
return unreachable_managers((await _stored(session))[1])
NAMERS = {"swarm_services_short": name_short_services, "swarm_nodes_down": name_down_nodes,
"swarm_managers_unreachable": name_unreachable_managers}
def due(now: datetime) -> bool:
"""Whether this report should be recorded: the first in RECORD_EVERY,
whichever manager it came from."""
global _last_recorded
if _last_recorded is not None and now - _last_recorded < RECORD_EVERY:
return False
_last_recorded = now
return True
+1 -1
View File
@@ -36,7 +36,7 @@ def node_cards(nodes, services, containers, *, node_host: dict, host_names: dict
"measured": True})
row["tasks"] += 1
row["cpu_pct"] += c.cpu_pct or 0.0
row["mem_bytes"] += c.mem_usage_bytes or 0
row["mem_bytes"] += c.mem_used_bytes or 0
placed: dict[str, dict[str, int]] = {}
for s in services:
for p in _placement(s):
+2 -2
View File
@@ -289,8 +289,8 @@ def build_swarm_services(containers, services, node_hostname: dict, host_name: d
"name": c.name,
"status": getattr(c, "status", "unknown"),
"cpu_pct": getattr(c, "cpu_pct", None),
"mem_pct": getattr(c, "mem_pct", None),
"mem_bytes": getattr(c, "mem_usage_bytes", None),
"mem_pct": getattr(c, "mem_used_pct", None),
"mem_bytes": getattr(c, "mem_used_bytes", None),
"started_at": getattr(c, "started_at", None),
"image": getattr(c, "image", None) or None,
"health": getattr(c, "health", None),
+2 -1
View File
@@ -2,7 +2,8 @@
Docker counts CPU per core (100% = one core busy), so CPU reads as cores,
toned by its share of the host's CPUs when the host's core count is known.
Memory reads in bytes, against a limit only when one is actually set;
Memory is what its processes hold, file cache left out (#6030), and
reads in bytes, against a limit only when one is actually set;
mem_pct is then a share of that limit, otherwise of the host's memory.
Each is toned by usage()'s grade when it has one: within the container's
own normal for the hour reads healthy, and says what is usual (#5742). -#}
@@ -66,6 +66,8 @@
{# ── Its CPU or memory out of its normal for the hour, and past anomalies (#5742) #}
{% with back=anomaly_back, base_resource=anomaly_resource %}{% include "_anomaly_card.html" %}{% endwith %}
{# ── Its open tickets ──────────────────────────────────────────────────────── #}
<div hx-get="/tickets/card?subject={{ anomaly_resource|urlencode }}&amp;back={{ anomaly_back|urlencode }}" hx-trigger="load" hx-swap="outerHTML"></div>
{# ── What it is set up with, and what it moved since it started ─────────── #}
<div class="card">
@@ -78,6 +80,10 @@
{%- if c.exit_code is not none and c.status == 'running' and c.restart_count %} <span class="tone-dim">· last exit {{ c.exit_code }}</span>{% endif %}</dd>
{% endif %}
<dt>Memory limit</dt><dd>{% if usage.mem_limit %}{{ usage.mem_limit|bytes }}{% else %}<span class="tone-dim">none set</span>{% endif %}</dd>
{#- Its memory figure is what its processes hold; the file cache charged to
it is shown here only, since the kernel gives it back when the host
needs it (#6030). #}
<dt>File cache</dt><dd>{% if c.mem_cache_bytes is not none %}{{ c.mem_cache_bytes|bytes }} <span class="tone-dim">files it read or wrote, kept in memory until the host needs it back; not counted in its memory</span>{% else %}<span class="tone-dim">—</span>{% endif %}</dd>
{% if ports %}
<dt>Ports</dt><dd class="mono">{% for p in ports %}{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}{% if not loop.last %}, {% endif %}{% endfor %}</dd>
{% endif %}
+2 -1
View File
@@ -35,7 +35,8 @@
<span class="w-label" title="{{ c.name }}">
{%- if session.user_id %}<a href="/plugins/docker/container/{{ c.host_id }}/{{ c.name }}">{{ c.name }}</a>{% else %}{{ c.name }}{% endif %}
<span class="w-sub tone-crit">
{%- if c.health == 'unhealthy' %}unhealthy{% elif c.status == 'restarting' %}restarting{% else %}exited {{ c.exit_code }}{% if c.oom_killed %}, out of memory{% endif %}{% endif %} · {{ host_name.get(c.host_id, c.host_id) }}
{%- if c.health == 'unhealthy' %}unhealthy{% elif c.status == 'restarting' %}restarting{% else %}exited {{ c.exit_code }}{% if c.oom_killed %}, out of memory{% endif %}{% endif %}
{%- set since = (bad_since or {}).get((c.host_id, c.name)) %}{% if since %} since {{ since|ago }}{% endif %} · {{ host_name.get(c.host_id, c.host_id) }}
</span>
</span>
</div>
+160
View File
@@ -0,0 +1,160 @@
# plugins/docker/timeline.py
"""What Docker puts on the timeline, judged from each snapshot as it is
ingested: a deploy (a container or swarm service running a new image, or a
container recreated), and a crash-loop as one entry for the whole episode.
A single restart or death is recorded but hidden. As on the Docker pages, a
crash loop is Settings → Thresholds' number of deaths within CRASH_WINDOW:
the one that reaches it opens a crash-loop entry, which each later one
extends until the container has been quiet for CRASH_WINDOW.
A container belongs to its swarm service when it has one, so a service's
tasks, replaced on every deploy and every crash, read as one subject.
"""
from __future__ import annotations
import logging
from datetime import timedelta
from sqlalchemy import func, select
from steward.models.timeline import TimelineEntry
from steward.timeline import catalog
from steward.timeline.record import record
logger = logging.getLogger(__name__)
TYPES = (
("docker.deployed", "Deployed", "change", "service", True,
"A container or swarm service started on a new image, or a container was recreated."),
("docker.crash_loop", "Crash-looping", "observed", "service", True,
"A container kept restarting or dying: one entry for the whole episode."),
("docker.restarted", "Container restarted or died", "observed", "service", False,
"A single restart, death or out-of-memory kill."),
)
CRASH_WINDOW = timedelta(hours=1)
_TROUBLE = ("restart", "die", "oom")
def register() -> None:
for t in TYPES:
catalog.register_type(*t)
def unit(c: dict) -> str:
"""What a container is part of: its swarm service, else itself."""
return c.get("service_name") or c.get("name")
def short_image(image: str | None) -> str:
"""An image as a person reads it: the name and tag, and a few characters
of the digest when there is one."""
if not image:
return "an unknown image"
name, _, digest = image.partition("@")
name = name.rsplit("/", 1)[-1]
if digest:
return f"{name} ({digest.split(':', 1)[-1][:12]})"
return name
def deploys(old_rows: list, latest: list[dict]) -> list[dict]:
"""Each unit whose containers now run an image they didn't, or a
container recreated under the same name: {unit, name, image, was,
recreated, swarm}. A swarm task landing on a host that ran none of its
service before is the scheduler moving it, not a deploy."""
images: dict[str, set] = {}
ids = {}
for r in old_rows:
images.setdefault(r.service_name or r.name, set()).add(r.image)
ids[r.name] = (r.container_id, r.image)
out, seen = [], set()
for c in latest:
if (c.get("status") or "").lower() != "running" or not c.get("name"):
continue
key, image = unit(c), c.get("image") or ""
if key in seen:
continue
before = images.get(key)
old_id, old_image = ids.get(c["name"], (None, None))
if before and image not in before:
was = old_image if old_image and old_image != image else sorted(before)[0]
out.append({"unit": key, "name": c["name"], "image": image, "was": was,
"recreated": False, "swarm": bool(c.get("service_name"))})
elif old_id and c.get("container_id") and old_id != c["container_id"] and not c.get("service_name"):
out.append({"unit": key, "name": c["name"], "image": image, "was": old_image,
"recreated": True, "swarm": False})
elif before is None and not c.get("service_name"):
out.append({"unit": key, "name": c["name"], "image": image, "was": None,
"recreated": False, "swarm": False})
else:
continue
seen.add(key)
return out
def _deploy_summary(d: dict, host_name: str) -> str:
what = f"{d['unit']} on {host_name}"
if d["recreated"]:
return f"{what}: recreated on {short_image(d['image'])}"
if d["was"] is None:
return f"{what}: started on {short_image(d['image'])}"
new, old = short_image(d["image"]), short_image(d["was"])
if d["image"].split("@")[0] == (d["was"] or "").split("@")[0]:
return f"{what}: new build of {new}"
return f"{what}: {old} → {new}"
async def _trouble(session, host, key: str, name: str, at, said: str, deaths: int) -> None:
"""One restart, death or OOM kill of `key`: extends its crash-loop, opens
one when it is the `deaths`-th within CRASH_WINDOW, else a hidden entry."""
subject = f"{host.name}/{key}"
loop = (await session.execute(select(TimelineEntry).where(
TimelineEntry.type == "docker.crash_loop", TimelineEntry.subject == subject,
TimelineEntry.until >= at - CRASH_WINDOW).order_by(TimelineEntry.at.desc()).limit(1))
).scalar_one_or_none()
if loop is not None:
count = (loop.detail or {}).get("count", 0) + 1
loop.until = max(loop.until, at)
loop.detail = {**(loop.detail or {}), "count": count, "last": said}
loop.summary = f"{key} on {host.name} crash-looping: {count} restarts or deaths"
return
recent = (await session.execute(select(func.count(), func.min(TimelineEntry.at)).where(
TimelineEntry.type == "docker.restarted", TimelineEntry.subject == subject,
TimelineEntry.at >= at - CRASH_WINDOW))).one()
url = f"/plugins/docker/container/{host.id}/{name}"
if recent[0] + 1 >= deaths:
count = recent[0] + 1
record(session, type="docker.crash_loop", at=recent[1] or at, until=at,
summary=f"{key} on {host.name} crash-looping: {count} restarts or deaths",
hosts=[host.name], subject=subject, actor="Docker",
detail={"count": count, "last": said, "url": url})
return
record(session, type="docker.restarted", at=at, summary=f"{key} on {host.name}: {said}",
hosts=[host.name], subject=subject, actor="Docker", detail={"url": url})
_SAID = {"restart": "restarted", "die": "died", "oom": "killed, out of memory"}
async def from_snapshot(session, host, old_rows: list, latest: list[dict], events: list, at,
deaths: int) -> None:
"""The entries one snapshot makes: its deploys, and its restarts and
deaths (`events`: (name, event, detail, when)), in a savepoint so a
failure here never loses the snapshot. `deaths` makes a crash loop."""
try:
async with session.begin_nested():
for d in deploys(old_rows, latest):
url = "/plugins/docker/swarm" if d["swarm"] else f"/plugins/docker/container/{host.id}/{d['name']}"
record(session, type="docker.deployed", at=at, summary=_deploy_summary(d, host.name),
hosts=[host.name], subject=f"{host.name}/{d['unit']}", actor="Docker",
detail={"image": d["image"], "was": d["was"], "url": url})
units = {c.get("name"): unit(c) for c in latest if c.get("name")}
units.update({r.name: r.service_name or r.name for r in old_rows if r.name not in units})
for name, event, detail, when in events:
if event in _TROUBLE:
said = _SAID[event] + (f" ({detail})" if detail else "")
await _trouble(session, host, units.get(name, name), name, when or at, said, deaths)
await session.flush()
except Exception:
logger.exception("Docker on %s: timeline entries could not be written", host.name)
+46
View File
@@ -0,0 +1,46 @@
# plugins/homeassistant/__init__.py
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from quart import Quart
_app: "Quart | None" = None
def setup(app: "Quart") -> None:
global _app
_app = app
from .models import HomeAssistantConnection, HomeAssistantSchedule, HomeAssistantUpdate # noqa: F401 — registers the tables
from .timeline import register
register()
def get_scheduled_tasks() -> list:
from .scheduler import make_tick_task
return [make_tick_task(_app)]
def get_blueprint():
from .routes import homeassistant_bp
return homeassistant_bp
def get_nav() -> list[dict]:
return [{"label": "Home Assistant", "href": "/plugins/homeassistant/"}]
def get_alert_metrics() -> dict[str, list[str]]:
"""What the poll records, all under the resource "homeassistant"."""
return {"homeassistant": ["is_up", "backup_age_hours", "updates_waiting", "problems_open", "disk_free_pct",
"update_failed"]}
def register_mcp_tools(register) -> None:
from .mcp_tools import register_tools
register_tools(register)
async def mcp_overview(db) -> list[dict]:
from .mcp_tools import mcp_overview as findings
return await findings(db)
+292
View File
@@ -0,0 +1,292 @@
# plugins/homeassistant/client.py
"""Talking to Home Assistant, with what its own sign-in gave Steward: a refresh
token kept in the connection, and access tokens made from it that last half an
hour. Repairs and the Supervisor's reports are only offered on its websocket. Home Assistant bans an address after a few refused requests, so nothing is
sent without a token and a refusal stops the asking. Every failure comes back as
one sentence a page can show."""
from __future__ import annotations
import asyncio
import json
import ssl
import time
from contextlib import asynccontextmanager
from urllib.parse import urlencode
import httpx
from websockets.asyncio.client import connect as ws_connect
from websockets.exceptions import WebSocketException
from steward.core.crypto import decrypt_secret
TIMEOUT_SECONDS = 15.0
# Home Assistant answers an action when it has finished, minutes later for a
# backup or an update, and carries on if the request ends. So its answer is only
# waited for this long, and the outcome is read from its entities.
ACTION_READ_SECONDS = 10.0
MAX_BYTES = 16 * 1024 * 1024
# An access token this close to running out isn't used for another request.
EXPIRY_MARGIN_SECONDS = 60
# One read of the system over the websocket, however its answers dribble in.
SYSTEM_READ_SECONDS = 30
_access: dict[str, tuple[str, float]] = {} # refresh token -> access token, when to stop using it
class Unreachable(Exception):
"""Home Assistant didn't answer usefully; it may be restarting."""
class Refused(Exception):
"""Home Assistant refused Steward's sign-in, or has banned this address."""
class Rejected(Exception):
"""Home Assistant understood an action and wouldn't do it."""
def _refusal(status: int) -> Refused | None:
if status == 401:
_access.clear()
return Refused("Home Assistant refused Steward's sign-in. Sign in again, as a user who may do this.")
if status == 403:
return Refused("Home Assistant answered 403: it may have banned Steward's address "
"after refused requests. Look in its ip_bans.yaml.")
return None
async def _request(method: str, url: str, path: str, verify_tls: bool, transport, *, token: str | None = None,
read_timeout: float = TIMEOUT_SECONDS, **kwargs) -> tuple[int, bytes]:
headers = {"Authorization": f"Bearer {token}"} if token else {}
timeout = httpx.Timeout(TIMEOUT_SECONDS, read=read_timeout)
try:
# A slow drip of bytes can keep each read inside its own timeout for ever.
async with asyncio.timeout(TIMEOUT_SECONDS + read_timeout):
async with httpx.AsyncClient(timeout=timeout, verify=verify_tls, transport=transport,
follow_redirects=False) as client:
async with client.stream(method, url.rstrip("/") + path, headers=headers, **kwargs) as response:
body = bytearray()
async for chunk in response.aiter_bytes():
body.extend(chunk)
if len(body) > MAX_BYTES:
raise Unreachable("Home Assistant's answer was too large to read.")
return response.status_code, bytes(body)
except TimeoutError:
raise httpx.ReadTimeout("no complete answer in time") from None
def _unreachable(exc: httpx.HTTPError) -> Unreachable:
if isinstance(exc, httpx.TimeoutException):
return Unreachable(f"Home Assistant didn't answer within {TIMEOUT_SECONDS:.0f} s.")
return Unreachable(f"Couldn't reach Home Assistant: {exc}")
# ── Signing in ───────────────────────────────────────────────────────────────
def sign_in_url(url: str, client_id: str, redirect_uri: str, state: str) -> str:
"""Home Assistant's own sign-in page, which sends the browser back to
`redirect_uri`. It accepts `client_id` as it is because the two share an
address, so it needn't be able to reach Steward."""
return f"{url.rstrip('/')}/auth/authorize?" + urlencode(
{"response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri, "state": state})
async def _tokens(url: str, form: dict, verify_tls: bool, transport) -> dict:
try:
status, body = await _request("POST", url, "/auth/token", verify_tls, transport, data=form)
except httpx.HTTPError as exc:
raise _unreachable(exc) from None
if status in (400, 401, 403):
raise Refused("Home Assistant no longer accepts Steward's sign-in. Sign in again.")
if status != 200:
raise Unreachable(f"Home Assistant answered {status}.")
try:
found = json.loads(body)
found["access_token"], int(found["expires_in"])
except (ValueError, KeyError, TypeError):
raise Unreachable("Home Assistant's answer wasn't a sign-in.") from None
return found
def _remember(refresh_token: str, found: dict) -> str:
until = time.monotonic() + int(found["expires_in"]) - EXPIRY_MARGIN_SECONDS
_access[refresh_token] = (found["access_token"], until)
return found["access_token"]
async def redeem_code(url: str, client_id: str, code: str, verify_tls: bool, transport=None) -> tuple[str, str]:
"""(refresh token, access token) for the code the sign-in page sent back."""
found = await _tokens(url, {"grant_type": "authorization_code", "client_id": client_id, "code": code},
verify_tls, transport)
refresh_token = found.get("refresh_token")
if not refresh_token:
raise Unreachable("Home Assistant's answer wasn't a sign-in.")
return refresh_token, _remember(refresh_token, found)
async def access_token(connection, transport=None) -> str:
refresh_token = decrypt_secret(connection.refresh_token, context="Home Assistant sign-in")
token, until = _access.get(refresh_token, (None, 0.0))
if token and time.monotonic() < until:
return token
found = await _tokens(connection.url, {"grant_type": "refresh_token", "client_id": connection.client_id,
"refresh_token": refresh_token}, connection.verify_tls, transport)
return _remember(refresh_token, found)
async def is_admin(url: str, token: str, verify_tls: bool, transport=None) -> bool:
"""Whether the signed-in user is an administrator, which its API only says by
refusing someone who isn't. So this is asked once, right after signing in, and
a refusal here counts once toward a ban."""
try:
status, _ = await _request("POST", url, "/api/template", verify_tls, transport, token=token,
json={"template": "1"})
except httpx.HTTPError as exc:
raise _unreachable(exc) from None
if status == 401:
return False
if status != 200:
raise (_refusal(status) or Unreachable(f"Home Assistant answered {status}."))
return True
async def revoke(connection, transport=None) -> None:
"""End this sign-in on Home Assistant. It answers 200 whether or not it knew it."""
refresh_token = decrypt_secret(connection.refresh_token, context="Home Assistant sign-in")
_access.pop(refresh_token, None)
try:
status, _ = await _request("POST", connection.url, "/auth/revoke", connection.verify_tls, transport,
data={"token": refresh_token})
except httpx.HTTPError as exc:
raise _unreachable(exc) from None
if status != 200:
raise Unreachable(f"Home Assistant answered {status}.")
# ── Reading and acting ───────────────────────────────────────────────────────
async def _get(url: str, path: str, token: str, verify_tls: bool, transport) -> list | dict | None:
"""The parsed JSON of a GET, or None for a 404."""
try:
status, body = await _request("GET", url, path, verify_tls, transport, token=token)
except httpx.HTTPError as exc:
raise _unreachable(exc) from None
if refusal := _refusal(status):
raise refusal
if status == 404:
return None
if status != 200:
raise Unreachable(f"Home Assistant answered {status}.")
try:
return json.loads(body)
except ValueError:
raise Unreachable("Home Assistant's answer wasn't JSON.") from None
async def read_states(url: str, token: str, verify_tls: bool, transport=None) -> list[dict]:
states = await _get(url, "/api/states", token, verify_tls, transport)
if not isinstance(states, list):
raise Unreachable("Home Assistant's answer wasn't a list of entities.")
return states
async def read_state(url: str, entity_id: str, token: str, verify_tls: bool, transport=None) -> dict | None:
return await _get(url, f"/api/states/{entity_id}", token, verify_tls, transport)
async def call_action(url: str, domain: str, action: str, data: dict, token: str, verify_tls: bool,
transport=None) -> None:
"""Ask Home Assistant to do something. Returning means it got the request,
not that it has finished: a request cut off once sent has been taken up, and
an update that restarts Home Assistant cuts its own request off."""
try:
status, body = await _request("POST", url, f"/api/services/{domain}/{action}", verify_tls, transport,
token=token, read_timeout=ACTION_READ_SECONDS, json=data)
except (httpx.ReadTimeout, httpx.ReadError, httpx.RemoteProtocolError):
return
except httpx.HTTPError as exc:
raise _unreachable(exc) from None
if refusal := _refusal(status):
raise refusal
if status != 200:
try:
said = json.loads(body).get("message")
except (ValueError, AttributeError):
said = None
raise Rejected(said or f"Home Assistant answered {status}.")
# ── The system, over the websocket ───────────────────────────────────────────
class _Socket:
"""Home Assistant's websocket after sign-in: one question at a time, each with
an id, and None for one it won't answer (no Supervisor, or not an administrator)."""
def __init__(self, ws):
self.ws, self.asked = ws, 0
async def sign_in(self, token: str) -> None:
await self.ws.recv()
await self.ws.send(json.dumps({"type": "auth", "access_token": token}))
if json.loads(await self.ws.recv()).get("type") != "auth_ok":
raise Refused("Home Assistant refused Steward's sign-in. Sign in again, as a user who may do this.")
async def ask(self, **question):
self.asked += 1
await self.ws.send(json.dumps({"id": self.asked, **question}))
while True:
answer = json.loads(await self.ws.recv())
if answer.get("id") == self.asked:
return answer.get("result") if answer.get("success") else None
async def supervisor(self, endpoint: str):
return await self.ask(type="supervisor/api", endpoint=endpoint, method="get")
@asynccontextmanager
async def _signed_in(url: str, token: str, verify_tls: bool, connect):
"""A signed-in socket. `connect` stands in for the websocket in tests."""
target = "ws" + url.rstrip("/").removeprefix("http") + "/api/websocket"
options: dict = {"max_size": MAX_BYTES}
if target.startswith("wss:") and not verify_tls:
options["ssl"] = ssl.create_default_context()
options["ssl"].check_hostname, options["ssl"].verify_mode = False, ssl.CERT_NONE
try:
async with asyncio.timeout(SYSTEM_READ_SECONDS):
async with (connect or ws_connect)(target, **options) as ws:
socket = _Socket(ws)
await socket.sign_in(token)
yield socket
except TimeoutError:
raise Unreachable(f"Home Assistant's system report didn't arrive within {SYSTEM_READ_SECONDS} s.") from None
except (OSError, ValueError, KeyError, WebSocketException) as exc:
raise Unreachable(f"Couldn't read Home Assistant's system report: {exc}") from None
async def read_system(url: str, token: str, verify_tls: bool, connect=None) -> dict:
"""What service.read_health needs, as Home Assistant answered it."""
async with _signed_in(url, token, verify_tls, connect) as socket:
found = {"config": await socket.ask(type="get_config"),
"issues": (await socket.ask(type="repairs/list_issues") or {}).get("issues"),
"resolution": await socket.supervisor("/resolution/info"),
"host": await socket.supervisor("/host/info"), "titles": {}, "boots": {}}
domains = sorted({i["domain"] for i in found["issues"] or []})
if domains:
titles = await socket.ask(type="frontend/get_translations", language="en",
category="issues", integration=domains)
found["titles"] = (titles or {}).get("resources") or {}
found["addons"] = (await socket.supervisor("/addons") or {}).get("addons")
# Whether one should be running is only on its own page, so a running one isn't asked.
for addon in found["addons"] or []:
if addon.get("state") != "started":
info = await socket.supervisor(f"/addons/{addon['slug']}/info") or {}
found["boots"][addon["slug"]] = info.get("boot")
return found
async def read_os(url: str, token: str, verify_tls: bool, connect=None) -> dict | None:
"""The Supervisor's report on the OS: `version`, and `version_pending` while a
written update waits for its reboot. None if it isn't answering yet, as just
after a boot."""
async with _signed_in(url, token, verify_tls, connect) as socket:
return await socket.supervisor("/os/info")
+168
View File
@@ -0,0 +1,168 @@
"""The Home Assistant plugin's MCP tools and its get_overview findings.
get_home_assistant reads what the poll stored. update_home_assistant starts an
update as the page's Install does. The sign-in Steward holds is never returned.
"""
from __future__ import annotations
from datetime import timezone
from sqlalchemy import select
from steward.core import weekly
from steward.core.formatting import zone
from steward.mcp._context import current_caller, mcp_app
from steward.mcp.tools._audit import audit_mcp, mcp_username
from steward.mcp.tools._shape import age_s, db_session, iso, now_utc, redact_url
from steward.mcp.tools.overview import finding
from steward.models.users import UserRole
from . import schedule
from .models import HomeAssistantConnection, HomeAssistantSchedule, HomeAssistantUpdate
from .routes import connection_says
from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment, problems_open
from .updates import CannotStart, start_update
RECENT_UPDATES = 10
def _schedule_view(plan, now) -> dict | None:
if plan is None:
return None
return {"enabled": plan.enabled, "when": weekly.describe(plan),
"next_slot_at": iso(weekly.next_slot(plan, now)) if plan.enabled else None,
"last_slot_at": iso(weekly.fired_slot(plan)), "stopped": plan.stopped}
def _run_view(r: HomeAssistantUpdate) -> dict:
return {"update_id": r.id, "entity_id": r.entity_id, "title": r.title, "kind": r.kind,
"from_version": r.from_version, "to_version": r.to_version, "state": r.state, "message": r.message,
"started_by": r.started_by, "started_at": iso(r.started_at), "backup_at": iso(r.backup_at),
"finished_at": iso(r.finished_at)}
async def get_home_assistant() -> dict:
"""Home Assistant as of the last poll: whether it answers, which updates are
waiting, how old its last backup is, and the updates Steward was asked to install.
`installable` is what update_home_assistant takes (Core, OS, add-ons);
`others_waiting` is firmware and custom integrations, which Steward leaves
to Home Assistant. `can_install` is whether the Home Assistant user who signed
Steward in is an administrator, which installing needs. `system.problems` is
what's wrong with the system itself: open repairs, Supervisor health, low disk,
recovery mode, add-ons that should be running. `schedule` is the weekly slot
for installing what's waiting, null until one is set.
"""
now = now_utc()
async with db_session() as db:
c = (await db.execute(select(HomeAssistantConnection))).scalars().first()
runs = list((await db.execute(select(HomeAssistantUpdate).order_by(
HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars())
plan = (await db.execute(select(HomeAssistantSchedule))).scalars().first()
if c is None:
return {"connected": False, "says": "No connection yet: an admin signs in on the Home Assistant page."}
reach, says = connection_says(c, now)
backup = c.backup or {}
age = backup_age_hours(backup, now)
return {"connected": True, "url": redact_url(c.url), "reach": reach, "says": says,
"polled_at": iso(c.last_polled_at), "ok_age_s": age_s(c.last_ok_at, now), "can_install": c.can_install,
"installable": [{k: u[k] for k in ("entity_id", "title", "kind", "installed", "latest", "in_progress")}
for u in installable(c)],
"others_waiting": [u["title"] for u in c.updates or [] if u["waiting"] and not u["kind"]],
"backup": {"verdict": backup_verdict(backup, now)[1], "last_ok": iso(parse_moment(backup.get("last_ok"))),
"age_hours": round(age, 1) if age is not None else None, "stale_after_hours": STALE_BACKUP_HOURS,
"next": iso(parse_moment(backup.get("next"))), "last_result": backup.get("last_result")},
"system": c.health or {}, "schedule": _schedule_view(plan, now),
"updates": [_run_view(r) for r in runs]}
async def update_home_assistant(entity_id: str) -> dict:
"""Install one Core, OS or add-on update, as the page's Install does: Steward
takes a backup, confirms it landed, installs, and follows it to its end.
entity_id: from get_home_assistant's `installable`.
An update restarts Home Assistant or the add-on. An OS update reads as done
once it's written, and takes effect after a reboot, which Steward leaves to a
person. Refused while another is under way, or if nothing is waiting there.
Admin only, as on the page. Recorded in the audit log.
"""
caller = current_caller()
try:
run = await start_update(mcp_app(), entity_id, mcp_username(caller.username))
except CannotStart as exc:
raise ValueError(f"Nothing was started: {exc}.") from None
await audit_mcp("homeassistant.update_started", "homeassistant_update", run.id,
{"entity_id": entity_id, "title": run.title, "to": run.to_version})
return {**_run_view(run), "next": "get_home_assistant follows it: `updates` shows its state."}
async def set_home_assistant_schedule(enabled: bool, weekday: int = 5, at_time: str = "05:00",
time_zone: str = "UTC") -> dict:
"""Set the weekly slot for installing what's waiting, or turn it off, as the page does.
weekday: 0 for Monday to 6 for Sunday. at_time: "HH:MM" in time_zone.
At each slot Steward installs add-ons, then Core, then the OS, one at a time,
each after a confirmed backup, stopping at the first failure. A Core update
restarts Home Assistant and an OS update reboots the machine, which Steward
does itself here, with nobody watching. The first slot is the next one from
now. Admin only, and audited.
"""
try:
weekly.parse_at(at_time)
except ValueError:
raise ValueError(f"at_time is HH:MM, not {at_time!r}.") from None
if not 0 <= weekday <= 6:
raise ValueError("weekday is 0 for Monday to 6 for Sunday.")
if zone(time_zone) is timezone.utc and time_zone.upper() != "UTC":
raise ValueError(f"Unknown time zone {time_zone!r}.")
row = await schedule.save(mcp_app(), enabled=enabled, weekday=weekday, at_time=at_time, time_zone=time_zone)
await audit_mcp("homeassistant.schedule_saved", "homeassistant_schedule", row.id,
{"enabled": row.enabled, "when": weekly.describe(row)})
return _schedule_view(row, now_utc())
def overview_findings(c, latest_run, now, plan=None) -> list[dict]:
"""A connection that isn't answering, a problem with the system, stale backups,
a newest update that failed, and a scheduled run that stopped. Updates waiting
aren't a problem to raise."""
if c is None:
return []
found = []
reach, says = connection_says(c, now)
if reach == "down":
found.append(finding("warning", "home_assistant_down", "Home Assistant",
f"Home Assistant isn't answering: {says}", since=c.last_ok_at, next="get_home_assistant()"))
problems = problems_open(c.health)
if problems:
more = f" (and {len(problems) - 1} more)" if len(problems) > 1 else ""
found.append(finding("warning", "home_assistant_problem", "Home Assistant", f"{problems[0]['title']}{more}",
next="get_home_assistant()"))
tone, backup_says = backup_verdict(c.backup, now)
if c.last_ok_at is not None and tone == "warn":
found.append(finding("warning", "home_assistant_backup", "Home Assistant", backup_says,
since=parse_moment((c.backup or {}).get("last_ok")), next="get_home_assistant()"))
if latest_run is not None and latest_run.state == "failed":
found.append(finding("warning", "home_assistant_update_failed", latest_run.title,
f"The update of {latest_run.title} failed: {latest_run.message}",
since=latest_run.finished_at, next="get_home_assistant()"))
if plan is not None and plan.stopped:
found.append(finding("warning", "home_assistant_schedule_stopped", "Home Assistant",
f"The scheduled update run stopped: {plan.stopped}", since=weekly.fired_slot(plan),
next="get_home_assistant()"))
return found
async def mcp_overview(db) -> list[dict]:
c = (await db.execute(select(HomeAssistantConnection))).scalars().first()
latest = (await db.execute(select(HomeAssistantUpdate).order_by(
HomeAssistantUpdate.started_at.desc()).limit(1))).scalars().first()
plan = (await db.execute(select(HomeAssistantSchedule))).scalars().first()
return overview_findings(c, latest, now_utc(), plan)
def register_tools(register) -> None:
register(get_home_assistant, role=UserRole.viewer, read_only=True)
register(update_home_assistant, role=UserRole.admin, read_only=False)
register(set_home_assistant_schedule, role=UserRole.admin, read_only=False)
@@ -0,0 +1,59 @@
"""Home Assistant: the connection and the updates Steward was asked to install
Revision ID: homeassistant_001_connection
Revises: (none — a branch off core via depends_on)
Create Date: 2026-10-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "homeassistant_001_connection"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = "homeassistant"
depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",)
def upgrade() -> None:
op.create_table(
"homeassistant_connection",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("url", sa.String(500), nullable=False),
sa.Column("token", sa.Text, nullable=False),
sa.Column("webhook_id", sa.Text, nullable=False),
sa.Column("verify_tls", sa.Boolean, nullable=False, server_default=sa.true()),
sa.Column("refused", sa.Boolean, nullable=False, server_default=sa.false()),
sa.Column("last_polled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_ok_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text, nullable=True),
sa.Column("updates", sa.JSON, nullable=False, server_default="[]"),
sa.Column("backup", sa.JSON, nullable=False, server_default="{}"),
sa.Column("automation", sa.String(20), nullable=True),
)
op.create_table(
"homeassistant_updates",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("entity_id", sa.String(255), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("kind", sa.String(10), nullable=False),
sa.Column("from_version", sa.String(100), nullable=True),
sa.Column("to_version", sa.String(100), nullable=True),
sa.Column("state", sa.String(20), nullable=False, server_default="backing_up"),
sa.Column("message", sa.Text, nullable=True),
sa.Column("started_by", sa.String(100), nullable=False, server_default=""),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("backup_before", sa.String(64), nullable=True),
sa.Column("backup_event_before", sa.String(64), nullable=True),
sa.Column("triggered_before", sa.String(64), nullable=True),
sa.Column("backup_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("install_sent_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
)
op.execute("CREATE UNIQUE INDEX uq_homeassistant_update_under_way ON homeassistant_updates ((true)) "
"WHERE state IN ('backing_up', 'installing')")
def downgrade() -> None:
op.drop_table("homeassistant_updates")
op.drop_table("homeassistant_connection")
@@ -0,0 +1,42 @@
"""Home Assistant: Steward holds a sign-in, in place of a token and a webhook
Revision ID: homeassistant_002_sign_in
Revises: homeassistant_001_connection
Create Date: 2026-10-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "homeassistant_002_sign_in"
down_revision: Union[str, None] = "homeassistant_001_connection"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# A saved token can't be refreshed like a sign-in, so that connection signs in again.
op.execute("DELETE FROM homeassistant_connection")
op.drop_column("homeassistant_connection", "webhook_id")
op.drop_column("homeassistant_connection", "automation")
op.drop_column("homeassistant_updates", "triggered_before")
op.alter_column("homeassistant_connection", "token", new_column_name="refresh_token")
op.add_column("homeassistant_connection", sa.Column("client_id", sa.String(500), nullable=False))
op.add_column("homeassistant_connection",
sa.Column("can_install", sa.Boolean, nullable=False, server_default=sa.false()))
op.add_column("homeassistant_connection",
sa.Column("signed_in_by", sa.String(100), nullable=False, server_default=""))
op.add_column("homeassistant_connection", sa.Column("signed_in_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.execute("DELETE FROM homeassistant_connection")
op.drop_column("homeassistant_connection", "signed_in_at")
op.drop_column("homeassistant_connection", "signed_in_by")
op.drop_column("homeassistant_connection", "can_install")
op.drop_column("homeassistant_connection", "client_id")
op.alter_column("homeassistant_connection", "refresh_token", new_column_name="token")
op.add_column("homeassistant_updates", sa.Column("triggered_before", sa.String(64), nullable=True))
op.add_column("homeassistant_connection", sa.Column("automation", sa.String(20), nullable=True))
op.add_column("homeassistant_connection", sa.Column("webhook_id", sa.Text, nullable=False))
@@ -0,0 +1,37 @@
"""Home Assistant: what the poll reads of the system, and a weekly slot for updates
Revision ID: homeassistant_003_health
Revises: homeassistant_002_sign_in
Create Date: 2026-11-01
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "homeassistant_003_health"
down_revision: Union[str, None] = "homeassistant_002_sign_in"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("homeassistant_connection",
sa.Column("health", sa.JSON, nullable=False, server_default="{}"))
op.create_table(
"homeassistant_schedule",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("enabled", sa.Boolean, nullable=False),
sa.Column("weekday", sa.Integer, nullable=False),
sa.Column("at_time", sa.String(5), nullable=False),
sa.Column("time_zone", sa.String(64), nullable=False),
sa.Column("window_minutes", sa.Integer, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_slot_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("stopped", sa.Text, nullable=True),
)
def downgrade() -> None:
op.drop_table("homeassistant_schedule")
op.drop_column("homeassistant_connection", "health")
@@ -0,0 +1,34 @@
"""Home Assistant: an update the schedule installs can reboot the machine and follow it
Revision ID: homeassistant_004_reboot
Revises: homeassistant_003_health
Create Date: 2026-11-02
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "homeassistant_004_reboot"
down_revision: Union[str, None] = "homeassistant_003_health"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _under_way(states: str) -> None:
op.execute("DROP INDEX uq_homeassistant_update_under_way")
op.execute("CREATE UNIQUE INDEX uq_homeassistant_update_under_way ON homeassistant_updates ((true)) "
f"WHERE state IN ({states})")
def upgrade() -> None:
op.add_column("homeassistant_updates",
sa.Column("reboot_after", sa.Boolean, nullable=False, server_default=sa.false()))
op.add_column("homeassistant_updates", sa.Column("reboot_sent_at", sa.DateTime(timezone=True), nullable=True))
_under_way("'backing_up', 'installing', 'rebooting'")
def downgrade() -> None:
_under_way("'backing_up', 'installing'")
op.drop_column("homeassistant_updates", "reboot_sent_at")
op.drop_column("homeassistant_updates", "reboot_after")
+92
View File
@@ -0,0 +1,92 @@
# plugins/homeassistant/models.py
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import JSON, Boolean, DateTime, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from steward.core.ids import time_ordered_id
from steward.models.base import Base
class HomeAssistantConnection(Base):
"""The Home Assistant instance Steward looks after; at most one row.
`refresh_token` is what Home Assistant's sign-in gave Steward, encrypted. It
has the same access as the user who signed in, whatever that is: Home
Assistant has no narrower permission. `can_install` is whether that user was
an administrator, which backups need. The last_* columns and `updates` and
`backup` and `health` are the newest poll's outcome, which the pages read. `refused` stops
all polling until someone signs in again or tries again: Home Assistant bans
an address after a few refused requests.
"""
__tablename__ = "homeassistant_connection"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
url: Mapped[str] = mapped_column(String(500), nullable=False)
client_id: Mapped[str] = mapped_column(String(500), nullable=False)
refresh_token: Mapped[str] = mapped_column(Text, nullable=False)
can_install: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
signed_in_by: Mapped[str] = mapped_column(String(100), nullable=False, default="")
signed_in_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
verify_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
refused: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
last_polled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_ok_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
updates: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
backup: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
health: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
class HomeAssistantSchedule(Base):
"""The weekly slot for installing what's waiting; at most one row, none until an
admin sets it. `last_slot_at` is the slot that last fired, and `stopped` says why
that slot ended early. Times are read through steward.core.weekly."""
__tablename__ = "homeassistant_schedule"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
weekday: Mapped[int] = mapped_column(Integer, nullable=False)
at_time: Mapped[str] = mapped_column(String(5), nullable=False)
time_zone: Mapped[str] = mapped_column(String(64), nullable=False)
window_minutes: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
last_slot_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
stopped: Mapped[str | None] = mapped_column(Text, nullable=True)
class HomeAssistantUpdate(Base):
"""One update Steward was asked to install, from the click to its end. It
moves through backing_up and installing to done or failed, and its row
carries everything needed to carry on after Steward restarts. The *_before
columns are Home Assistant's own readings at the start, so "a newer backup"
is judged on its clock alone. One can be under way at a time. `reboot_after`
sends it through rebooting too, for an OS update the schedule installs.
"""
__tablename__ = "homeassistant_updates"
__table_args__ = (
Index("uq_homeassistant_update_under_way", text("(true)"), unique=True,
postgresql_where=text("state IN ('backing_up', 'installing', 'rebooting')")),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
entity_id: Mapped[str] = mapped_column(String(255), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
kind: Mapped[str] = mapped_column(String(10), nullable=False)
from_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
to_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
state: Mapped[str] = mapped_column(String(20), nullable=False, default="backing_up")
message: Mapped[str | None] = mapped_column(Text, nullable=True)
started_by: Mapped[str] = mapped_column(String(100), nullable=False, default="")
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
backup_before: Mapped[str | None] = mapped_column(String(64), nullable=True)
backup_event_before: Mapped[str | None] = mapped_column(String(64), nullable=True)
backup_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
install_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
reboot_after: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
reboot_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+17
View File
@@ -0,0 +1,17 @@
# plugins/homeassistant/plugin.yaml
name: homeassistant
version: "1.0.0"
description: "Keeps Home Assistant running: shows whether backups are happening and whether the system has a problem, and installs Core, OS and add-on updates after a confirmed backup, on a click or a weekly schedule."
author: "Steward"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/FabledSteward"
homepage: "https://git.fabledsword.com/bvandeusen/FabledSteward/src/branch/dev/plugins/homeassistant"
tags:
- home-assistant
- updates
- backups
# The connection is added and edited on the Home Assistant page
# (/plugins/homeassistant/).
config: {}
+249
View File
@@ -0,0 +1,249 @@
"""The Home Assistant page. Every GET reads Steward's own records, which the poll
fills. Only signing in, removing the connection and Install, an admin's explicit
actions, ask Home Assistant anything (System 178's ruling)."""
from __future__ import annotations
import hmac
import logging
import secrets
from datetime import datetime, timezone
from types import SimpleNamespace
from quart import Blueprint, current_app, redirect, render_template, request, session
from sqlalchemy import select
from steward.auth.middleware import require_role
from steward.core import weekly
from steward.core.audit import log_audit
from steward.core.crypto import encrypt_secret
from steward.core.formatting import zone
from steward.core.settings import public_base_url
from steward.models.users import User, UserRole
from . import client, schedule
from .models import HomeAssistantConnection, HomeAssistantSchedule, HomeAssistantUpdate
from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment, problems_open
from .updates import UNDER_WAY, CannotStart, start_update
logger = logging.getLogger(__name__)
homeassistant_bp = Blueprint("homeassistant", __name__, template_folder="templates")
RECENT_UPDATES = 10
_SIGN_IN = "homeassistant_sign_in" # in the session between leaving for Home Assistant and coming back
def _actor() -> tuple[str | None, str]:
return session.get("user_id"), session.get("username", "")
async def _connection(db) -> HomeAssistantConnection | None:
return (await db.execute(select(HomeAssistantConnection))).scalars().first()
def connection_says(connection, now: datetime) -> tuple[str, str]:
"""(badge state, sentence) for whether Home Assistant is answering."""
if connection.refused:
return "down", f"{connection.last_error} Steward has stopped asking until you sign in again or try again."
if connection.last_polled_at is None:
return "pending", "Not read yet: the first read runs within seconds."
if connection.last_error:
return "down", connection.last_error
return "up", "Answering."
async def _render_index(error: str | None = None, status: int = 200):
async with current_app.db_sessionmaker() as db:
connection = await _connection(db)
runs = list((await db.execute(select(HomeAssistantUpdate).order_by(
HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars())
plan = (await db.execute(select(HomeAssistantSchedule))).scalars().first()
me = await db.get(User, session.get("user_id")) if session.get("user_id") else None
if connection is None:
return await render_template("homeassistant/index.html", connection=None, error=error), status
default_zone = (me.time_zone if me and me.time_zone else "UTC")
now = datetime.now(timezone.utc)
reach, says = connection_says(connection, now)
updates = connection.updates or []
backup = connection.backup or {}
tone, backup_says = backup_verdict(backup, now)
return await render_template(
"homeassistant/index.html", connection=connection, error=error, reach=reach, says=says,
installable=installable(connection),
others=[u for u in updates if u["waiting"] and not u["kind"]],
current=[u for u in updates if not u["waiting"] and u["kind"]],
runs=runs, under_way=next((r for r in runs if r.state in UNDER_WAY), None),
backup=backup, backup_tone=tone, backup_says=backup_says,
backup_at=parse_moment(backup.get("last_ok")), backup_next=parse_moment(backup.get("next")),
backup_age=backup_age_hours(backup, now), stale_hours=STALE_BACKUP_HOURS,
health=connection.health or {}, problems=problems_open(connection.health),
plan=plan, plan_when=weekly.describe(plan) if plan else None,
plan_next=weekly.next_slot(plan, now) if plan and plan.enabled else None,
weekdays=weekly.WEEKDAYS, default_zone=default_zone), status
@homeassistant_bp.get("/")
@require_role(UserRole.viewer)
async def index():
return await _render_index()
@homeassistant_bp.post("/install")
@require_role(UserRole.admin)
async def install():
form = await request.form
entity_id = (form.get("entity_id") or "").strip()
user_id, username = _actor()
try:
run = await start_update(current_app, entity_id, username)
except CannotStart as exc:
return await _render_index(f"Nothing was started: {exc}.", 409)
await log_audit(current_app, user_id, username, "homeassistant.update_started",
entity_type="homeassistant_update", entity_id=run.id,
detail={"entity_id": entity_id, "title": run.title, "to": run.to_version})
return redirect("/plugins/homeassistant/")
@homeassistant_bp.post("/schedule")
@require_role(UserRole.admin)
async def save_schedule():
"""Set the weekly slot for installing what's waiting, or turn it off."""
form = await request.form
time_zone = (form.get("time_zone") or "UTC").strip()
try:
weekday, at_time = int(form.get("weekday", "")), (form.get("at_time") or "").strip()
weekly.parse_at(at_time)
except ValueError:
return await _render_index("Choose a day and a time.", 422)
if not 0 <= weekday <= 6 or (zone(time_zone) is timezone.utc and time_zone.upper() != "UTC"):
return await _render_index(f"Choose a day, and a known time zone rather than “{time_zone}”.", 422)
row = await schedule.save(current_app, enabled=bool(form.get("enabled")), weekday=weekday,
at_time=at_time, time_zone=time_zone)
await log_audit(current_app, *_actor(), "homeassistant.schedule_saved", entity_type="homeassistant_schedule",
entity_id=row.id, detail={"enabled": row.enabled, "when": weekly.describe(row)})
return redirect("/plugins/homeassistant/")
# ── The connection ───────────────────────────────────────────────────────────
async def _render_form(connection, url: str | None = None, verify_tls: bool | None = None,
error: str | None = None, status: int = 200):
return await render_template(
"homeassistant/form.html", connection=connection, error=error,
url=url if url is not None else (connection.url if connection else ""),
verify_tls=verify_tls if verify_tls is not None else (connection.verify_tls if connection else True)), status
@homeassistant_bp.get("/connection")
@require_role(UserRole.admin)
async def connection_form():
async with current_app.db_sessionmaker() as db:
connection = await _connection(db)
return await _render_form(connection)
@homeassistant_bp.post("/connection/sign-in")
@require_role(UserRole.admin)
async def sign_in():
"""Send the browser to Home Assistant's own sign-in page."""
form = await request.form
url = (form.get("url") or "").strip().rstrip("/")
verify_tls = bool(form.get("verify_tls"))
if not url.startswith(("http://", "https://")):
async with current_app.db_sessionmaker() as db:
connection = await _connection(db)
return await _render_form(connection, url, verify_tls, "Enter Home Assistant's address: http://… or https://…", 422)
base, state = public_base_url(request), secrets.token_urlsafe(32)
client_id = f"{base}/"
session[_SIGN_IN] = {"state": state, "url": url, "verify_tls": verify_tls, "client_id": client_id}
return redirect(client.sign_in_url(url, client_id, f"{base}/plugins/homeassistant/connection/callback", state))
@homeassistant_bp.get("/connection/callback")
@require_role(UserRole.admin)
async def sign_in_callback():
"""Where Home Assistant sends the browser back. A GET that asks Home Assistant
something, because the admin's sign-in has only just happened and its code
lasts minutes. The state shows the sign-in began here, for this admin."""
pending = session.pop(_SIGN_IN, None)
state, code = request.args.get("state", ""), request.args.get("code", "")
async with current_app.db_sessionmaker() as db:
connection = await _connection(db)
if not pending or not hmac.compare_digest(pending["state"].encode(), state.encode()):
return await _render_form(connection, error="That sign-in didn't start from this page, so it wasn't used. "
"If it keeps happening, check Public base URL in Settings → General: "
"Steward must be opened at that address.", status=400)
url, verify_tls, client_id = pending["url"], pending["verify_tls"], pending["client_id"]
if not code:
return await _render_form(connection, url, verify_tls, "Home Assistant didn't finish the sign-in. Nothing changed.", 400)
try:
refresh_token, token = await client.redeem_code(url, client_id, code, verify_tls)
can_install = await client.is_admin(url, token, verify_tls)
except (client.Refused, client.Unreachable) as exc:
return await _render_form(connection, url, verify_tls, f"The sign-in couldn't be finished: {exc}", 502)
user_id, username = _actor()
previous = None
async with current_app.db_sessionmaker() as db:
async with db.begin():
connection = await _connection(db)
if connection is None:
connection = HomeAssistantConnection(url=url, client_id=client_id, refresh_token="")
db.add(connection)
else:
previous = (connection.url, connection.client_id, connection.refresh_token, connection.verify_tls)
connection.url, connection.client_id, connection.verify_tls = url, client_id, verify_tls
connection.refresh_token = encrypt_secret(refresh_token)
connection.can_install, connection.signed_in_by = can_install, username
connection.signed_in_at = datetime.now(timezone.utc)
connection.refused, connection.last_error, connection.last_polled_at = False, None, None
await db.flush()
connection_id = connection.id
if previous is not None:
await _end_sign_in(SimpleNamespace(url=previous[0], client_id=previous[1], refresh_token=previous[2],
verify_tls=previous[3]))
await log_audit(current_app, user_id, username, "homeassistant.connection_saved",
entity_type="homeassistant_connection", entity_id=connection_id,
detail={"name": url, "can_install": can_install})
return redirect("/plugins/homeassistant/")
async def _end_sign_in(connection) -> bool:
"""End a sign-in Steward no longer needs, so it doesn't stay valid on Home
Assistant. It can be ended there by hand too, so failing here stops nothing."""
try:
await client.revoke(connection)
except (client.Refused, client.Unreachable) as exc:
logger.warning("Home Assistant: couldn't end Steward's old sign-in: %s", exc)
return False
return True
@homeassistant_bp.post("/connection/retry")
@require_role(UserRole.admin)
async def retry_connection():
"""Ask again after a refusal, once a ban has been dealt with."""
async with current_app.db_sessionmaker() as db:
async with db.begin():
connection = await _connection(db)
if connection is not None:
connection.refused, connection.last_polled_at = False, None
return redirect("/plugins/homeassistant/")
@homeassistant_bp.post("/connection/delete")
@require_role(UserRole.admin)
async def delete_connection():
async with current_app.db_sessionmaker() as db:
async with db.begin():
connection = await _connection(db)
if connection is None:
return redirect("/plugins/homeassistant/")
connection_id, url = connection.id, connection.url
await db.delete(connection)
ended = await _end_sign_in(connection)
await log_audit(current_app, *_actor(), "homeassistant.connection_removed",
entity_type="homeassistant_connection", entity_id=connection_id, detail={"name": url, "sign_in_ended": ended})
if not ended:
return await _render_index("Removed from Steward, but Home Assistant couldn't be reached to end the sign-in. "
"End it yourself under Security on that user's profile in Home Assistant.")
return redirect("/plugins/homeassistant/")
+95
View File
@@ -0,0 +1,95 @@
# plugins/homeassistant/schedule.py
"""Installing what's waiting at a weekly slot: one at a time through the same path
as the page's Install, each after its own confirmed backup. Add-ons go first and
the OS last, as it reboots the machine. The first failure ends the slot, leaving the
rest for someone to look at. Everything is on the rows, so a restart of Steward or
of Home Assistant only delays the next step."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from steward.core import weekly
from .models import HomeAssistantConnection, HomeAssistantSchedule, HomeAssistantUpdate
from .service import installable
from .updates import CannotStart, start_update, under_way
ACTOR = "schedule"
WINDOW_MINUTES = 240
_ORDER = {"addon": 0, "core": 1, "os": 2}
def next_to_install(waiting: list[dict], tried: set[str]) -> dict | None:
left = [u for u in waiting if u["entity_id"] not in tried and not u["in_progress"]]
return min(left, key=lambda u: _ORDER[u["kind"]], default=None)
async def _stop(app, schedule_id: str, why: str) -> None:
async with app.db_sessionmaker() as session:
async with session.begin():
row = await session.get(HomeAssistantSchedule, schedule_id)
if row is not None:
row.stopped = why
async def _fire(app, schedule_id: str, slot: datetime) -> None:
"""Open the slot, and have the next poll say what's waiting at this moment."""
async with app.db_sessionmaker() as session:
async with session.begin():
row = await session.get(HomeAssistantSchedule, schedule_id)
row.last_slot_at, row.stopped = slot, None
connection = (await session.execute(select(HomeAssistantConnection))).scalars().first()
if connection is not None:
connection.last_polled_at = None
async def step(app, transport=None) -> None:
"""Fire a slot that has come, or start the next update of one that's open."""
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as session:
schedule = (await session.execute(select(HomeAssistantSchedule))).scalars().first()
connection = (await session.execute(select(HomeAssistantConnection))).scalars().first()
if schedule is None or connection is None:
return
slot = weekly.is_due(schedule, now)
if slot is not None:
await _fire(app, schedule.id, slot)
return
slot = weekly.fired_slot(schedule)
if not schedule.enabled or schedule.stopped or slot is None \
or now - slot >= timedelta(minutes=schedule.window_minutes):
return
# Only on a fresh, good read: Home Assistant may be restarting from the last update.
if connection.refused or connection.last_polled_at is None or connection.last_error:
return
if await under_way(session) is not None:
return
runs = (await session.execute(select(HomeAssistantUpdate).where(
HomeAssistantUpdate.started_at >= slot))).scalars().all()
failed = next((r for r in runs if r.state == "failed"), None)
if failed is not None:
await _stop(app, schedule.id, f"{failed.title}: {failed.message} Nothing more was installed in this slot.")
return
update = next_to_install(installable(connection), {r.entity_id for r in runs})
if update is None:
return
try:
await start_update(app, update["entity_id"], ACTOR, transport, reboot_after=update["kind"] == "os")
except CannotStart as exc:
await _stop(app, schedule.id, f"{update['title']} wasn't started: {exc}. Nothing more was installed in this slot.")
async def save(app, *, enabled: bool, weekday: int, at_time: str, time_zone: str) -> HomeAssistantSchedule:
"""Set the slot. Its first is the next one from now, not one already past."""
async with app.db_sessionmaker() as session:
async with session.begin():
row = (await session.execute(select(HomeAssistantSchedule))).scalars().first()
if row is None:
row = HomeAssistantSchedule(window_minutes=WINDOW_MINUTES)
session.add(row)
row.enabled, row.weekday, row.time_zone = enabled, weekday, time_zone
row.at_time = f"{weekly.parse_at(at_time):%H:%M}"
row.last_slot_at, row.stopped = datetime.now(timezone.utc), None
return row
+116
View File
@@ -0,0 +1,116 @@
# plugins/homeassistant/scheduler.py
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from sqlalchemy import select
from steward.core.scheduler import ScheduledTask
from . import client, schedule
from .models import HomeAssistantConnection, HomeAssistantSchedule, HomeAssistantUpdate
from .service import RESOURCE, SOURCE, backup_age_hours, installable, read_health, read_snapshot
from .updates import advance
if TYPE_CHECKING:
from quart import Quart
logger = logging.getLogger(__name__)
# An update under way is followed each tick, with one small read of its entity.
TICK_SECONDS = 15
# The whole entity list is a few hundred KB, and updates and backups change rarely.
POLL_SECONDS = 300
# A step past this is given up until the next tick, whatever the client's own
# timeouts did: a slow drip of bytes can keep each read inside its timeout for ever.
STEP_DEADLINE_SECONDS = 60
def make_tick_task(app: "Quart") -> ScheduledTask:
busy = asyncio.Lock()
async def tick() -> None:
if busy.locked():
return
async with busy:
for step in (poll, advance, schedule.step):
try:
await asyncio.wait_for(step(app), STEP_DEADLINE_SECONDS)
except TimeoutError:
logger.warning("Home Assistant %s gave up after %d s", step.__name__, STEP_DEADLINE_SECONDS)
return ScheduledTask(name="homeassistant_tick", coro_factory=tick,
interval_seconds=TICK_SECONDS, run_on_startup=True)
def is_due(connection, now: datetime) -> bool:
"""Whether to poll. Never after a refusal: another refused request counts
toward Home Assistant banning this address."""
if connection.refused:
return False
if connection.last_polled_at is None:
return True
return (now - connection.last_polled_at).total_seconds() >= POLL_SECONDS - TICK_SECONDS / 2
async def update_failed(session) -> bool:
"""Whether the newest update failed, or the schedule couldn't start one."""
stopped = (await session.execute(select(HomeAssistantSchedule.stopped))).scalars().first()
newest = (await session.execute(select(HomeAssistantUpdate.state).order_by(
HomeAssistantUpdate.started_at.desc()).limit(1))).scalars().first()
return bool(stopped) or newest == "failed"
async def poll(app: "Quart", transport=None, connect=None) -> None:
from steward.core.alerts import MetricBatch
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as session:
connection = (await session.execute(select(HomeAssistantConnection))).scalars().first()
if connection is None or not is_due(connection, now):
return
snapshot, health, error, refused = None, None, None, False
try:
token = await client.access_token(connection, transport)
snapshot = read_snapshot(await client.read_states(connection.url, token, connection.verify_tls, transport))
try:
health = read_health(await client.read_system(connection.url, token, connection.verify_tls, connect))
except client.Unreachable as exc:
# Updates and backups were read, so it's answering. The last report stands, and says it's old.
health = {**(connection.health or {}), "error": str(exc)}
except client.Refused as exc:
snapshot, error, refused = None, str(exc), True
except client.Unreachable as exc:
error = str(exc)
async with app.db_sessionmaker() as session:
async with session.begin():
row = await session.get(HomeAssistantConnection, connection.id)
if row is None: # removed while it was being polled
return
batch = MetricBatch()
row.last_polled_at, row.last_error, row.refused = now, error, refused
if snapshot is None:
batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="is_up", value=0.0, recorded_at=now)
else:
row.last_ok_at = now
row.updates, row.backup, row.health = snapshot["updates"], snapshot["backup"], health
batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="is_up", value=1.0, recorded_at=now)
batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="updates_waiting",
value=len(installable(row)), recorded_at=now)
age = backup_age_hours(snapshot["backup"], now)
if age is not None:
batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="backup_age_hours",
value=age, recorded_at=now)
if not health.get("error"):
if health["repairs_read"]:
batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="problems_open",
value=len(health["problems"]), recorded_at=now)
if health["disk_free_pct"] is not None:
batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="disk_free_pct",
value=health["disk_free_pct"], recorded_at=now)
batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="update_failed",
value=1.0 if await update_failed(session) else 0.0, recorded_at=now)
await batch.record(session)
+161
View File
@@ -0,0 +1,161 @@
# plugins/homeassistant/service.py
"""Reading what Home Assistant reports: which updates it knows of, which of them
Steward may install, how its backups stand, and whether the system has a problem."""
from __future__ import annotations
from datetime import datetime, timezone
SOURCE = "homeassistant"
RESOURCE = "homeassistant"
# Backups run daily, so past a day and a bit one has been missed.
STALE_BACKUP_HOURS = 26
LOW_DISK_PCT = 10
CORE = "update.home_assistant_core_update"
OS = "update.home_assistant_operating_system_update"
SUPERVISOR = "update.home_assistant_supervisor_update"
LAST_BACKUP = "sensor.backup_last_successful_automatic_backup"
NEXT_BACKUP = "sensor.backup_next_scheduled_automatic_backup"
BACKUP_MANAGER = "sensor.backup_backup_manager_state"
BACKUP_EVENT = "event.backup_automatic_backup"
_KIND_WORDS = {"core": "Core", "os": "Operating system", "addon": "Add-on"}
_KIND_ORDER = {"core": 0, "os": 1, "addon": 2, None: 3}
def classify(entity: dict) -> str | None:
""""core", "os" or "addon" for an update Steward may install; None for the
rest: Supervisor updates itself, and firmware and custom integrations are
Home Assistant's own to offer."""
entity_id = entity.get("entity_id")
if entity_id == CORE:
return "core"
if entity_id == OS:
return "os"
if entity_id == SUPERVISOR:
return None
picture = (entity.get("attributes") or {}).get("entity_picture") or ""
return "addon" if picture.startswith("/api/hassio/addons/") else None
def _update(entity: dict) -> dict:
a = entity.get("attributes") or {}
kind = classify(entity)
url = a.get("release_url") or ""
return {"entity_id": entity["entity_id"], "kind": kind, "kind_word": _KIND_WORDS.get(kind, "Other"),
"title": a.get("title") or a.get("friendly_name") or entity["entity_id"],
"installed": a.get("installed_version"), "latest": a.get("latest_version"),
"waiting": entity.get("state") == "on", "in_progress": bool(a.get("in_progress")),
"auto_update": bool(a.get("auto_update")),
"release_url": url if url.startswith(("http://", "https://")) else None}
def _state(entity: dict | None) -> str | None:
value = (entity or {}).get("state")
return None if value in (None, "unknown", "unavailable") else value
def parse_moment(value: str | None) -> datetime | None:
try:
moment = datetime.fromisoformat(value) if value else None
except ValueError:
return None
if moment is not None and moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
return moment
def is_newer(value: str | None, than: str | None) -> bool:
"""Whether reading `value` is a later moment than `than`, where `than` may be never."""
moment, before = parse_moment(value), parse_moment(than)
return moment is not None and (before is None or moment > before)
def read_snapshot(states: list[dict]) -> dict:
"""What a poll keeps of Home Assistant's entities."""
by_id = {e.get("entity_id"): e for e in states}
updates = [_update(e) for i, e in by_id.items() if (i or "").startswith("update.") and e.get("state") != "unavailable"]
updates.sort(key=lambda u: (not u["waiting"], _KIND_ORDER[u["kind"]], u["title"].lower()))
event = by_id.get(BACKUP_EVENT) or {}
event_attrs = event.get("attributes") or {}
return {
"updates": updates,
"backup": {"last_ok": _state(by_id.get(LAST_BACKUP)), "next": _state(by_id.get(NEXT_BACKUP)),
"manager": _state(by_id.get(BACKUP_MANAGER)), "last_result": event_attrs.get("event_type"),
"failed_reason": event_attrs.get("failed_reason"), "known": LAST_BACKUP in by_id},
}
def backup_age_hours(backup: dict | None, now: datetime) -> float | None:
last = parse_moment((backup or {}).get("last_ok"))
return None if last is None else max(0.0, (now - last).total_seconds() / 3600)
def installable(connection) -> list[dict]:
"""The waiting updates Steward may install."""
return [u for u in (connection.updates or []) if u["waiting"] and u["kind"]]
def backup_verdict(backup: dict | None, now: datetime) -> tuple[str, str]:
"""(tone, sentence) for how the backups stand."""
backup = backup or {}
if not backup.get("known"):
return "warn", "Home Assistant reports no automatic backups. Turn them on under Settings → System → Backups."
age = backup_age_hours(backup, now)
if age is None:
return "warn", "Home Assistant has no successful automatic backup yet."
if backup.get("last_result") == "failed":
reason = backup.get("failed_reason")
return "warn", f"The newest backup attempt failed{f' ({reason})' if reason else ''}."
if age > STALE_BACKUP_HOURS:
return "warn", f"The last good backup is {age:.0f} hours old, past the {STALE_BACKUP_HOURS} hours expected."
return "ok", "Backups are up to date."
def _issue_title(issue: dict, titles: dict) -> str:
key = issue.get("translation_key") or issue.get("issue_id") or ""
title = titles.get(f"component.{issue.get('domain')}.issues.{key}.title")
if not title:
return key.removeprefix("issue_").replace("_", " ").capitalize() or "A repair is open"
for name, value in (issue.get("translation_placeholders") or {}).items():
title = title.replace("{" + name + "}", str(value))
return title
def read_health(found: dict) -> dict:
"""What a poll keeps of client.read_system: the problems to show, and the disk.
A part Home Assistant wouldn't answer is left out rather than read as fine:
`repairs_read` says whether the repairs were seen, and `supervised` whether a
Supervisor answered, as it doesn't on every install or for every user."""
problems = []
if (found.get("config") or {}).get("recovery_mode"):
problems.append({"kind": "recovery", "severity": "critical",
"title": "Home Assistant started in recovery mode: its configuration didn't load."})
issues = found.get("issues")
for issue in issues or []:
if not issue.get("ignored") and not issue.get("dismissed_version"):
problems.append({"kind": "repair", "severity": issue.get("severity") or "warning",
"title": _issue_title(issue, found.get("titles") or {})})
resolution = found.get("resolution") or {}
for word in ("unhealthy", "unsupported"):
for reason in resolution.get(word) or []:
problems.append({"kind": "supervisor", "severity": "error" if word == "unhealthy" else "warning",
"title": f"The Supervisor calls this system {word}: {str(reason).replace('_', ' ')}."})
host = found.get("host") or {}
free, used, total = host.get("disk_free"), host.get("disk_used"), host.get("disk_total")
free_pct = round(100 * free / total, 1) if free is not None and total else None
# Used and free don't add up to the total: the system reserves some of the disk.
used_pct = round(100 * used / total, 1) if used is not None and total else None
if free_pct is not None and free_pct < LOW_DISK_PCT:
problems.append({"kind": "disk", "severity": "error", "title": f"Only {free:g} GB of {total:g} GB free."})
boots = found.get("boots") or {}
for addon in found.get("addons") or []:
if boots.get(addon.get("slug")) == "auto":
problems.append({"kind": "addon", "severity": "error",
"title": f"{addon.get('name') or addon.get('slug')} should start on its own and isn't running."})
return {"repairs_read": issues is not None, "supervised": found.get("resolution") is not None, "problems": problems,
"disk_free_gb": free, "disk_used_gb": used, "disk_total_gb": total,
"disk_free_pct": free_pct, "disk_used_pct": used_pct}
def problems_open(health: dict | None) -> list[dict]:
return (health or {}).get("problems") or []
@@ -0,0 +1,50 @@
{# plugins/homeassistant/templates/homeassistant/form.html — where Home Assistant
is, what signing in gives Steward, and leaving for its sign-in page. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{% block title %}Home Assistant connection — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Home Assistant", "/plugins/homeassistant/"), ("Connection", "")]) }}{% endblock %}
{% block content %}
{{ page_header("Home Assistant connection") }}
<div class="form-page">
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
{% if connection %}
<p class="field-hint">Signed in{% if connection.signed_in_at %} {{ connection.signed_in_at|ago }}{% endif %}{% if connection.signed_in_by %} by {{ connection.signed_in_by }}{% endif %}, as {{ "an administrator: Steward can install updates" if connection.can_install else "a user who isn't an administrator: Steward shows status only" }}.</p>
{% endif %}
<form method="post" action="/plugins/homeassistant/connection/sign-in">{{ csrf_field() }}
<section class="card form-section" aria-labelledby="ha-sign-in">
<h2 id="ha-sign-in" class="section-title">Sign in with Home Assistant</h2>
<div class="field">
<label class="field-label" for="ha-url">Address</label>
<input class="input mono" id="ha-url" name="url" required maxlength="500" value="{{ url or '' }}" placeholder="http://homeassistant.local:8123" autocomplete="off">
<span class="field-hint">Both your browser and Steward need to reach it at this address.</span>
</div>
<label style="display:flex;gap:0.5rem;align-items:center;cursor:pointer;margin-bottom:0.9rem;">
<input type="checkbox" name="verify_tls" value="on"{% if verify_tls %} checked{% endif %}> Check its HTTPS certificate
</label>
<p class="tone-warn"><strong>Steward stores a credential with the same access as the user who signs in.</strong> Home Assistant has no narrower permission, so an administrator's credential can do anything that administrator can, including controlling every device. Steward only uses it to read status, take backups and install updates.</p>
<p class="field-hint"><strong>We recommend a service account:</strong> a Home Assistant user made just for Steward (Settings → People → Add person). What Steward does then appears under its own name in Home Assistant's logs, so it's easy to tell apart from what people do.</p>
<p class="field-hint" style="margin-bottom:0;">Installing updates needs an administrator. With any other user, Steward shows status only.</p>
</section>
<div class="form-actions">
<button type="submit" class="btn">{{ "Sign in again" if connection else "Continue to Home Assistant" }}</button>
<a href="/plugins/homeassistant/" class="btn btn-ghost">Cancel</a>
</div>
</form>
{% if connection %}
<section class="card danger-zone" aria-labelledby="ha-remove">
<div>
<h2 id="ha-remove" class="section-title">Remove this connection</h2>
<p class="field-hint">Steward stops reading Home Assistant, forgets its credential and ends the sign-in there.</p>
</div>
<form method="post" action="/plugins/homeassistant/connection/delete"
data-confirm="Remove the Home Assistant connection? Steward stops reading it and forgets its credential.">{{ csrf_field() }}
<button class="btn btn-danger" type="submit">Remove connection</button>
</form>
</section>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,150 @@
{# plugins/homeassistant/templates/homeassistant/index.html — whether Home
Assistant answers, which updates are waiting with Install beside each, how its
backups stand, and the updates Steward has run. Refreshes itself while one is
under way. #}
{% extends "base.html" %}
{% from "_macros.html" import page_header, status_badge, stat_tile, empty_state %}
{% block title %}Home Assistant — Steward{% endblock %}
{% block content %}
{% set admin = session.user_role == 'admin' %}
{% call page_header("Home Assistant", connection.url if connection else none) %}
{% if admin and connection %}<a class="btn btn-sm btn-ghost" href="/plugins/homeassistant/connection">Connection</a>{% endif %}
{% endcall %}
{% if not connection %}
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
<div class="card">{{ empty_state("Not connected. Sign in to a Home Assistant instance to see its waiting updates and backups, and to install updates after a confirmed backup.",
"Connect" if admin else none, "/plugins/homeassistant/connection") }}</div>
{% else %}
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
<div id="ha-body"{% if under_way %} hx-get="/plugins/homeassistant/" hx-select="#ha-body" hx-trigger="every 15s" hx-swap="outerHTML"{% endif %}>
<p class="page-verdict">
{% if under_way %}{{ status_badge('pending', 'Updating') }}
{{ under_way.title }}: {{ {'backing_up': "taking a backup first", 'rebooting': "rebooting into " ~ under_way.to_version}.get(under_way.state, "installing " ~ under_way.to_version) }}. Home Assistant may stop answering for a while.
{% elif reach == 'up' %}{{ status_badge('up', 'Answering') }}
{% if installable %}{{ installable|length }} update{{ "s" if installable|length != 1 }} waiting.{% else %}Everything Steward can update is up to date.{% endif %}
Read {{ connection.last_polled_at|ago }}.
{% elif reach == 'down' %}{{ status_badge('down', 'Not answering') }} <span class="tone-crit">{{ says }}</span>
{% if connection.last_ok_at %}Last answered {{ connection.last_ok_at|ago }}; what's below is from then.{% endif %}
{% if admin and connection.refused %}<form method="post" action="/plugins/homeassistant/connection/retry" style="display:inline;">{{ csrf_field() }}<button class="btn btn-sm btn-ghost" type="submit">Try again</button></form>{% endif %}
{% else %}{{ status_badge('pending', 'Not read yet') }} {{ says }}{% endif %}
</p>
{% if not connection.can_install %}
<p class="tone-warn">The Home Assistant user who signed in isn't an administrator, so updates can't be installed from here.
{% if admin %}<a href="/plugins/homeassistant/connection">Sign in again</a> as an administrator to install them.{% endif %}</p>
{% endif %}
<div class="stat-strip">
{{ stat_tile("System problems", problems|length if health.repairs_read else "—", state="degraded" if problems else none) }}
{{ stat_tile("Storage", ("%s of %s GB (%.0f%%)"|format(health.disk_used_gb, health.disk_total_gb, health.disk_used_pct)) if health.disk_used_pct is number else "—") }}
{{ stat_tile("Updates waiting", installable|length if connection.last_ok_at else "—") }}
{{ stat_tile("Last good backup", (backup_at|ago) if backup_at else "—", state="degraded" if backup_tone == 'warn' else none) }}
{{ stat_tile("Next backup", (backup_next|ago) if backup_next else "—") }}
</div>
<p class="{{ 'tone-warn' if backup_tone == 'warn' else 'field-hint' }}">{{ backup_says }}</p>
<section class="card" aria-labelledby="ha-system">
<h2 id="ha-system" class="section-title">System</h2>
{% if problems %}
<table class="table">
<thead><tr><th scope="col">Problem</th><th scope="col">Severity</th></tr></thead>
<tbody>
{% for p in problems %}<tr><td>{{ p.title }}</td><td>{{ status_badge('down' if p.severity in ('critical', 'error') else 'degraded', p.severity|capitalize) }}</td></tr>{% endfor %}
</tbody>
</table>
<p class="field-hint">Fix these in Home Assistant: Settings → System → Repairs.</p>
{% elif health.repairs_read %}
<p class="field-hint">No repairs open, and nothing wrong reported.</p>
{% elif connection.last_ok_at %}
<p class="tone-warn">Home Assistant wouldn't share its repairs with this user. {% if admin %}<a href="/plugins/homeassistant/connection">Sign in again</a> as an administrator to see them.{% endif %}</p>
{% endif %}
{% if health.repairs_read and not health.supervised %}<p class="field-hint">No Supervisor answered, so disk space and add-ons aren't checked here.</p>{% endif %}
{% if health.error %}<p class="tone-warn" style="margin-bottom:0;">This is from an earlier read. {{ health.error }}</p>{% endif %}
</section>
<section class="card" aria-labelledby="ha-updates">
<h2 id="ha-updates" class="section-title">Updates</h2>
{% if installable %}
<table class="table">
<thead><tr><th scope="col">What</th><th scope="col">Now</th><th scope="col">New</th>{% if admin %}<th scope="col"></th>{% endif %}</tr></thead>
<tbody>
{% for u in installable %}
<tr>
<td>{{ u.title }}<div class="cell-sub">{{ u.kind_word }}{% if u.auto_update %}, updates itself{% endif %}</div></td>
<td class="mono">{{ u.installed or "—" }}</td>
<td class="mono">{% if u.release_url %}<a href="{{ u.release_url }}" rel="noopener noreferrer" target="_blank">{{ u.latest or "—" }}</a>{% else %}{{ u.latest or "—" }}{% endif %}</td>
{% if admin %}<td style="text-align:right;">
{% if connection.can_install and not under_way and not connection.refused and not u.in_progress %}
<form method="post" action="/plugins/homeassistant/install"
data-confirm="Install {{ u.title }} {{ u.latest }}? Steward takes a backup first and won't install without it. {{ 'Home Assistant restarts.' if u.kind == 'core' else ('It takes effect once you reboot the machine, which Steward leaves to you.' if u.kind == 'os' else 'The add-on restarts.') }}">{{ csrf_field() }}
<input type="hidden" name="entity_id" value="{{ u.entity_id }}">
<button class="btn btn-sm" type="submit">Install</button>
</form>
{% endif %}
</td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="field-hint" style="margin:0;">Nothing waiting.</p>
{% endif %}
{% if current %}<p class="field-hint">Up to date: {% for u in current %}{{ u.title }} {{ u.installed }}{{ ", " if not loop.last }}{% endfor %}.</p>{% endif %}
{% if others %}<p class="field-hint" style="margin-bottom:0;">Waiting, for Home Assistant to install: {% for u in others %}{{ u.title }}{{ ", " if not loop.last }}{% endfor %}. Steward installs Core, OS and add-on updates only.</p>{% endif %}
</section>
<section class="card" aria-labelledby="ha-schedule">
<h2 id="ha-schedule" class="section-title">Scheduled updates</h2>
{% if plan and plan.enabled %}
<p class="field-hint">{{ plan_when }}. Next: {{ plan_next|ago }}. Add-ons first, then Core, then the OS and its reboot, each after a confirmed backup. A failure ends the run and the rest wait.</p>
{% else %}
<p class="field-hint">Off. Updates are only installed when someone chooses Install.</p>
{% endif %}
{% if plan and plan.stopped %}<p class="tone-warn">The last run stopped early. {{ plan.stopped }}</p>{% endif %}
{% if admin %}
<p class="tone-warn">A Core update restarts Home Assistant and an OS update reboots the machine, with nobody there to watch. An update can also change how automations behave.</p>
<form method="post" action="/plugins/homeassistant/schedule" style="display:flex;flex-wrap:wrap;gap:0.75rem;align-items:flex-end;">{{ csrf_field() }}
<label style="display:flex;gap:0.5rem;align-items:center;cursor:pointer;margin-bottom:0.4rem;">
<input type="checkbox" name="enabled" value="on"{% if plan and plan.enabled %} checked{% endif %}> Install on a schedule
</label>
<div class="field" style="margin-bottom:0;">
<label class="field-label" for="ha-day">Day</label>
<select class="input" id="ha-day" name="weekday">{% for d in weekdays %}<option value="{{ loop.index0 }}"{% if loop.index0 == (plan.weekday if plan else 5) %} selected{% endif %}>{{ d }}</option>{% endfor %}</select>
</div>
<div class="field" style="margin-bottom:0;">
<label class="field-label" for="ha-time">Time</label>
<input class="input" id="ha-time" type="time" name="at_time" required value="{{ plan.at_time if plan else '05:00' }}">
</div>
<div class="field" style="margin-bottom:0;">
<label class="field-label" for="ha-zone">Time zone</label>
<input class="input" id="ha-zone" name="time_zone" required value="{{ plan.time_zone if plan else default_zone }}">
</div>
<button class="btn btn-sm" type="submit">Save</button>
</form>
{% endif %}
</section>
{% if runs %}
<section class="card" aria-labelledby="ha-runs">
<h2 id="ha-runs" class="section-title">Updates run from Steward</h2>
<table class="table">
<thead><tr><th scope="col">What</th><th scope="col">Versions</th><th scope="col">Outcome</th><th scope="col">Started</th></tr></thead>
<tbody>
{% for r in runs %}
<tr>
<td>{{ r.title }}<div class="cell-sub">by {{ r.started_by or "Steward" }}</div></td>
<td class="mono">{{ r.from_version or "—" }} → {{ r.to_version or "—" }}</td>
<td>{% if r.state == 'done' %}{{ status_badge('up', 'Done') }}
{% elif r.state == 'failed' %}{{ status_badge('down', 'Failed') }}<div class="cell-sub">{{ r.message }}</div>
{% else %}{{ status_badge('pending', {'backing_up': 'Taking a backup', 'rebooting': 'Rebooting'}.get(r.state, 'Installing')) }}{% endif %}</td>
<td>{{ r.started_at|ago }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
</div>
{% endif %}
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
# plugins/homeassistant/timeline.py
"""What Home Assistant maintenance puts on the timeline: each update once it ends."""
from __future__ import annotations
from steward.timeline import catalog
from steward.timeline.record import record_or_update
TYPES = (
("homeassistant.updated", "Home Assistant updated", "automation", "service", True,
"Steward installed a Home Assistant update after a confirmed backup."),
("homeassistant.update_failed", "Home Assistant update failed", "automation", "service", True,
"A Home Assistant update Steward was asked to install didn't finish."),
)
def register() -> None:
for t in TYPES:
catalog.register_type(*t)
from .models import HomeAssistantUpdate
catalog.register_ref("homeassistant_update", HomeAssistantUpdate)
async def update_entry(session, run) -> None:
done = run.state == "done"
versions = f"{run.from_version} to {run.to_version}" if run.to_version else "a new version"
summary = (f"{run.title} updated from {versions}" if done
else f"{run.title} update failed: {run.message}")
await record_or_update(
session, ("homeassistant_update", run.id),
type="homeassistant.updated" if done else "homeassistant.update_failed",
at=run.started_at, until=run.finished_at, summary=summary, subject=run.title,
detail={"entity_id": run.entity_id, "from": run.from_version, "to": run.to_version,
"url": "/plugins/homeassistant/"},
actor=run.started_by or "Steward")
+259
View File
@@ -0,0 +1,259 @@
# plugins/homeassistant/updates.py
"""Installing one update: take a backup, confirm it landed, install, and follow
it to its end. Its row holds where it has got to, so Steward restarting, or Home
Assistant restarting as an update does, only delays the next step."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from . import client
from .models import HomeAssistantConnection, HomeAssistantUpdate
from .service import BACKUP_EVENT, LAST_BACKUP, is_newer, parse_moment
from .timeline import update_entry
logger = logging.getLogger(__name__)
UNDER_WAY = ("backing_up", "installing", "rebooting")
BACKUP_DEADLINE_S = 30 * 60
REBOOT_DEADLINE_S = 20 * 60
# An OS update downloads and writes a whole system image, which is slow on a Pi. Home
# Assistant reports the new version as soon as that's written, before the reboot that
# makes it run, and raises a repair asking for that reboot. The schedule's own OS
# updates make that reboot; one started by a person leaves it to them.
INSTALL_DEADLINE_S = {"addon": 20 * 60, "core": 30 * 60, "os": 45 * 60}
class CannotStart(Exception):
"""Why an update wasn't started, said to the person."""
async def _mark_refused(app, connection_id: str, said: str) -> None:
async with app.db_sessionmaker() as session:
async with session.begin():
row = await session.get(HomeAssistantConnection, connection_id)
if row is not None:
row.refused, row.last_error = True, said
async def under_way(session) -> HomeAssistantUpdate | None:
return (await session.execute(select(HomeAssistantUpdate).where(
HomeAssistantUpdate.state.in_(UNDER_WAY)))).scalars().first()
async def start_update(app, entity_id: str, username: str, transport=None, *, reboot_after: bool = False) -> HomeAssistantUpdate:
"""Start installing `entity_id`, an explicit action that may contact Home
Assistant. Raises CannotStart, with nothing started, unless the update is
still waiting and the signed-in user may install it. `reboot_after` reboots
the machine once it's written, and it's done when the new version is running."""
async with app.db_sessionmaker() as session:
connection = (await session.execute(select(HomeAssistantConnection))).scalars().first()
if connection is None:
raise CannotStart("Home Assistant isn't connected")
if connection.refused:
raise CannotStart("Home Assistant refused Steward's sign-in")
if await under_way(session) is not None:
raise CannotStart("another update is under way")
if not connection.can_install:
raise CannotStart("the Home Assistant user who signed in isn't an administrator")
url, verify = connection.url, connection.verify_tls
known = next((u for u in connection.updates or [] if u["entity_id"] == entity_id), None)
if known is None or not known["kind"]:
raise CannotStart("that isn't a Core, OS or add-on update")
try:
token = await client.access_token(connection, transport)
current = await client.read_state(url, entity_id, token, verify, transport)
backup = await client.read_state(url, LAST_BACKUP, token, verify, transport)
event = await client.read_state(url, BACKUP_EVENT, token, verify, transport)
except client.Refused as exc:
await _mark_refused(app, connection.id, str(exc))
raise CannotStart(str(exc).rstrip(".")) from None
except client.Unreachable as exc:
raise CannotStart(str(exc).rstrip(".")) from None
if backup is None:
raise CannotStart("Home Assistant has no automatic backups to confirm one with")
if current is None or current.get("state") != "on":
raise CannotStart("that update is no longer waiting")
attrs = current.get("attributes") or {}
run = HomeAssistantUpdate(
entity_id=entity_id, title=known["title"], kind=known["kind"],
from_version=attrs.get("installed_version"), to_version=attrs.get("latest_version"),
started_by=username, backup_before=backup.get("state"),
backup_event_before=(event or {}).get("state"), reboot_after=reboot_after)
async with app.db_sessionmaker() as session:
try:
async with session.begin():
session.add(run)
await session.flush()
run_id = run.id
except IntegrityError:
raise CannotStart("another update is under way") from None
try:
await client.call_action(url, "backup", "create_automatic", {}, token, verify, transport)
except client.Refused as exc:
await _mark_refused(app, connection.id, str(exc))
await _finish(app, run_id, "failed", f"The backup couldn't be started: {exc} Nothing was installed.")
raise CannotStart(str(exc).rstrip(".")) from None
except (client.Unreachable, client.Rejected) as exc:
await _finish(app, run_id, "failed", f"The backup couldn't be started: {exc} Nothing was installed.")
raise CannotStart(str(exc).rstrip(".")) from None
async with app.db_sessionmaker() as session:
return await session.get(HomeAssistantUpdate, run_id)
async def _finish(app, run_id: str, state: str, message: str | None) -> None:
async with app.db_sessionmaker() as session:
async with session.begin():
run = await session.get(HomeAssistantUpdate, run_id)
run.state, run.message, run.finished_at = state, message, datetime.now(timezone.utc)
# The page should show the new versions now, not at the next poll.
connection = (await session.execute(select(HomeAssistantConnection))).scalars().first()
if connection is not None:
connection.last_polled_at = None
try:
async with session.begin_nested():
await update_entry(session, run)
except Exception:
logger.exception("Home Assistant update %s: its timeline entry could not be written", run_id)
async def advance(app, transport=None, connect=None) -> None:
"""Move the update under way one step on, if there is one."""
async with app.db_sessionmaker() as session:
run = await under_way(session)
connection = (await session.execute(select(HomeAssistantConnection))).scalars().first()
if run is None:
return
if connection is None:
await _finish(app, run.id, "failed", "The Home Assistant connection was removed.")
return
try:
if run.state == "backing_up":
await _advance_backup(app, run, connection, transport)
elif run.state == "installing":
await _advance_install(app, run, connection, transport)
else:
await _advance_reboot(app, run, connection, transport, connect)
except client.Refused as exc:
await _mark_refused(app, connection.id, str(exc))
await _finish(app, run.id, "failed", str(exc))
except client.Rejected as exc:
await _finish(app, run.id, "failed", f"Home Assistant wouldn't install it: {exc}")
except client.Unreachable as exc:
# Expected while Home Assistant restarts: the next tick asks again.
logger.info("Home Assistant update %s: %s", run.id, exc)
await _past_deadline(app, run)
def _elapsed(since: datetime) -> float:
return (datetime.now(timezone.utc) - since).total_seconds()
async def _past_deadline(app, run) -> bool:
if run.state == "backing_up" and _elapsed(run.started_at) > BACKUP_DEADLINE_S:
await _finish(app, run.id, "failed",
f"No new backup within {BACKUP_DEADLINE_S // 60} minutes. Nothing was installed.")
return True
if run.state == "installing":
limit = INSTALL_DEADLINE_S.get(run.kind, INSTALL_DEADLINE_S["os"])
if _elapsed(run.install_sent_at or run.backup_at or run.started_at) > limit:
await _finish(app, run.id, "failed",
f"Still not on {run.to_version} after {limit // 60} minutes. Look in Home Assistant.")
return True
if run.state == "rebooting" and _elapsed(run.reboot_sent_at or run.install_sent_at or run.started_at) > REBOOT_DEADLINE_S:
await _finish(app, run.id, "failed", f"{run.to_version} was installed, but isn't running "
f"{REBOOT_DEADLINE_S // 60} minutes after the reboot. Look in Home Assistant.")
return True
return False
async def _advance_backup(app, run, connection, transport) -> None:
token = await client.access_token(connection, transport)
url, verify = connection.url, connection.verify_tls
backup = await client.read_state(url, LAST_BACKUP, token, verify, transport)
value = (backup or {}).get("state")
if is_newer(value, run.backup_before):
async with app.db_sessionmaker() as session:
async with session.begin():
row = await session.get(HomeAssistantUpdate, run.id)
row.state, row.backup_at = "installing", parse_moment(value)
return
event = await client.read_state(url, BACKUP_EVENT, token, verify, transport)
if event and event.get("state") != run.backup_event_before \
and (event.get("attributes") or {}).get("event_type") == "failed":
reason = (event.get("attributes") or {}).get("failed_reason")
await _finish(app, run.id, "failed",
f"The backup failed{f' ({reason})' if reason else ''}. Nothing was installed.")
return
await _past_deadline(app, run)
async def _mark_sent(app, run_id: str, at: datetime | None) -> None:
async with app.db_sessionmaker() as session:
async with session.begin():
row = await session.get(HomeAssistantUpdate, run_id)
row.install_sent_at = at
async def _advance_install(app, run, connection, transport) -> None:
token = await client.access_token(connection, transport)
url, verify = connection.url, connection.verify_tls
if run.install_sent_at is None:
# Marked first: Steward stopping here leaves an install that runs out its
# deadline, where asking twice would fail one that is going fine.
await _mark_sent(app, run.id, datetime.now(timezone.utc))
try:
await client.call_action(url, "update", "install", {"entity_id": run.entity_id}, token, verify, transport)
except client.Unreachable:
await _mark_sent(app, run.id, None)
raise
return
current = await client.read_state(url, run.entity_id, token, verify, transport)
attrs = (current or {}).get("attributes") or {}
installed = attrs.get("installed_version")
if installed and (installed == run.to_version
or (current.get("state") == "off" and installed != run.from_version)):
if run.reboot_after:
async with app.db_sessionmaker() as session:
async with session.begin():
row = await session.get(HomeAssistantUpdate, run.id)
row.state = "rebooting"
return
await _finish(app, run.id, "done", None)
return
await _past_deadline(app, run)
async def _mark_reboot_sent(app, run_id: str, at: datetime | None) -> None:
async with app.db_sessionmaker() as session:
async with session.begin():
row = await session.get(HomeAssistantUpdate, run_id)
row.reboot_sent_at = at
async def _advance_reboot(app, run, connection, transport, connect) -> None:
"""Reboot once, and wait for the new version to be what's running. The version
is judged against the old one, so a machine that fell back to it fails at the
deadline, as does one that never comes back."""
token = await client.access_token(connection, transport)
url, verify = connection.url, connection.verify_tls
if run.reboot_sent_at is None:
await _mark_reboot_sent(app, run.id, datetime.now(timezone.utc))
try:
await client.call_action(url, "hassio", "host_reboot", {}, token, verify, transport)
except client.Unreachable:
await _mark_reboot_sent(app, run.id, None)
await _past_deadline(app, run)
return
except client.Rejected as exc:
await _finish(app, run.id, "failed", f"{run.to_version} was installed, but Home Assistant "
f"wouldn't reboot: {exc} It needs a reboot to take effect.")
return
found = await client.read_os(url, token, verify, connect)
if found and found.get("version") not in (None, run.from_version) and not found.get("version_pending"):
await _finish(app, run.id, "done", None)
return
await _past_deadline(app, run)
+99 -5
View File
@@ -19,11 +19,43 @@ def setup(app: "Quart") -> None:
learn_normal("host_agent", "cpu_pct", floor_setting(app, "host_cpu_pct"), parts=False)
learn_normal("host_agent", "mem_used_pct", floor_setting(app, "host_mem_pct"), parts=False)
learn_normal("host_agent", "load_pct", floor_setting(app, "host_load_pct"), parts=False)
# "Agent offline" speaks for everything the agent carries: while it is
# firing, the host's other rules going silent isn't emailed (#5721).
from steward.core.alerts import agent_fed, agent_heartbeat
from steward.hosts.service import agent_stale_after_seconds
agent_heartbeat("host_agent", "seconds_since_seen", lambda: agent_stale_after_seconds(app.config))
agent_fed("host_agent", lambda resource: resource.split(":", 1)[0])
# temp_c_max stands for one of many sensors: its alert says which.
from steward.core.alerts import names_behind
from .routes import name_hottest_sensor
names_behind("host_agent", "temp_c_max", name_hottest_sensor)
# get_host over MCP marks the sensors the host's temperature leaves out.
from steward.mcp.tools.hosts import judge_sensors
from .routes import split_temps
judge_sensors(lambda host, temps: {k: why for k, (_c, why) in split_temps(host, temps)[1].items()})
# A reboot the agent announced is silence with a reason, for a while (#5756).
from datetime import datetime, timezone
from steward.core.alerts import agents_away
from .downtime import hosts_away, restart_grace_seconds
agents_away(lambda session: hosts_away(session, datetime.now(timezone.utc),
restart_grace_seconds(app.config)))
from steward.core.cleanup import prune_by_age
from .models import HostDowntime
prune_by_age(HostDowntime.started_at)
# Maintenance runs and the stretches a host was away go on the timeline.
from .timeline import register as register_timeline
register_timeline()
def get_audit_entities() -> dict:
"""Audit rows about a maintenance plan name it and link to the plans page."""
from .models import HostMaintenancePlan
return {"maintenance_plan": (HostMaintenancePlan, "/plugins/host_agent/maintenance")}
def get_scheduled_tasks() -> list:
from .scheduler import make_task
return [make_task(_app)]
from .scheduler import make_maintenance_task, make_sensor_task, make_task
return [make_task(_app), make_maintenance_task(_app), make_sensor_task(_app)]
def get_blueprint():
@@ -41,15 +73,77 @@ async def mcp_overview(db) -> list[dict]:
return await findings(db)
async def timeline_backfill(session, since) -> list:
from .timeline import backfill
return await backfill(session, since)
def get_alert_metrics() -> dict[str, list[str]]:
"""Host metrics ingest records for alert rules (resource = the host name;
disk_used_pct per mount, resource "<host>:<mount>"). seconds_since_seen
comes from the scheduler, not the agent: "agent offline" is > 180.
clock_off_secs is how far the host's clock is from Steward's, either way
(agent ≥ 1.12.0): "clock drifting" is > 30. load_pct is load_1m as a %
of the host's cores."""
of the host's cores. inodes_used_pct is per mount (agent ≥ 1.13.0), and
disk_used_pct_worst is the fullest mount by space or inodes.
mounts_stalled counts the mounts that didn't answer the agent in time
(agent ≥ 1.14.0): "an NFS share stopped" is > 0. agent_rss_bytes and
agent_cpu_pct are the agent's own memory and CPU (agent ≥ 1.15.0): a
healthy agent sits in tens of MB and a few %. updates_pending and
updates_security are what the package manager has waiting, and
reboot_required is 1 when the host needs a reboot (agent ≥ 1.16.0):
"security updates waiting" is updates_security > 0. maintenance_failed
comes from the scheduler (#5768): 1 when the host's latest maintenance job
failed or timed out, 0 once one succeeds: "maintenance failed" is > 0.
temp_c_max is the host's hottest real thermometer, in °C (#5787); sensors
with nothing wired to them are left out (#5759, #5783): "running hot" is
> 85. temp_c is one sensor, resource "<host>:temp:<label>". net_rx_bps /
net_tx_bps and disk_read_bps / disk_write_bps are bytes per second, for
the whole host ("<host>") or one interface ("<host>:net:<iface>") or disk
("<host>:diskio:<device>").
NFS delay (agent ≥ 1.22.0, #5996), per NFS mount on "<host>:<mount>",
over the requests it finished since the last sample: nfs_exe_ms is the
average time a program waited on one (queueing included), nfs_rtt_ms the
server's round trip, nfs_read_ms / nfs_write_ms the same for reads and
writes alone, nfs_ops_per_sec how many, and nfs_retrans / nfs_timeouts the
retransmissions and major timeouts among them; nfs_tx_bps / nfs_rx_bps
(agent ≥ 1.25.0, #6115) are the bytes a second it sent and received, its
share of the host's network traffic. nfs_exe_max_ms is the
host's slowest mount, for a covering rule: "NFS slow" is > 1000 for 3
checks. A healthy share answers in a few ms.
Drive health (agent ≥ 1.20.0, #5789), read hourly with smartctl by the
root helper and judged in drives.py, per drive on "<host>:drive:<device>":
drive_failing is 1 when the drive fails its own self-assessment, raises
an NVMe critical warning or runs out of spare blocks; drive_life_used_pct
is how much of its rated life it has used; drive_bad_sectors counts its
media errors or reallocated / pending / uncorrectable sectors;
drive_temp_c is its own temperature. Per host, for covering rules:
drives_failing (how many) and drive_life_used_max_pct (the most worn):
"drive failing" is drives_failing > 0, "drive wearing out" is
drive_life_used_max_pct > 90.
Recorded but not offered (NOT_ALERTABLE): disk_used_bytes and
disk_total_bytes, where disk_used_pct is the figure to watch (and docker
uses disk_total_bytes for something else), and mem_cached_bytes /
mem_buffers_bytes, parts of what mem_available_bytes already counts."""
from .drives import DRIVE_METRICS
return {"host_agent": [
"cpu_pct", "mem_used_pct", "mem_available_bytes", "swap_used_bytes",
"disk_used_pct_worst", "disk_used_pct", "load_1m", "load_5m", "load_15m", "load_pct",
"uptime_secs", "seconds_since_seen", "clock_off_secs",
"disk_used_pct_worst", "disk_used_pct", "inodes_used_pct", "load_1m", "load_5m", "load_15m",
"load_pct",
"uptime_secs", "seconds_since_seen", "clock_off_secs", "mounts_stalled",
"agent_rss_bytes", "agent_cpu_pct",
"updates_pending", "updates_security", "reboot_required", "maintenance_failed",
"temp_c_max", "temp_c", "net_rx_bps", "net_tx_bps", "disk_read_bps", "disk_write_bps",
"nfs_exe_max_ms", "nfs_exe_ms", "nfs_rtt_ms", "nfs_read_ms", "nfs_write_ms",
"nfs_ops_per_sec", "nfs_retrans", "nfs_timeouts", "nfs_tx_bps", "nfs_rx_bps",
*DRIVE_METRICS,
]}
# Metrics host_agent records that no rule is offered, each for the reason
# get_alert_metrics gives; tests/core/test_metric_catalog.py holds every other
# recorded name to the catalog.
NOT_ALERTABLE = frozenset({"disk_used_bytes", "disk_total_bytes", "mem_cached_bytes", "mem_buffers_bytes"})
File diff suppressed because it is too large Load Diff
+281
View File
@@ -0,0 +1,281 @@
# plugins/host_agent/cadence.py
"""A group's maintenance cadence (#5768): every week, its hosts updated one at
a time, each rebooting only when its update needs it (or as the plan says).
A plan names an inventory group, a weekday and a time in its time zone, and a
window. At that time a run starts with the group's hosts in a fixed order. Each
host gets a maintenance job (maintenance.py); the next host starts only when
the last one is back in service: its agent posting, its swarm node ready and
active, every replicated service at its replicas. A job that fails, or a host
that doesn't come back into service, stops the run, and the host's
maintenance_failed figure goes to 1 for an alert rule to catch. No host is
started after the window; the run then stops and says which hosts it didn't
reach. A run that drained swarm nodes ends by spreading the swarm's services
back out (rebalance.py), unless its plan says not to.
The decisions are model-free here (plans and runs are anything with the
attributes); the database side is below them, and the plugin's minute tick
drives it. Everything is on the rows, so a restart of Steward, or the reboot of
the host it runs on, picks up where it was.
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta
from steward.core.weekly import WEEKDAYS, describe, fired_slot, is_due, last_slot, next_slot, parse_at # noqa: F401
from . import maintenance, rebalance, timeline
# How long a host may take to be back in service after its job finished.
SETTLE_LIMIT = timedelta(minutes=30)
# An agent heard from this recently is posting.
POSTING_WITHIN = timedelta(minutes=3)
RUN_STATES = ("running", "done", "failed", "stopped")
def back_in_service(*, last_seen_at, node, services, job_finished_at, now: datetime,
expect_active: bool = True) -> str | None:
"""None when the host is back in service, else why not yet. `node` is the
freshest swarm-node row for the job's node (None outside a swarm, or when
the job has no node); `services` the freshest row per swarm service. Only
rows reported after the job finished count, so a stale "all fine" from
before the reboot never passes. `expect_active` is False for a node the
job didn't drain (the operator keeps it drained): it need only be ready."""
if last_seen_at is None or now - last_seen_at > POSTING_WITHIN:
return "its agent isn't posting yet"
if node is None:
return None
if node.updated_at < job_finished_at:
return "no swarm report since it came back"
if node.status != "ready" or (expect_active and node.availability != "active"):
return f"its swarm node is {node.status}/{node.availability}"
fresh = [s for s in services if s.updated_at >= job_finished_at]
if not fresh:
return "no swarm report since it came back"
short = sorted(s.service_name for s in fresh
if s.mode == "replicated" and s.running < s.desired)
if short:
return "services below their replicas: " + ", ".join(short[:5])
return None
def next_move(run, plan, job, back: str | None, now: datetime) -> tuple[str, str | None]:
"""What a running run does now: ("start", None) the current host's job;
("wait", None); ("next", None) on to the next host; ("settling", why);
("fail", why); ("stop", why) the window ended; ("done", None)."""
hosts = json.loads(run.host_ids)
if run.position >= len(hosts):
return "done", None
if job is None:
if now >= run.started_at + timedelta(minutes=plan.window_minutes):
left = len(hosts) - run.position
return "stop", f"the window ended with {left} host{'s' if left != 1 else ''} not started"
return "start", None
if job.state in maintenance.ACTIVE:
return "wait", None
if job.state != "done":
return "fail", job.detail or maintenance.LABELS[job.state]
if back is None:
return "next", None
if run.settle_since is not None and now - run.settle_since > SETTLE_LIMIT:
return "fail", f"{maintenance.NOT_BACK} {int(SETTLE_LIMIT.total_seconds() // 60)} min: {back}"
return "settling", back
# ── applied to the database: the minute tick, Run now, the page ──────────────
async def group_hosts(session, group_id: str) -> list:
"""The group's hosts (through their linked Ansible targets), by name."""
from sqlalchemy import select
from steward.models.ansible_inventory import AnsibleTarget, ansible_target_groups
from steward.models.hosts import Host
rows = (await session.execute(
select(Host).join(AnsibleTarget, AnsibleTarget.host_id == Host.id)
.join(ansible_target_groups, ansible_target_groups.c.target_id == AnsibleTarget.id)
.where(ansible_target_groups.c.group_id == group_id))).scalars().all()
return sorted({h.id: h for h in rows}.values(), key=lambda h: h.name.lower())
async def start_run(session, plan, now: datetime, manual: bool = False):
"""A run of the plan with the group's hosts in order. A host whose agent
has no maintenance helper is passed over, and the run says so."""
from sqlalchemy import select
from .models import HostAgentRegistration, HostMaintenanceRun
hosts = await group_hosts(session, plan.group_id)
regs = {r.host_id: r for r in (await session.execute(select(HostAgentRegistration).where(
HostAgentRegistration.host_id.in_([h.id for h in hosts])))).scalars()} if hosts else {}
order, skipped = [], []
for h in hosts:
reg = regs.get(h.id)
if reg is None:
skipped.append({"host_id": h.id, "reason": "no agent"})
elif not reg.maint_helper:
skipped.append({"host_id": h.id, "reason": "no maintenance helper: run Update agent"})
else:
order.append(h.id)
run = HostMaintenanceRun(plan_id=plan.id, state="running", host_ids=json.dumps(order),
position=0, skipped=json.dumps(skipped), manual=manual,
started_at=now)
session.add(run)
await session.flush()
return run
async def _back(session, host, job, reg, now: datetime) -> str | None:
from sqlalchemy import select
node = None
services: list = []
if job.node_id:
models = maintenance.docker_models()
if models is not None:
DockerSwarmNode, DockerSwarmService = models.DockerSwarmNode, models.DockerSwarmService
node = (await session.execute(select(DockerSwarmNode).where(
DockerSwarmNode.node_id == job.node_id)
.order_by(DockerSwarmNode.updated_at.desc()).limit(1))).scalar_one_or_none()
freshest: dict = {}
for s in (await session.execute(select(DockerSwarmService))).scalars():
if s.service_name not in freshest or s.updated_at > freshest[s.service_name].updated_at:
freshest[s.service_name] = s
services = list(freshest.values())
return back_in_service(last_seen_at=reg.last_seen_at if reg else None, node=node,
services=services, job_finished_at=job.finished_at or now, now=now,
expect_active=bool(job.drain_host_id))
async def advance(session, run, plan, now: datetime) -> str:
"""Move one running run on as far as it can go now; returns its state."""
from sqlalchemy import select
from steward.models.hosts import Host
from .models import HostAgentRegistration, HostMaintenanceJob
for _ in range(64): # a host per pass; bounded
hosts = json.loads(run.host_ids)
host_id = hosts[run.position] if run.position < len(hosts) else None
job = host = reg = None
if host_id is not None:
host = await session.get(Host, host_id)
reg = (await session.execute(select(HostAgentRegistration).where(
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
job = (await session.execute(select(HostMaintenanceJob).where(
HostMaintenanceJob.run_id == run.id, HostMaintenanceJob.host_id == host_id)
.order_by(HostMaintenanceJob.created_at.desc()).limit(1))).scalar_one_or_none()
back = None
if job is not None and job.state == "done":
back = await _back(session, host, job, reg, now)
move, why = next_move(run, plan, job, back, now)
if move == "start":
if host is None:
_skip(run, host_id, "the host is gone")
continue
try:
await maintenance.create_job(session, host, reg, plan.created_by,
plan.reboot_policy, now, run_id=run.id)
except maintenance.CannotStart as exc:
_skip(run, host_id, str(exc))
continue
return run.state
if move == "next":
run.position += 1
run.settle_since = None
continue
if move == "settling":
run.settle_since = run.settle_since or now
return run.state
if move == "fail":
if job is not None and job.state == "done":
maintenance._finish(job, "failed", now, why) # the host never came back into service
await timeline.write(session, f"maintenance job {job.id}",
timeline.job_entry(session, job, host.name if host else None))
name = host.name if host else host_id
_end(run, "failed", f"{name}: {why}", now=now)
elif move == "stop":
_end(run, "stopped", why, now=now)
elif move == "done":
if await rebalance.begin(session, run, plan, now) or \
(run.rebalance or {}).get("state") == "handed":
return run.state # a manager is spreading services back out
_end(run, *rebalance.outcome(run), now=now)
return run.state
return run.state
def _skip(run, host_id: str, reason: str) -> None:
skipped = json.loads(run.skipped)
skipped.append({"host_id": host_id, "reason": reason})
run.skipped = json.dumps(skipped)
hosts = json.loads(run.host_ids)
hosts.pop(run.position)
run.host_ids = json.dumps(hosts)
def _end(run, state: str, detail: str | None = None, *, now: datetime) -> None:
run.state, run.finished_at, run.settle_since = state, now, None
if detail:
run.detail = detail[:maintenance.DETAIL_MAX]
async def tick(session, now: datetime) -> list[str]:
"""Start due plans and move running runs on; returns lines worth logging."""
from sqlalchemy import select
from .models import HostMaintenancePlan, HostMaintenanceRun
said = []
running = {r.plan_id: r for r in (await session.execute(select(HostMaintenanceRun).where(
HostMaintenanceRun.state == "running"))).scalars()}
for plan in (await session.execute(select(HostMaintenancePlan))).scalars():
slot = is_due(plan, now)
if slot is None:
continue
plan.last_slot_at = slot
if plan.id in running:
said.append(f"plan {plan.name!r}: the {slot:%Y-%m-%d %H:%M}Z slot came with a run still going")
continue
running[plan.id] = await start_run(session, plan, now)
said.append(f"plan {plan.name!r}: run started")
for run in running.values():
plan = await session.get(HostMaintenancePlan, run.plan_id)
if plan is None:
continue
if rebalance.check_deadline(run, now):
await rebalance.finish(session, run, now)
before = run.state
after = await advance(session, run, plan, now)
if after != before:
said.append(f"plan {plan.name!r}: run {after}" + (f" ({run.detail})" if run.detail else ""))
return said
async def failed_hosts(session, now: datetime) -> dict[str, float]:
"""{host name: 1 or 0} for every host with an agent: whether its latest
finished maintenance job failed or timed out, for the maintenance_failed
figure an alert rule watches. A host with no job yet reads 0, so a rule
on it reports rather than sitting at "no data". A later job that
succeeds puts it back to 0, and so does a host that failed only for not
coming back into service in time, once it is back (#6105). That return
is kept on the job (came_back_at), so a later wobble in the host's
services doesn't flag it again (#6175)."""
from sqlalchemy import select
from steward.models.hosts import Host
from .models import HostAgentRegistration, HostMaintenanceJob
agents = (await session.execute(
select(Host.name).join(HostAgentRegistration, HostAgentRegistration.host_id == Host.id))).scalars()
out = {name: 0.0 for name in agents}
rows = (await session.execute(
select(Host, HostMaintenanceJob)
.join(Host, Host.id == HostMaintenanceJob.host_id)
.where(HostMaintenanceJob.state.in_(maintenance.FINISHED),
HostMaintenanceJob.finished_at >= now - timedelta(days=30)))).all()
latest: dict = {}
for host, job in rows:
if host.name not in latest or job.finished_at > latest[host.name][1].finished_at:
latest[host.name] = (host, job)
for name, (host, job) in latest.items():
if (job.state == "failed" and job.came_back_at is None
and (job.detail or "").startswith(maintenance.NOT_BACK)):
reg = (await session.execute(select(HostAgentRegistration).where(
HostAgentRegistration.host_id == host.id))).scalar_one_or_none()
if await _back(session, host, job, reg, now) is None:
job.came_back_at = now
await timeline.write(session, f"maintenance job {job.id}",
timeline.job_entry(session, job, host.name))
out[name] = 1.0 if maintenance.still_failed(job) else 0.0
return out
+172
View File
@@ -0,0 +1,172 @@
# plugins/host_agent/downtime.py
"""What a host's agent says happened to it while it was away (#5756).
An agent ≥ 1.14.0 tells Steward when it stops (and whether its host is going
down with it), when it starts (and when its host booted), and how long it
couldn't reach Steward. Each becomes a HostDowntime row, so a reboot reads as
a reboot with its length, and "agent offline" is left for silence nobody
explained. A post can be retried, so every event is applied idempotently.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from .models import HostDowntime
# A stop the agent announced: the silence after it is explained, for a while.
ANNOUNCED = ("reboot", "agent_stop")
LABELS = {
"reboot": "Rebooted",
"agent_stop": "Agent stopped",
"unclean": "Restarted without shutting down",
"lost_contact": "Lost contact",
}
def restart_grace_seconds(config) -> int:
"""How long an announced stop explains the agent's silence (Settings →
Plugins → host_agent, default 10 minutes): past it, agent offline fires."""
return int(((config.get("PLUGINS") or {}).get("host_agent") or {})
.get("reboot_grace_seconds", 600))
def _at(value) -> datetime | None:
if not isinstance(value, str):
return None
try:
got = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return got if got.tzinfo else got.replace(tzinfo=timezone.utc)
def _when(event: dict) -> datetime | None:
return _at(event.get("at") or event.get("from"))
async def record_events(session, host_id: str, events, last_seen_at: datetime | None) -> int:
"""Apply one post's events to the host's downtime, oldest first; returns
how many rows were added or changed. `last_seen_at` is when the agent was
last heard before this post: a boot after it that nobody announced is a
restart without a shutdown. Each row added or changed goes on the
timeline, kept up to date as it ends."""
if not isinstance(events, list):
return 0
timed = sorted(((t, e) for e in events if isinstance(e, dict)
for t in [_when(e)] if t is not None), key=lambda te: te[0])
changed = []
for at, event in timed:
kind = event.get("kind")
row = None
if kind == "stopping":
row = await _stopping(session, host_id, at, event.get("host_shutdown"))
elif kind == "started":
row = await _started(session, host_id, at, _at(event.get("boot_at")), last_seen_at)
elif kind == "lost_contact":
row = await _lost_contact(session, host_id, at, _at(event.get("to")))
if row is not None and row not in changed:
changed.append(row)
if changed:
from . import timeline
await session.flush()
for row in changed:
await timeline.write(session, f"host downtime {row.id}", timeline.downtime_entry(session, row))
return len(changed)
async def _stopping(session, host_id: str, at: datetime, host_shutdown) -> HostDowntime | None:
seen = (await session.execute(select(HostDowntime.id).where(
HostDowntime.host_id == host_id, HostDowntime.kind.in_(ANNOUNCED),
HostDowntime.started_at == at))).first()
if seen:
return None
row = HostDowntime(host_id=host_id, kind="reboot" if host_shutdown else "agent_stop", started_at=at)
session.add(row)
return row
async def _started(session, host_id: str, at: datetime, boot_at: datetime | None,
last_seen_at: datetime | None) -> HostDowntime | None:
stop = (await session.execute(select(HostDowntime).where(
HostDowntime.host_id == host_id, HostDowntime.kind.in_(ANNOUNCED),
HostDowntime.ended_at.is_(None), HostDowntime.started_at <= at)
.order_by(HostDowntime.started_at.desc()).limit(1))).scalar_one_or_none()
if stop is not None:
stop.ended_at = at
# The boot time settles what the stop was: the host came back from a
# boot after it, or it stayed up while only the agent restarted.
if boot_at is not None:
stop.kind = "reboot" if boot_at > stop.started_at else "agent_stop"
return stop
if boot_at is None or last_seen_at is None or boot_at <= last_seen_at:
return None
seen = (await session.execute(select(HostDowntime.id).where(
HostDowntime.host_id == host_id, HostDowntime.kind == "unclean",
HostDowntime.ended_at == at))).first()
if seen:
return None
row = HostDowntime(host_id=host_id, kind="unclean", started_at=last_seen_at, ended_at=at)
session.add(row)
return row
async def _lost_contact(session, host_id: str, since: datetime,
until: datetime | None) -> HostDowntime | None:
if until is None or until <= since:
return None
row = (await session.execute(select(HostDowntime).where(
HostDowntime.host_id == host_id, HostDowntime.kind == "lost_contact",
HostDowntime.started_at == since))).scalar_one_or_none()
if row is None:
row = HostDowntime(host_id=host_id, kind="lost_contact", started_at=since, ended_at=until)
session.add(row)
return row
if row.ended_at is None or until > row.ended_at:
row.ended_at = until
return row
return None
async def announced_stops(session, now: datetime, grace_seconds: int) -> dict[str, datetime]:
"""{host id: when the silence its agent announced stops being explained}
for every announced stop still going and inside its grace."""
rows = (await session.execute(select(HostDowntime.host_id, HostDowntime.started_at).where(
HostDowntime.kind.in_(ANNOUNCED), HostDowntime.ended_at.is_(None),
HostDowntime.started_at > now - timedelta(seconds=grace_seconds)))).all()
out: dict[str, datetime] = {}
for host_id, started in rows:
until = started + timedelta(seconds=grace_seconds)
out[host_id] = max(out.get(host_id, until), until)
return out
async def hosts_away(session, now: datetime, grace_seconds: int) -> set[str]:
"""Names of the hosts in announced_stops: their silence has a reason."""
from steward.models.hosts import Host
ids = list(await announced_stops(session, now, grace_seconds))
if not ids:
return set()
return set((await session.execute(select(Host.name).where(Host.id.in_(ids)))).scalars())
def describe(row: HostDowntime, now: datetime) -> dict:
"""One downtime row as the host page shows it."""
label = LABELS.get(row.kind, row.kind)
if row.kind == "agent_stop" and row.ended_at is not None:
label = "Agent restarted"
end = row.ended_at or now
return {"kind": row.kind, "label": label, "started_at": row.started_at,
"ended_at": row.ended_at, "ongoing": row.ended_at is None,
"seconds": max(0.0, (end - row.started_at).total_seconds())}
async def recent(session, host_id: str, now: datetime, days: int = 7, limit: int = 6) -> list[dict]:
"""The host's downtime in the last `days`, newest first."""
rows = (await session.execute(select(HostDowntime).where(
HostDowntime.host_id == host_id,
HostDowntime.started_at > now - timedelta(days=days))
.order_by(HostDowntime.started_at.desc()).limit(limit))).scalars().all()
return [describe(r, now) for r in rows]
+116
View File
@@ -0,0 +1,116 @@
"""Each drive's health, judged from what the root helper read with smartctl
(#5789). The helper reports figures, not verdicts, so how a drive is judged
can change here without an agent update.
A drive is:
- "failing" when its own self-assessment fails (SMART overall-health), an NVMe
drive raises a critical warning, or its spare blocks fall under threshold;
- "worn" when it has used WORN_PCT of its rated life;
- "watch" when it has bad sectors or media errors but otherwise passes: old
disks carry a few for years, so a count alone isn't a failure;
- "ok", or "unavailable" when it has no SMART to read (a virtual disk, a
bridge that hides it), or "unreadable" when smartctl couldn't read it.
Metrics, per drive on "<host>:drive:<device>": drive_failing (1/0),
drive_life_used_pct, drive_bad_sectors, drive_temp_c. Per host, for covering
rules: drives_failing (how many) and drive_life_used_max_pct.
"""
from __future__ import annotations
WORN_PCT = 90
DRIVE_METRICS = ("drive_failing", "drive_life_used_pct", "drive_bad_sectors", "drive_temp_c",
"drives_failing", "drive_life_used_max_pct")
# What a drive's own figures count as bad sectors or media errors, whichever
# its kind reports: NVMe media errors, ATA reallocated / pending / offline
# uncorrectable sectors, the SCSI grown defect list.
_BAD = ("media_errors", "reallocated", "pending", "offline_uncorrectable", "grown_defects")
_ORDER = {"failing": 0, "unreadable": 1, "worn": 2, "watch": 3, "ok": 4, "unavailable": 5}
def _num(v):
return v if isinstance(v, (int, float)) and not isinstance(v, bool) else None
def bad_sectors(d: dict) -> int | None:
counts = [_num(d.get(k)) for k in _BAD]
counts = [c for c in counts if c is not None]
return int(sum(counts)) if counts else None
def failing_reasons(d: dict) -> list[str]:
why = []
if d.get("passed") is False:
why.append("its SMART self-assessment failed")
if _num(d.get("critical_warning")):
why.append("critical warning %#x" % int(d["critical_warning"]))
spare, floor = _num(d.get("spare_pct")), _num(d.get("spare_threshold_pct"))
if spare is not None and floor is not None and spare < floor:
why.append(f"spare blocks {spare:.0f}% under the {floor:.0f}% threshold")
return why
def judge(d: dict) -> dict:
"""One drive as the host page shows it: its figures plus `state` and `why`."""
out = dict(d)
if d.get("error"):
out.update(state="unreadable", why=str(d["error"]))
elif d.get("available") is False:
out.update(state="unavailable", why="no SMART data (a virtual disk, or SMART is off)")
elif failing_reasons(d):
out.update(state="failing", why="; ".join(failing_reasons(d)))
elif (_num(d.get("life_used_pct")) or 0) >= WORN_PCT:
out.update(state="worn", why=f"{d['life_used_pct']:.0f}% of its rated life used")
elif bad_sectors(d):
out.update(state="watch", why=f"{bad_sectors(d)} bad sectors or media errors")
else:
out.update(state="ok", why="")
out["bad_sectors"] = bad_sectors(d)
return out
def drives_of(report) -> list[dict]:
"""The report's drives, judged, what needs attention first. A name seen
twice gets an ordinal, so two drives never share one series: agent
1.20.0 named every disk behind a RAID controller "0" (#5806)."""
drives = report.get("drives") if isinstance(report, dict) else None
judged, seen = [], {}
for d in drives or []:
if not isinstance(d, dict) or not d.get("device"):
continue
d = judge(d)
name = str(d["device"])
seen[name] = seen.get(name, 0) + 1
if seen[name] > 1:
d["device"] = f"{name}#{seen[name]}"
judged.append(d)
return sorted(judged, key=lambda d: (_ORDER[d["state"]], str(d["device"])))
def metric_rows(report, host_name: str) -> list[tuple[str, str, float]]:
"""(metric, resource, value) for every figure a drive has, and the host's
two roll-ups once any drive was read."""
rows: list[tuple[str, str, float]] = []
failing = 0
life_max: float | None = None
read = 0
for d in drives_of(report):
if d["state"] in ("unreadable", "unavailable"):
continue
read += 1
res = f"{host_name}:drive:{d['device']}"
is_failing = d["state"] == "failing"
failing += is_failing
rows.append(("drive_failing", res, 1.0 if is_failing else 0.0))
life = _num(d.get("life_used_pct"))
if life is not None:
rows.append(("drive_life_used_pct", res, float(life)))
life_max = life if life_max is None else max(life_max, life)
if d["bad_sectors"] is not None:
rows.append(("drive_bad_sectors", res, float(d["bad_sectors"])))
if _num(d.get("temp_c")) is not None:
rows.append(("drive_temp_c", res, float(d["temp_c"])))
if read:
rows.append(("drives_failing", host_name, float(failing)))
if life_max is not None:
rows.append(("drive_life_used_max_pct", host_name, float(life_max)))
return rows
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
# Steward host agent: the packages the agent's helper uses, installed with
# whichever package manager the host has (#5789). Served by Steward at
# /plugins/host_agent/files/install-deps.sh and run, as root, by install.sh
# and the host_agent playbooks: one copy of the list.
#
# smartmontools gives the helper smartctl, which reads each drive's health.
# On pacman hosts, pacman-contrib gives the agent checkupdates, the one safe
# way to see pending updates without root (#5803).
# A package that can't be installed is reported, never fatal: the agent runs
# without it and the host page says what is missing.
#
# Recommended / weak dependencies are skipped: on Debian, smartmontools
# recommends a mail server.
have() { command -v "$1" >/dev/null 2>&1; }
install_with() {
if have apt-get; then
export DEBIAN_FRONTEND=noninteractive
# A stale package list can't find the package: refresh once and retry.
apt-get install -y --no-install-recommends "$@" \
|| { apt-get update && apt-get install -y --no-install-recommends "$@"; }
elif have dnf; then
dnf -y install --setopt=install_weak_deps=False "$@"
elif have yum; then
yum -y install "$@"
elif have zypper; then
zypper --non-interactive install --no-recommends "$@"
elif have pacman; then
pacman -S --needed --noconfirm "$@"
elif have apk; then
apk add --no-cache "$@"
else
echo "no package manager this script knows (apt, dnf, yum, zypper, pacman, apk)"
return 1
fi
}
missing=""
have smartctl || missing="$missing smartmontools"
if have pacman && ! have checkupdates; then
missing="$missing pacman-contrib"
fi
[ -n "$missing" ] || { echo "dependencies present"; exit 0; }
# shellcheck disable=SC2086 # one word per package
if install_with $missing; then
echo "installed:$missing"
else
echo "WARNING: could not install$missing; until it is, drive health reads \"smartctl not installed\" (smartmontools) and the update check reads \"checkupdates isn't installed\" (pacman-contrib)"
fi
exit 0
+646
View File
@@ -0,0 +1,646 @@
#!/usr/bin/env python3
"""Steward maintenance helper (#5766): runs as root, started by steward-maint.path
when the host agent leaves a request, and does only what ACTIONS lists.
The agent (an unprivileged user under NoNewPrivileges) can't update or reboot
its host itself. It writes `<id>.json` = {"id", "action", ["node"]} into
REQUESTS, the one directory it may write. This helper reads each request
without following links, refuses anything outside the list, deletes it, does
the action, and writes the outcome to RESULTS (root's own directory, which the
agent reads back and reports to Steward). A stolen agent token can therefore
make its host update, reboot or drain, or on a swarm manager restart named
services where the swarm puts them, and nothing else.
One action only reads: `drives` (#5789) asks smartctl for each disk's health,
which needs root, and writes a summary to DRIVES, never a result: it is not a
maintenance job. `check` (#6098) changes nothing but the package lists: it
says what an update would upgrade, whether Docker is among it, and which
containers run outside the swarm.
`rebalance` spreads swarm services back out after a maintenance run left them
on one node. Steward names the services, having checked that the emptier node
holds each one's image; the helper force-updates them one at a time, spec
unchanged and no image pulled, so the swarm places each on its emptiest node.
A service a constraint or a preference places is left alone.
Stdlib only; served by Steward at /plugins/host_agent/files/maint.py.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
import time
from datetime import datetime, timezone
REQUESTS = "/var/lib/steward-agent/requests"
RESULTS = "/var/lib/steward-maint/results"
DRIVES = "/var/lib/steward-maint/drives.json"
ACTIONS = ("check", "update", "reboot", "drain", "undrain", "drives", "rebalance")
REQUEST_MAX_BYTES = 4096
RESULTS_KEPT_SECONDS = 7 * 86400
UPDATE_DEADLINE_SECONDS = 3000
COMMAND_DEADLINE_SECONDS = 600
OUTPUT_KEPT_CHARS = 2000
# A listing of what an update would change is read whole, not just its tail.
LISTING_KEPT_CHARS = 1_000_000
_ID = re.compile(r"^[A-Za-z0-9-]{1,64}$")
_NODE = re.compile(r"^[a-z0-9]{25}$")
# A swarm service's name as Docker allows it, never a flag.
_SERVICE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$")
SERVICES_MAX = 48
MOVE_DEADLINE_SECONDS = 300
# A rebalance starts no move after this long.
REBALANCE_SECONDS = 2400
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def read_request(path: str):
"""The request at `path` as a dict, or None when it is not a small regular
file holding a valid request. Never follows a link the agent planted."""
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except OSError:
return None
try:
st = os.fstat(fd)
if not (st.st_mode & 0o170000 == 0o100000) or st.st_size > REQUEST_MAX_BYTES:
return None
raw = os.read(fd, REQUEST_MAX_BYTES + 1)
finally:
os.close(fd)
try:
req = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
return None
return req if valid(req) else None
def valid(req) -> bool:
"""A request names a known action, has a plain id, a node id only in the
swarm's own form, and, for a rebalance alone, a list of service names."""
if not isinstance(req, dict) or req.get("action") not in ACTIONS:
return False
if not isinstance(req.get("id"), str) or not _ID.match(req["id"]):
return False
services = req.get("services")
if req["action"] == "rebalance":
if not (isinstance(services, list) and 0 < len(services) <= SERVICES_MAX
and all(isinstance(s, str) and _SERVICE.match(s) for s in services)):
return False
elif services is not None:
return False
node = req.get("node")
return node is None or (isinstance(node, str) and bool(_NODE.match(node)))
def run(cmd: list, deadline: int = COMMAND_DEADLINE_SECONDS, env: dict | None = None,
ok_codes: tuple = (0,), keep: int | None = OUTPUT_KEPT_CHARS):
"""(ok, output tail) of a command, bounded by `deadline`. `ok_codes` are
the exit codes that mean it worked (zypper says more than 0); `keep` is
how much of the output's end is kept, None for all of it."""
try:
done = subprocess.run(cmd, capture_output=True, text=True, timeout=deadline,
env=dict(os.environ, LANG="C", LC_ALL="C", **(env or {})))
except subprocess.TimeoutExpired:
return False, f"{cmd[0]} didn't finish in {deadline} s"
except OSError as exc:
return False, str(exc)
out = (done.stdout or "") + (done.stderr or "")
return done.returncode in ok_codes, out if keep is None else out[-keep:]
REBOOT_MARKER = "/var/run/reboot-required" # Debian and Ubuntu
KERNEL_MODULES = "/usr/lib/modules"
# zypper's own exit codes for "done, and": 102 a reboot is needed, 103 zypper
# updated itself and would run again (its next run picks up the rest).
ZYPPER_OK = (0, 102, 103)
def reboot_required() -> bool | None:
"""Whether the host needs a reboot after an update, asked the way its
package manager says it; None when it can't say."""
if os.path.exists(REBOOT_MARKER):
return True
if shutil.which("apt-get"):
return False
try:
if shutil.which("needs-restarting"): # dnf, yum
return subprocess.run(["needs-restarting", "-r"], capture_output=True,
timeout=60).returncode == 1
if shutil.which("zypper"):
code = subprocess.run(["zypper", "needs-rebooting"], capture_output=True,
timeout=60).returncode
return {0: False, 102: True}.get(code)
except (OSError, subprocess.TimeoutExpired):
return None
if shutil.which("pacman"):
# An upgraded kernel removes the running one's modules.
return not os.path.isdir(os.path.join(KERNEL_MODULES, os.uname().release))
return None
def upgraded_count(output: str) -> int | None:
"""Packages an update changed, from the manager's summary line."""
m = re.search(r"(\d+) upgraded, (\d+) newly installed", output) # apt
if m:
return int(m.group(1)) + int(m.group(2))
m = re.search(r"(\d+) packages? to upgrade(?:, (\d+) new)?", output) # zypper
if m:
return int(m.group(1)) + int(m.group(2) or 0)
m = re.search(r"^Packages \((\d+)\)", output, re.MULTILINE) # pacman
if m:
return int(m.group(1))
counts = re.findall(r"^\s*(?:Upgrade|Install)\s+(\d+) Packages?", output, re.MULTILINE) # dnf, yum
return sum(int(c) for c in counts) if counts else None
# The same as the agent's: this helper runs as root on its own and imports
# nothing of the agent's, so it keeps its own copy.
def _os_release_id(path: str = "/etc/os-release") -> str:
try:
with open(path) as fh:
for line in fh:
if line.startswith("ID="):
return line.split("=", 1)[1].strip().strip('"')
except OSError:
pass
return ""
def update_steps(manager: str) -> list:
"""The commands an update runs on a host with `manager`, in order: each
(what it is, command, deadline, env, ok exit codes). The last one is the upgrade
whose output is counted. Existing config files are kept, and needrestart
only lists, leaving service restarts to a reboot. A package's own install
script can still restart its service: Docker's does, which is why the job
checks first and drains a swarm node before such an update (#6098)."""
long = UPDATE_DEADLINE_SECONDS
if manager == "apt":
env = {"DEBIAN_FRONTEND": "noninteractive", "NEEDRESTART_MODE": "l"}
return [("apt-get update", ["apt-get", "update"], COMMAND_DEADLINE_SECONDS, env, (0,)),
("apt-get dist-upgrade", ["apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef",
"-o", "Dpkg::Options::=--force-confold", "dist-upgrade"],
long, env, (0,))]
if manager == "dnf":
return [("dnf upgrade", ["dnf", "-y", "upgrade", "--refresh"], long, None, (0,))]
if manager == "yum":
return [("yum update", ["yum", "-y", "update"], long, None, (0,))]
if manager == "zypper":
# Tumbleweed is a rolling release: it upgrades with dup, not update.
upgrade = "dup" if _os_release_id() == "opensuse-tumbleweed" else "update"
return [("zypper refresh", ["zypper", "--non-interactive", "refresh"],
COMMAND_DEADLINE_SECONDS, None, ZYPPER_OK),
("zypper " + upgrade, ["zypper", "--non-interactive", upgrade, "--auto-agree-with-licenses"],
long, None, ZYPPER_OK)]
if manager == "pacman":
# Packages from the AUR aren't updated: no AUR helper runs as root.
return [("pacman -Syu", ["pacman", "-Syu", "--noconfirm"], long, None, (0,))]
return []
# Which manager a host has; dnf before yum, since dnf hosts often keep a yum
# that is dnf underneath.
UPDATE_MANAGERS = (("apt", "apt-get"), ("dnf", "dnf"), ("yum", "yum"), ("zypper", "zypper"),
("pacman", "pacman"))
def host_manager() -> str | None:
for manager, binary in UPDATE_MANAGERS:
if shutil.which(binary):
return manager
return None
def do_update() -> dict:
"""Upgrade every package with whichever package manager the host has."""
manager = host_manager()
if manager is None:
return {"ok": False, "detail": "no package manager this helper knows "
"(apt, dnf, yum, zypper, pacman) on this host"}
out = ""
for label, cmd, deadline, env, ok_codes in update_steps(manager):
# The whole output: every manager prints its count before the long
# log of what it then does, so a tail loses it on a large update.
ok, out = run(cmd, deadline=deadline, env=env, ok_codes=ok_codes, keep=None)
if not ok:
return {"ok": False, "detail": f"{label} failed: " + out[-OUTPUT_KEPT_CHARS:]}
changed = upgraded_count(out)
if manager == "apt":
run(["apt-get", "-y", "autoremove"], env={"DEBIAN_FRONTEND": "noninteractive"})
return {"ok": True, "packages": changed, "reboot_required": reboot_required(),
"detail": out[-500:]}
# ── what an update would change, and what runs outside the swarm (#6098) ─────
# Docker's own packages restart the daemon from their install scripts, whatever
# needrestart is told, and every container on the host stops with it. So the
# job asks first: a swarm node is drained before an update that upgrades
# Docker, not after it, and the containers running outside the swarm are
# noted, to see each of them running again once the job is over.
DOCKER_PACKAGES = re.compile(
r"^(docker|docker-ce|docker\.io|docker-engine|moby-engine|containerd|containerd\.io)$")
CONTAINERS_MAX = 40
CONTAINER_NAME_MAX = 64
def pending_steps(manager: str) -> list:
"""The commands that list what an update would upgrade, changing nothing
but the package lists: each (what it is, command, ok exit codes). The
last one's output names the packages."""
if manager == "apt":
return [("apt-get update", ["apt-get", "update"], (0,)),
("apt-get -s dist-upgrade", ["apt-get", "-s", "dist-upgrade"], (0,))]
if manager == "dnf":
# check-update exits 100 when there is something to update.
return [("dnf check-update", ["dnf", "-q", "check-update", "--refresh"], (0, 100))]
if manager == "yum":
return [("yum check-update", ["yum", "-q", "check-update"], (0, 100))]
if manager == "zypper":
if _os_release_id() == "opensuse-tumbleweed":
listing = ("zypper dup --dry-run", ["zypper", "--non-interactive", "dup", "--dry-run",
"--auto-agree-with-licenses"])
else:
listing = ("zypper list-updates", ["zypper", "--non-interactive", "list-updates"])
return [("zypper refresh", ["zypper", "--non-interactive", "refresh"], ZYPPER_OK),
(*listing, ZYPPER_OK)]
if manager == "pacman":
# -Sy refreshes the lists only; the update's -Syu follows it. -Qu exits
# 1 when there is nothing to upgrade.
return [("pacman -Sy", ["pacman", "-Sy"], (0,)), ("pacman -Qu", ["pacman", "-Qu"], (0, 1))]
return []
def pending_names(manager: str, output: str) -> list:
"""The packages a listing from pending_steps says would be upgraded."""
if manager == "apt":
return re.findall(r"^Inst (\S+)", output, re.MULTILINE)
if manager in ("dnf", "yum"):
# "docker-ce.x86_64 3:27.1.1-1.el9 docker-ce-stable"
return [parts[0].rsplit(".", 1)[0] for parts in (line.split() for line in output.splitlines())
if len(parts) == 3 and "." in parts[0]]
if manager == "zypper":
names, column, listing = [], None, False
for line in output.splitlines():
cells = [c.strip() for c in line.split("|")]
if len(cells) > 2 and column is None and "Name" in cells:
column = cells.index("Name") # list-updates' table
elif column is not None and len(cells) > column and cells[0] == "v":
names.append(cells[column])
elif re.search(r"going to be (upgraded|installed)", line):
listing = True # dup --dry-run's lists
elif listing and line.startswith(" ") and line.strip():
names.extend(line.split())
else:
listing = False
return names
if manager == "pacman":
return [line.split()[0] for line in output.splitlines() if line.strip()]
return []
def standalone_containers() -> list:
"""The running containers that aren't swarm tasks, each with its restart
policy, by name; at most CONTAINERS_MAX, so the result stays small."""
if not shutil.which("docker"):
return []
ok, out = run(["docker", "ps", "-q", "--no-trunc"], deadline=30, keep=LISTING_KEPT_CHARS)
ids = out.split() if ok else []
if not ids:
return []
fmt = '{{.Name}}|{{.HostConfig.RestartPolicy.Name}}|{{index .Config.Labels "com.docker.swarm.task.id"}}'
ok, out = run(["docker", "inspect", "--format", fmt, *ids], deadline=60, keep=LISTING_KEPT_CHARS)
if not ok:
return []
found = []
for line in out.splitlines():
parts = line.split("|")
if len(parts) != 3 or parts[2].strip():
continue # a swarm task: the drain moves it
found.append({"name": parts[0].lstrip("/")[:CONTAINER_NAME_MAX],
"restart": parts[1].strip() or "no"})
return sorted(found, key=lambda c: c["name"])[:CONTAINERS_MAX]
def do_check() -> dict:
"""What an update would upgrade, whether Docker is among it, and the
containers running outside the swarm. Only the package lists change."""
manager = host_manager()
if manager is None:
return {"ok": False, "detail": "no package manager this helper knows "
"(apt, dnf, yum, zypper, pacman) on this host"}
env = {"DEBIAN_FRONTEND": "noninteractive"} if manager == "apt" else None
out = ""
for label, cmd, ok_codes in pending_steps(manager):
ok, out = run(cmd, env=env, ok_codes=ok_codes, keep=LISTING_KEPT_CHARS)
if not ok:
return {"ok": False, "detail": f"{label} failed: " + out[-500:]}
names = pending_names(manager, out)
docker = sorted({n for n in names if DOCKER_PACKAGES.match(n)})
return {"ok": True, "pending": len(names), "docker_update": bool(docker),
"docker_packages": docker[:10], "containers": standalone_containers()}
def own_node() -> str | None:
ok, out = run(["docker", "info", "--format", "{{.Swarm.NodeID}}"], deadline=30)
node = out.strip() if ok else ""
return node if _NODE.match(node) else None
def do_node(availability: str, node: str | None) -> dict:
"""Set a swarm node's availability (drain before a reboot, active after).
Only a manager can; a worker's helper says so and Steward asks a manager."""
if not shutil.which("docker"):
return {"ok": False, "detail": "no docker on this host"}
node = node or own_node()
if node is None:
return {"ok": False, "detail": "this host is not in a swarm"}
ok, out = run(["docker", "node", "update", "--availability", availability, node], deadline=60)
return {"ok": ok, "node": node, "detail": out.strip()[-500:]}
def placed_by_rule(name: str) -> bool | None:
"""Whether a constraint or a preference decides where the service runs
(a force-update would leave it where it is); None when it can't be read."""
ok, out = run(["docker", "service", "inspect", "--format",
"{{json .Spec.TaskTemplate.Placement}}", name], deadline=30)
if not ok:
return None
try:
placement = json.loads(out.strip() or "null") or {}
except ValueError:
return None
return bool(placement.get("Constraints") or placement.get("Preferences"))
def do_rebalance(services: list, clock=time.monotonic) -> dict:
"""Force-update each named service in turn, waiting for it to settle, so
the swarm starts it again on its emptiest node. Fails when a move didn't
settle; what was left and why is in `left`."""
if not shutil.which("docker"):
return {"ok": False, "detail": "no docker on this host"}
began = clock()
moved, left, failed = [], [], []
for name in services:
if clock() - began > REBALANCE_SECONDS:
left.append({"service": name, "reason": "out of time"})
continue
rule = placed_by_rule(name)
if rule is None:
left.append({"service": name, "reason": "not found on this manager"})
continue
if rule:
left.append({"service": name, "reason": "placed by a constraint"})
continue
ok, out = run(["docker", "service", "update", "--force", "--detach=false", "--quiet", name],
deadline=MOVE_DEADLINE_SECONDS)
if ok:
moved.append(name)
else:
failed.append(name)
left.append({"service": name, "reason": f"didn't settle: {out.strip()[-120:]}"})
detail = f"moved {len(moved)} of {len(services)}"
if failed:
detail += "; didn't settle: " + ", ".join(failed)
return {"ok": not failed, "moved": moved, "left": left, "detail": detail}
# ── drive health (#5789) ─────────────────────────────────────────────────────
# smartctl ≥ 7.0 answers in JSON, the same for SATA, SAS and NVMe drives on any
# distribution; an older one is reported, never parsed as text. The helper
# finds the drives itself: the request names none.
SMARTCTL_MIN = (7, 0)
SMARTCTL_DEADLINE_SECONDS = 60
DRIVES_MAX = 32
# smartctl's exit status is a bit mask: 1 the command line, 2 the device
# couldn't be opened. Higher bits describe the disk, and its JSON still holds.
_SMARTCTL_UNREAD = 0b11
# ATA attributes counted as bad sectors.
_ATA_BAD = {5: "reallocated", 197: "pending", 198: "offline_uncorrectable"}
def smartctl_version():
"""(major, minor) of the installed smartctl, or None when there is none."""
if not shutil.which("smartctl"):
return None
ok, out = run(["smartctl", "--version"], deadline=30)
m = re.search(r"smartctl (\d+)\.(\d+)", out)
return (int(m.group(1)), int(m.group(2))) if m else (0, 0)
def smartctl_json(args: list):
"""(exit status, parsed JSON or None) of one smartctl call."""
try:
done = subprocess.run(["smartctl", "--json", *args], capture_output=True, text=True,
timeout=SMARTCTL_DEADLINE_SECONDS,
env=dict(os.environ, LANG="C", LC_ALL="C"))
except (subprocess.TimeoutExpired, OSError):
return None, None
try:
return done.returncode, json.loads(done.stdout)
except ValueError:
return done.returncode, None
def _int(v):
return v if isinstance(v, int) and not isinstance(v, bool) else None
def drive_name(name: str, kind) -> str:
"""A drive's name, unique on its host. Disks behind a RAID controller
share one path (/dev/bus/0) and differ only by the slot in their type
(megaraid,3; cciss,0), so the slot is part of the name (#5806)."""
base = os.path.basename(name)
if name.startswith("/dev/bus/"):
base = "bus" + base
if isinstance(kind, str) and "," in kind:
base += "-" + re.sub(r"[^A-Za-z0-9]", "", kind.split("+")[-1])
return base
def drive_summary(name: str, status, data, kind=None) -> dict:
"""The few figures Steward reads from one drive's `smartctl -a` JSON."""
out = {"device": drive_name(name, kind)}
if not isinstance(data, dict) or status is None or status & _SMARTCTL_UNREAD:
messages = ((data.get("smartctl") or {}).get("messages") or []) if isinstance(data, dict) else []
text = "; ".join(str(m.get("string", "")) for m in messages if isinstance(m, dict))
out["error"] = (text or "smartctl couldn't read it")[:200]
return out
dev = data.get("device") or {}
out["protocol"] = str(dev.get("protocol") or "")[:16]
out["model"] = str(data.get("model_name") or data.get("scsi_model_name") or "")[:80]
cap = (data.get("user_capacity") or {}).get("bytes")
if _int(cap):
out["capacity_bytes"] = cap
support = data.get("smart_support") or {}
if support.get("available") is False or support.get("enabled") is False:
out["available"] = False # a virtual disk, or SMART switched off
return out
passed = (data.get("smart_status") or {}).get("passed")
if isinstance(passed, bool):
out["passed"] = passed
temp = _int((data.get("temperature") or {}).get("current"))
if temp is not None:
out["temp_c"] = temp
hours = _int((data.get("power_on_time") or {}).get("hours"))
if hours is not None:
out["power_on_hours"] = hours
nvme = data.get("nvme_smart_health_information_log") or {}
for src, dst in (("critical_warning", "critical_warning"), ("percentage_used", "life_used_pct"),
("media_errors", "media_errors"), ("available_spare", "spare_pct"),
("available_spare_threshold", "spare_threshold_pct")):
if _int(nvme.get(src)) is not None:
out[dst] = nvme[src]
for attr in (data.get("ata_smart_attributes") or {}).get("table") or []:
key = _ATA_BAD.get(attr.get("id")) if isinstance(attr, dict) else None
raw = _int((attr.get("raw") or {}).get("value")) if key else None
if raw is not None:
out[key] = raw
used = _int((data.get("endurance_used") or {}).get("current_percent")) # ATA SSDs
used = used if used is not None else _int(data.get("scsi_percentage_used_endurance_indicator"))
if used is not None and "life_used_pct" not in out:
out["life_used_pct"] = used
if _int(data.get("scsi_grown_defect_list")) is not None:
out["grown_defects"] = data["scsi_grown_defect_list"]
return out
def do_drives() -> dict:
"""Each drive's health, as {"checked_at", "smartctl", "drives"} or
{"checked_at", "error"} when smartctl is missing or too old."""
base = {"checked_at": now_iso()}
version = smartctl_version()
if version is None:
return {**base, "error": "smartctl not installed"}
base["smartctl"] = "%d.%d" % version
if version < SMARTCTL_MIN:
return {**base, "error": "smartctl %d.%d is too old: drive health needs 7.0 or newer" % version}
_, scan = smartctl_json(["--scan-open"])
devices = (scan or {}).get("devices") if isinstance(scan, dict) else None
if not isinstance(devices, list):
return {**base, "error": "smartctl couldn't list the drives"}
drives = []
for d in devices[:DRIVES_MAX]:
name, kind = (d.get("name"), d.get("type")) if isinstance(d, dict) else (None, None)
if not isinstance(name, str) or not name.startswith("/dev/"):
continue
args = ["-a", name] if not isinstance(kind, str) else ["-a", "-d", kind, name]
status, data = smartctl_json(args)
drives.append(drive_summary(name, status, data, kind))
return {**base, "drives": drives}
def write_drives(report: dict, path: str | None = None) -> None:
"""DRIVES, written whole and readable by the agent."""
path = path or DRIVES
tmp = path + ".tmp"
with open(tmp, "w") as fh:
json.dump(report, fh)
os.chmod(tmp, 0o644)
os.replace(tmp, path)
def write_result(result: dict) -> None:
"""RESULTS/<id>.json, written whole (a temp file renamed into place)."""
os.makedirs(RESULTS, mode=0o755, exist_ok=True)
final = os.path.join(RESULTS, result["id"] + ".json")
tmp = final + ".tmp"
with open(tmp, "w") as fh:
json.dump(result, fh)
os.chmod(tmp, 0o644)
os.replace(tmp, final)
def already_handled(job_id: str) -> bool:
"""A request the agent sent again, for a job already run, is not run twice."""
return os.path.exists(os.path.join(RESULTS, job_id + ".json"))
def handle(req: dict) -> dict:
base = {"id": req["id"], "action": req["action"], "started_at": now_iso()}
if req["action"] == "drives": # a read, not a job: no result
write_drives(do_drives())
return {**base, "state": "done"}
write_result({**base, "state": "running"})
action = req["action"]
if action == "check":
outcome = do_check()
elif action == "update":
outcome = do_update()
elif action in ("drain", "undrain"):
outcome = do_node("drain" if action == "drain" else "active", req.get("node"))
elif action == "rebalance":
outcome = do_rebalance(req["services"])
else: # reboot
result = {**base, "state": "rebooting", "ok": True, "finished_at": now_iso()}
write_result(result)
os.sync()
run(["systemctl", "reboot"], deadline=60)
return result
result = {**base, **outcome, "state": "done" if outcome.get("ok") else "failed",
"finished_at": now_iso()}
write_result(result)
return result
def prune_results(now: float | None = None) -> None:
now = time.time() if now is None else now
try:
names = os.listdir(RESULTS)
except OSError:
return
for name in names:
path = os.path.join(RESULTS, name)
try:
if now - os.lstat(path).st_mtime > RESULTS_KEPT_SECONDS:
os.unlink(path)
except OSError:
pass
def pending_requests() -> list:
"""Request files, oldest first."""
try:
names = [n for n in os.listdir(REQUESTS) if n.endswith(".json")]
except OSError:
return []
paths = [os.path.join(REQUESTS, n) for n in names]
def age(p):
try:
return os.lstat(p).st_mtime
except OSError:
return 0.0
return sorted(paths, key=age)
def main() -> int:
prune_results()
for path in pending_requests():
req = read_request(path)
try:
os.unlink(path) # whatever it held: never re-read in a loop
except OSError:
pass
if req is None:
sys.stderr.write(f"[steward-maint] ignored {os.path.basename(path)}: not a valid request\n")
continue
if already_handled(req["id"]):
continue
result = handle(req)
sys.stderr.write(f"[steward-maint] {req['action']} {req['id']}: {result['state']}\n")
if result["state"] == "rebooting":
break # the rest waits for the next boot
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,26 @@
# Served by Steward at /plugins/host_agent/files/steward-agent.service and
# installed by install.sh and the host_agent playbooks: one copy of the unit.
[Unit]
Description=Steward host agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=steward-agent
Environment=STEWARD_AGENT_CONFIG=/etc/steward-agent.conf
ExecStart=/usr/bin/env python3 /usr/local/lib/steward-agent/agent.py
Restart=on-failure
RestartSec=5
# Stopping waits for the agent's last post, bounded at 10 s.
TimeoutStopSec=30
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadOnlyPaths=/proc /sys
# The one place the agent writes: requests to the maintenance helper (#5766).
ReadWritePaths=-/var/lib/steward-agent/requests
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,10 @@
# Starts the maintenance helper when the host agent leaves a request (#5766).
[Unit]
Description=Steward maintenance requests from the host agent
[Path]
PathExistsGlob=/var/lib/steward-agent/requests/*.json
Unit=steward-maint.service
[Install]
WantedBy=paths.target
@@ -0,0 +1,11 @@
# Runs as root, and does only what maint.py's fixed list allows: update the
# host's packages, reboot it, drain / reactivate its swarm node (#5766), or
# read its drives' health with smartctl (#5789).
[Unit]
Description=Steward maintenance helper
[Service]
Type=oneshot
ExecStart=/usr/bin/env python3 /usr/local/lib/steward-agent/maint.py
# A full upgrade on a slow mirror; past this systemd stops it.
TimeoutStartSec=3600
+660
View File
@@ -0,0 +1,660 @@
# 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).
A job first asks the helper what the update would change (#6098). Docker's
own packages restart the daemon from their install scripts, stopping every
container on the host, so a swarm node is drained before an update that
upgrades Docker; otherwise only before a reboot. After a drain the node's
tasks get DRAIN_SETTLE to move before the update or reboot goes ahead. The
containers running outside the swarm when the job began must be running again
when it ends, within RECOVER_LIMIT, or the job fails naming them.
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 → [checking →] [draining → settling →] updating →
[draining → settling →] [rebooting →] [undraining →] [recovering →] done, or
failed / timed_out. A node is drained at most once a job: before the update
when Docker is upgraded, else before the reboot. settling and recovering are
Steward's own waits, moved on by the minute tick. An agent older than
CHECK_AGENT gets no check: its job starts at updating.
A job that fails or is given up while its node may be drained makes the node
active again first: it goes to undraining with the outcome kept in
`ends_as`, and finishes as that once the undrain is through. A job never ends
leaving behind a drain it made.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
ACTIVE = ("queued", "checking", "draining", "settling", "updating", "rebooting",
"undraining", "recovering")
FINISHED = ("done", "failed", "timed_out")
# How a job failed for not coming back into service begins (cadence.py): its
# update (and reboot) went through, so once the host is back nothing is left
# failed.
NOT_BACK = "not back in service within"
# The helper action each handed state asks for.
STEP_ACTION = {"checking": "check", "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),
"checking": timedelta(minutes=30), # two listings, the helper stops each at 10
# The drain's result comes back the way the drain can take down: draining
# the node that runs the proxy (or Steward) moves them, and the result
# waits until they run elsewhere, image pull included.
"draining": timedelta(minutes=30),
"settling": timedelta(minutes=15), # the tick moves it on at DRAIN_SETTLE
"updating": timedelta(minutes=90), # the helper itself stops at 60
"rebooting": timedelta(minutes=30),
"undraining": timedelta(minutes=10),
"recovering": timedelta(minutes=15), # the tick fails it at RECOVER_LIMIT
}
# After a drain, how long the node's swarm tasks get to move before the
# update or the reboot goes ahead (the operator, #6098: "drain wait 5 minutes
# and then reboot").
DRAIN_SETTLE = timedelta(minutes=5)
# How long the containers that ran outside the swarm before the job get to
# run again after it.
RECOVER_LIMIT = timedelta(minutes=10)
# The first agent whose helper answers a check (#6098).
CHECK_AGENT = (1, 24, 0)
# A restart policy under which Docker starts a container again after a reboot.
RESTARTS = ("always", "unless-stopped")
CONTAINERS_KEPT = 40
# 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 may_be_drained(job) -> bool:
"""Whether the job may have left its node drained: its drain was handed
out (a result may just not have come back), or went through and the node
hasn't been made active again."""
return drains(job) and (job.state == "draining" or
(job.drained_at is not None and job.state in ("settling", "updating", "rebooting")))
def _give_up(job, state: str, now: datetime, detail: str) -> None:
"""End the job as `state` (failed or timed_out), making its node active
again first when the job may have drained it."""
if may_be_drained(job):
job.detail = detail[:DETAIL_MAX]
job.ends_as = state
_enter(job, "undraining", now)
else:
_finish(job, state, now, detail)
def can_check(agent_version) -> bool:
"""Whether an agent this version hands the helper a check."""
try:
return tuple(int(x) for x in str(agent_version).split(".")[:3]) >= CHECK_AGENT
except ValueError:
return False
def start(job, now: datetime, check: bool = False) -> None:
"""The host's agent is about to be handed the job: what the update would
change first, when its helper can say, else the update."""
if job.state == "queued":
_enter(job, "checking" if check else "updating", now)
def drains(job) -> bool:
"""Whether the job drains the host's swarm node: not when the host isn't
in a swarm, nor when the operator keeps its node drained."""
return bool(job.node_id and job.drain_host_id)
def outside_swarm(job) -> list:
"""The containers the check found running outside the swarm."""
return (job.checked or {}).get("containers") or []
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
REBOOT_POLICIES = ("if_required", "always", "never")
def wants_reboot(job) -> bool:
"""Reboot only when the update says one is needed (the operator's
default), always (a plan may ask), or never."""
if job.reboot_policy == "always":
return True
return bool(job.reboot_needed) and job.reboot_policy == "if_required"
def _checked(event: dict) -> dict:
"""What a check said, kept on the job: bounded, and only the fields used."""
containers = []
for c in event.get("containers") or []:
if isinstance(c, dict) and isinstance(c.get("name"), str) and c["name"]:
containers.append({"name": c["name"][:64], "restart": str(c.get("restart") or "no")[:16]})
packages = [str(p)[:64] for p in event.get("docker_packages") or [] if isinstance(p, str)]
pending = event.get("pending")
return {"pending": pending if isinstance(pending, int) else None,
"docker_packages": packages[:10], "containers": containers[:CONTAINERS_KEPT]}
def _after_check(job, event: dict, now: datetime) -> None:
job.docker_update = event.get("docker_update") is True
job.checked = _checked(event)
_enter(job, "draining" if job.docker_update and drains(job) else "updating", now)
def _after_update(job, now: datetime) -> None:
if not wants_reboot(job):
_after_work(job, now)
elif drains(job) and job.drained_at is None:
_enter(job, "draining", now)
else:
_enter(job, "rebooting", now)
def _after_work(job, now: datetime) -> None:
"""The update (and any reboot) is over: make the node active again if the
job drained it, then see the containers back."""
if job.drained_at is not None and drains(job):
_enter(job, "undraining", now)
else:
_recover(job, now)
def _recover(job, now: datetime) -> None:
_enter(job, "recovering" if outside_swarm(job) else "done", 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":
_finish(job, "failed", now, _after_given_up(job, detail + " (the node is still drained)"))
else:
_give_up(job, "failed", now, detail)
return True
if job.state == "checking":
_after_check(job, event, now)
elif 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":
job.drained_at = now
_enter(job, "settling", now)
elif job.state == "undraining":
if job.ends_as: # given up: the node is active again, the job ends as it was
_enter(job, job.ends_as, now)
else:
_recover(job, 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
_after_work(job, now)
return True
def settle(job, now: datetime) -> bool:
"""A drained node has had DRAIN_SETTLE for its tasks to move: on to the
update, or to the reboot when the update is done (reboot_needed is set)."""
if job.state != "settling" or job.state_at is None or now - job.state_at < DRAIN_SETTLE:
return False
_enter(job, "updating" if job.reboot_needed is None else "rebooting", now)
return True
def recovered(job, running: set, now: datetime) -> bool:
"""Done once every container that ran outside the swarm before the job
runs again (`running`: the host's containers seen running since the job
came to this state); failed, naming the rest, past RECOVER_LIMIT."""
if job.state != "recovering" or job.state_at is None:
return False
missing = [c for c in outside_swarm(job) if c["name"] not in running]
if not missing:
_enter(job, "done", now)
return True
if now - job.state_at < RECOVER_LIMIT:
return False
names = [c["name"] + ("" if c["restart"] in RESTARTS else " (no restart policy)")
for c in missing[:6]]
more = f" and {len(missing) - 6} more" if len(missing) > 6 else ""
_finish(job, "failed", now, f"not running again {int(RECOVER_LIMIT.total_seconds() // 60)} min "
f"after the job: {', '.join(names)}{more}")
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",
"checking": "the check of what the update would change didn't finish",
"draining": "the node wasn't drained",
"settling": "the wait after the drain didn't end",
"updating": "the update didn't finish",
"rebooting": "the host didn't come back",
"undraining": "the node wasn't made active again",
"recovering": "its containers weren't seen again"}[job.state]
detail = f"{waiting} within {int(limit.total_seconds() // 60)} min"
if job.state == "undraining":
_finish(job, job.ends_as or "timed_out", now, _after_given_up(job, detail))
else:
_give_up(job, "timed_out", now, detail)
return True
def _after_given_up(job, detail: str) -> str:
"""What an undrain going wrong adds to why a given-up job ended."""
return f"{job.detail}; then {detail}" if job.ends_as and job.detail else detail
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.
A swarm node's job keeps a second over the swarm-wide figures (#5788):
draining and rebooting a node takes it down and can leave a service
pinned to it short, which is the job, not news."""
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))
elif job.created_by is None or job.state in FINISHED:
return # a window needs an owner; none for a finished job
else:
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
if job.node_id:
await _swarm_window(session, job, host_name, now)
SWARM_SCOPE = ("docker", "swarm") # the swarm-wide figures' source and resource
async def _swarm_window(session, job, host_name: str, now: datetime) -> None:
"""The swarm half of a swarm node's job window, found again by its name
and the job's start, so it needs no column of its own."""
from sqlalchemy import select
from steward.models.alerts import MaintenanceWindow
name = f"Maintenance: {host_name} (swarm)"[:128]
w = (await session.execute(select(MaintenanceWindow).where(
MaintenanceWindow.name == name, MaintenanceWindow.scope_source == SWARM_SCOPE[0],
MaintenanceWindow.scope_resource == SWARM_SCOPE[1],
MaintenanceWindow.start_at >= job.created_at))).scalars().first()
if w is not None:
w.end_at = max(w.start_at, window_end(job, now))
elif job.created_by is not None and job.state not in FINISHED:
session.add(MaintenanceWindow(name=name, start_at=now, end_at=window_end(job, now),
scope_source=SWARM_SCOPE[0], scope_resource=SWARM_SCOPE[1],
created_by=job.created_by))
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 _moved(session, job, now: datetime) -> None:
"""After a job moved: its window follows it, and once it ends it goes on
the timeline."""
host_name = await _host_name(session, job.host_id)
await _window(session, job, host_name, now)
if job.state in FINISHED:
from . import timeline
await timeline.write(session, f"maintenance job {job.id}",
timeline.job_entry(session, job, host_name))
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. A result no job owns may be a run's rebalance (rebalance.py)."""
from sqlalchemy import select
from . import rebalance
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 job is None:
await rebalance.apply_event(session, event, now)
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 _moved(session, job, now)
return len(changed)
async def handoffs(session, host_id: str, host_name: str, now: datetime,
agent_version: str | None = None) -> 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, with a check
when the agent is new enough to hand one over. A manager also carries out
a run's rebalance."""
from sqlalchemy import or_, select
from . import rebalance
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, check=can_check(agent_version))
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 + await rebalance.handoffs(session, host_id)
async def _running(session, job) -> set:
"""The job's host's containers seen running since the job came to its
state; without the Docker plugin, the ones it waits for, as nothing can
say otherwise."""
from sqlalchemy import select
models = docker_models()
if models is None:
return {c["name"] for c in outside_swarm(job)}
DockerContainer = models.DockerContainer
return set((await session.execute(select(DockerContainer.name).where(
DockerContainer.host_id == job.host_id, DockerContainer.status == "running",
DockerContainer.scraped_at >= job.state_at))).scalars())
async def tick(session, now: datetime) -> int:
"""Move on the jobs waiting on Steward (the wait after a drain, the
containers seen again) and give up on jobs past their deadline; returns
how many were given up."""
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():
moved = settle(job, now)
if job.state == "recovering":
moved = recovered(job, await _running(session, job), now) or moved
if check_deadline(job, now):
gave_up += 1
moved = True
if moved:
await _moved(session, job, 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, availability: str = "active") -> 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. A node the
operator already set to drain or pause (a manager kept free of work) is
left as it is: no drain before, and no making it active after."""
node_id = node_hostname_to_id.get(host_name.lower())
if node_id is None:
return None, None
if availability != "active":
return node_id, 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 REBOOT_POLICIES:
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")
nodes, managers, availability = await _swarm(session)
helpers = set((await session.execute(select(HostAgentRegistration.host_id).where(
HostAgentRegistration.maint_helper.is_(True)))).scalars())
node_id = nodes.get(host.name.lower())
try:
return pick_drainer(nodes, managers, host.name, host.id, helpers,
availability.get(node_id, "active"))
except CannotStart:
# A job that never reboots needs a drain only if Docker is upgraded;
# with no manager to drain, it runs undrained rather than not at all.
if reboot_policy == "never":
return None, None
raise
async def create_job(session, host, reg, user_id: str | None,
reboot_policy: str = "if_required", now: datetime | None = None,
run_id: str | 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,
run_id=run_id)
session.add(job)
await session.flush()
return job
async def latest_job(session, host_id: str, now: datetime, days: int = 366):
"""The host's running job, else its last one (within a year), 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",
"checking": "Checking what the update will change",
"draining": "Draining its swarm node",
"settling": "Giving its swarm tasks 5 min to move",
"updating": "Updating",
"rebooting": "Rebooting",
"undraining": "Making its swarm node active again",
"recovering": "Waiting for its containers to run again",
"done": "Done",
"failed": "Failed",
"timed_out": "Timed out",
}
def came_back_late(job) -> bool:
"""A job failed only for not coming back into service in time, whose
host has since been found back (#6175)."""
return (job.state == "failed" and job.came_back_at is not None
and (job.detail or "").startswith(NOT_BACK))
def still_failed(job) -> bool:
"""Whether a finished job still flags its host: it failed or timed out,
and isn't a late return whose host has since come back (#6175). The
maintenance_failed figure, the overview and the pages all ask this."""
return job.state in ("failed", "timed_out") and not came_back_late(job)
def summary(job) -> str:
"""One line on what the job did, for the panel."""
if job.state not in FINISHED:
return LABELS[job.state]
if came_back_late(job):
return f"Back in service late, {job.came_back_at:%Y-%m-%d %H:%M}Z"
parts = []
if job.docker_update:
parts.append("Docker upgraded")
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
def docker_models():
"""The loaded Docker plugin's models module, or None when it isn't loaded."""
from steward.core.plugin_manager import loaded_plugin_submodule
return loaded_plugin_submodule("docker", "models")
async def _swarm(session) -> tuple:
"""({node hostname: node id}, {manager host id: its node id},
{node id: availability}) 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."""
models = docker_models()
if models is None:
return {}, {}, {}
DockerSwarmNode = models.DockerSwarmNode
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()}
availability = {n.node_id: (n.availability or "active")
for n in sorted(rows, key=lambda n: n.updated_at)}
return nodes, managers, availability
+307 -5
View File
@@ -5,14 +5,18 @@ from datetime import timedelta
from sqlalchemy import select
import json
from steward.hosts import service as host_svc
from steward.mcp._context import mcp_app
from steward.mcp.tools._shape import age_s, db_session, iso, now_utc
from steward.mcp._context import current_caller, mcp_app
from steward.mcp.tools._audit import audit_mcp
from steward.mcp.tools._shape import age_s, clamp_limit, db_session, iso, lookback, now_utc, pick, rnd
from steward.mcp.tools.overview import finding
from steward.models.hosts import Host
from steward.models.users import UserRole
from .models import HostAgentRegistration
from . import cadence, maintenance, rebalance, senders
from .models import HostAgentRegistration, HostMaintenanceJob, HostMaintenancePlan, HostMaintenanceRun
# A registration this old that has never checked in is a failed install, not one in progress.
PENDING_GRACE = timedelta(minutes=30)
@@ -33,6 +37,19 @@ async def _agents(db):
)).all()
def _updates(reg) -> dict | None:
"""A registration's pending updates and reboot, as list_agents says them (#5765)."""
if reg.updates_checked_at is None and reg.reboot_required is None:
return None
return {
"manager": reg.updates_manager, "pending": reg.updates_pending,
"security": reg.updates_security, "checked_at": iso(reg.updates_checked_at),
"error": reg.updates_error, "reboot_required": reg.reboot_required,
"reboot_required_since": iso(reg.reboot_required_since),
"reboot_packages": reg.reboot_packages.split(", ") if reg.reboot_packages else [],
}
async def list_agents() -> dict:
"""Every host agent Steward has registered, with its version and whether it is reporting.
@@ -41,6 +58,11 @@ async def list_agents() -> dict:
(registered, never checked in, usually an install that is still running
or that failed). Each row carries the agent version, kernel, distro, arch
and the IP the agent reports. get_host shows an agent's latest metrics.
`updates` is what the host's package manager has waiting, from the
agent's hourly check (agent ≥ 1.16.0): manager, pending, security (null
when the manager can't say), checked_at, error, and reboot_required with
since when and the packages that asked for it. null before the first check.
"""
now = now_utc()
stale_after = host_svc.agent_stale_after_seconds(mcp_app().config)
@@ -54,6 +76,7 @@ async def list_agents() -> dict:
"agent_version": reg.agent_version, "kernel": reg.kernel, "distro": reg.distro,
"arch": reg.arch, "reported_ip": reg.host_ip,
"registered_at": iso(reg.created_at),
"updates": _updates(reg),
}
for reg, h in rows
]
@@ -61,21 +84,300 @@ async def list_agents() -> dict:
return {"stale_after_s": stale_after, "counts": counts, "agents": agents, "total": len(agents)}
# ── maintenance (#5834): plans, runs, jobs, and starting one ─────────────────
JOBS_MAX = 50
# A failed job stays on the overview this long, unless a later one succeeded.
FAILED_JOB_DAYS = 7
async def _names(db, ids) -> dict[str, str]:
from steward.models.users import User
ids = {i for i in ids if i}
if not ids:
return {}
hosts = dict((await db.execute(select(Host.id, Host.name).where(Host.id.in_(ids)))).all())
users = dict((await db.execute(select(User.id, User.username).where(User.id.in_(ids)))).all())
return {**users, **hosts}
def _job_row(job, names: dict, now) -> dict:
deadline = maintenance.DEADLINES.get(job.state)
started = job.created_at if job.state == "queued" else job.state_at
return {
"job_id": job.id,
"host": names.get(job.host_id, job.host_id),
"state": job.state,
"label": maintenance.LABELS.get(job.state, job.state),
"summary": maintenance.summary(job),
"active": job.state in maintenance.ACTIVE,
"reboot_policy": job.reboot_policy,
"packages_updated": job.packages,
"reboot_needed": job.reboot_needed,
"rebooted_at": iso(job.back_at),
"swarm_node": job.node_id,
"drained_by": names.get(job.drain_host_id, job.drain_host_id),
"drained_at": iso(job.drained_at),
"came_back_at": iso(job.came_back_at),
"ends_as": job.ends_as,
"docker_update": job.docker_update,
"checked": job.checked,
"detail": job.detail,
"state_since": iso(job.state_at), "state_age_s": age_s(job.state_at, now),
"gives_up_at": iso(started + deadline) if deadline and started else None,
"started_by": names.get(job.created_by) if job.created_by else
("maintenance plan" if job.run_id else None),
"run_id": job.run_id,
"created_at": iso(job.created_at),
"finished_at": iso(job.finished_at),
}
async def list_maintenance_plans() -> dict:
"""Each maintenance plan: which inventory group it updates, when (weekly
slot, time zone, how long a run may keep starting hosts), its reboot
policy, whether it is enabled, its next slot, and its latest run.
A run updates the group's hosts one at a time; `latest_run` says how far
it got (`hosts_done` of `hosts`), which hosts it passed over and why
(`skipped`), and how it ended. `last_slot_at` is the last scheduled slot
that fired, null until one has. get_maintenance_job shows one host's job.
Plans are created and edited on the Maintenance page.
With `spread_after_run`, a run that drained swarm nodes ends by spreading
the swarm's services back out, leaving `keep_in_place` where they are;
`latest_run.spread` says what it moved and what it left and why (state:
handed, done, failed, timed_out, or nothing to move), null when the run
had none.
"""
from steward.models.ansible_inventory import AnsibleGroup
now = now_utc()
async with db_session() as db:
plans = list((await db.execute(select(HostMaintenancePlan)
.order_by(HostMaintenancePlan.name))).scalars())
groups = dict((await db.execute(select(AnsibleGroup.id, AnsibleGroup.name))).all())
latest = {}
for plan in plans:
latest[plan.id] = (await db.execute(
select(HostMaintenanceRun).where(HostMaintenanceRun.plan_id == plan.id)
.order_by(HostMaintenanceRun.started_at.desc()).limit(1))).scalar_one_or_none()
names = await _names(db, [h for r in latest.values() if r
for h in json.loads(r.host_ids or "[]")
+ [x["host_id"] for x in json.loads(r.skipped or "[]")]])
out = []
for plan in plans:
run = latest[plan.id]
nxt = cadence.next_slot(plan, now) if plan.enabled else None
row = {
"plan_id": plan.id, "name": plan.name,
"group": groups.get(plan.group_id, plan.group_id),
"schedule": cadence.describe(plan),
"weekday": cadence.WEEKDAYS[plan.weekday], "at_time": plan.at_time,
"time_zone": plan.time_zone, "window_minutes": plan.window_minutes,
"reboot_policy": plan.reboot_policy, "enabled": plan.enabled,
"spread_after_run": plan.spread,
"keep_in_place": rebalance.keep_list(plan.keep_in_place),
"next_slot_at": iso(nxt), "last_slot_at": iso(cadence.fired_slot(plan)),
"latest_run": None,
}
if run:
hosts = json.loads(run.host_ids or "[]")
row["latest_run"] = {
"run_id": run.id, "state": run.state, "manual": run.manual,
"hosts": [names.get(h, h) for h in hosts],
"hosts_done": min(run.position, len(hosts)),
"skipped": [{"host": names.get(x["host_id"], x["host_id"]), "reason": x["reason"]}
for x in json.loads(run.skipped or "[]")],
"detail": run.detail,
"spread": _spread_row(run.rebalance),
"started_at": iso(run.started_at), "finished_at": iso(run.finished_at),
}
out.append(row)
return {"plans": out, "total": len(out)}
def _spread_row(rb: dict | None) -> dict | None:
"""A run's rebalance as the tool shows it: no step ids or window ids."""
if not rb:
return None
return {"state": rb.get("state"), "planned": rb.get("planned") or [],
"moved": rb.get("moved") or [], "left": rb.get("left") or [],
"detail": rb.get("detail"), "started_at": rb.get("at"),
"finished_at": rb.get("finished_at")}
async def get_maintenance_job(host: str = "", job: str = "", limit: int = 10) -> dict:
"""A host's maintenance jobs, newest first (its running one first), or one
job by id.
host: a host's name or id (list_hosts); job: a job id (from here, or a
plan's run). One of the two.
limit: how many of the host's jobs (default 10, max 50).
A job is one host's update and, only when the update needs it, a reboot,
with its swarm node drained first and made active again after. It first
checks what the update would change: `docker_update` is whether it
upgrades Docker (whose packages restart the daemon), and `checked` holds
{pending, docker_packages, containers}, the containers then running
outside the swarm. A swarm node is drained before an update that upgrades
Docker, else before a reboot; `drained_at` is when, and its tasks get 5
min to move (settling). `state` moves queued → [checking →] [draining →
settling →] updating → [draining → settling →] [rebooting →]
[undraining →] [recovering →] done, or failed / timed_out; a job given
up while its node may be drained goes through undraining first, and
`ends_as` is what it then finishes as. recovering
waits up to 10 min for those containers to run again; `detail` says why
a job failed, naming any that didn't. A job failed only for not coming
back into service in time has `came_back_at`, when its host was first
found back; from then it no longer flags the host. `summary` is the host page's line
("Done: 12 packages updated, rebooted"). `gives_up_at` is when an active
job's current state times out.
"""
if bool(host.strip()) == bool(job.strip()):
raise ValueError("pass either `host` (a name or id) or `job` (a job id).")
now = now_utc()
limit = clamp_limit(limit, 10, JOBS_MAX)
async with db_session() as db:
if job.strip():
row = await db.get(HostMaintenanceJob, job.strip())
if row is None:
raise ValueError(f"no maintenance job {job.strip()!r}; "
"get_maintenance_job(host=...) lists a host's jobs.")
jobs = [row]
else:
h = pick(host, (await db.execute(select(Host))).scalars(),
kind="host", list_tool="list_hosts")
jobs = list((await db.execute(
select(HostMaintenanceJob).where(HostMaintenanceJob.host_id == h.id)
.order_by(HostMaintenanceJob.state.in_(maintenance.ACTIVE).desc(),
HostMaintenanceJob.created_at.desc())
.limit(limit + 1))).scalars())
names = await _names(db, [j.host_id for j in jobs] + [j.drain_host_id for j in jobs]
+ [j.created_by for j in jobs])
rows = [_job_row(j, names, now) for j in jobs[:limit]]
return {"jobs": rows, "total": len(rows), "truncated": len(jobs) > limit}
async def get_host_senders(host: str, range: str = "1h", direction: str = "tx", limit: int = 10) -> dict:
"""Who sent (or received) a host's network traffic: its containers and NFS
mounts, ranked against the host's own total.
host: name or id (list_hosts). range: "1h" (default), "6h", "24h" or "7d"
(at most the raw window; the rollup keeps no per-sender detail).
direction: "tx" (default, what the host sent) or "rx" (what it received).
limit: senders listed (default 10, max 50).
The window is cut into about 120 buckets (`bucket_s` wide). `host` is the
host's physical interfaces: average, peak and the bucket it peaked in.
Each sender is a container (a swarm service's tasks added up, by service
name) or an NFS mount, with its average over the window, its own peak
and when, and `at_host_peak_bps`, what it sent in the host's peak bucket;
biggest there first. `unaccounted_*` is what the host carried beyond them
all: the host itself (a download, a backup run on the host, swarm ingress
passing traffic to another node). A container's traffic to another on
the same host counts for it without crossing an interface, so senders
can add up to more than the host. Per-container rates need Steward after
#6115; NFS bytes need agent 1.25.0. Bytes a second throughout.
"""
since, key = lookback(range)
limit = clamp_limit(limit, 10, 50)
async with db_session() as db:
h = pick(host, (await db.execute(select(Host))).scalars(), kind="host", list_tool="list_hosts")
got = await senders.senders(db, h.name, since, direction=direction, limit=limit)
if got["host"] is None:
raise ValueError(f"no net_{direction}_bps recorded for host {h.name!r} in the last {key}; "
"is its agent reporting? list_agents says.")
return {
"host_id": h.id, "host_name": h.name, "range": key, "direction": direction,
"since": iso(got["since"]), "bucket_s": got["bucket_s"],
"host": {"avg_bps": rnd(got["host"]["avg_bps"], 1), "peak_bps": rnd(got["host"]["peak_bps"], 1),
"peak_at": iso(got["host"]["peak_at"])},
"senders": [{"kind": r["kind"], "name": r["name"], "at_host_peak_bps": rnd(r["at_host_peak_bps"], 1),
"avg_bps": rnd(r["avg_bps"], 1), "peak_bps": rnd(r["peak_bps"], 1),
"peak_at": iso(r["peak_at"])} for r in got["senders"]],
"more": got["more"],
"unaccounted_avg_bps": rnd(got["unaccounted_avg_bps"], 1),
"unaccounted_at_peak_bps": rnd(got["unaccounted_at_peak_bps"], 1),
}
async def run_maintenance(host: str, reboot: str = "if_required") -> dict:
"""Update a host now, as the host page's Update now does: the job starts
with the agent's next post and get_maintenance_job follows it.
host: the host's name or id.
reboot: "if_required" (default) reboots only when the update says the
host needs it, draining its swarm node first and making it active after;
"never" updates and leaves any reboot for later.
Refused while the host already has a job running, or when its agent has
no maintenance helper yet (Update agent installs it). Admin only, as on
the page. Recorded in the audit log.
"""
if reboot not in ("if_required", "never"):
raise ValueError('reboot must be "if_required" or "never". Nothing was started.')
caller = current_caller()
async with db_session() as db, db.begin():
h = pick(host, (await db.execute(select(Host))).scalars(),
kind="host", list_tool="list_hosts")
reg = (await db.execute(select(HostAgentRegistration).where(
HostAgentRegistration.host_id == h.id))).scalar_one_or_none()
try:
created = await maintenance.create_job(db, h, reg, caller.user_id, reboot)
except maintenance.CannotStart as exc:
raise ValueError(f"{h.name} can't be updated now: {exc}. Nothing was started.") from None
job_id, host_id, name = created.id, h.id, h.name
await audit_mcp("host_agent.maintenance", "host", host_id,
{"job_id": job_id, "reboot_policy": reboot})
return {"job_id": job_id, "host": name, "state": "queued", "reboot_policy": reboot,
"next": f'get_maintenance_job(job="{job_id}") follows it; it starts with the '
"agent's next post."}
async def _failed_jobs(db, now) -> list:
"""Each host's latest finished job, when it failed or timed out within
FAILED_JOB_DAYS and still flags the host (a later success clears it, and
so does a late return once the host is back, #6175)."""
rows = (await db.execute(
select(HostMaintenanceJob, Host.name).join(Host, Host.id == HostMaintenanceJob.host_id)
.where(HostMaintenanceJob.state.in_(maintenance.FINISHED),
HostMaintenanceJob.finished_at >= now - timedelta(days=FAILED_JOB_DAYS))
.order_by(HostMaintenanceJob.finished_at.desc()))).all()
latest = {}
for j, name in rows:
latest.setdefault(j.host_id, (j, name))
return [(j, name) for j, name in latest.values() if maintenance.still_failed(j)]
async def mcp_overview(db) -> list[dict]:
"""Agents registered more than PENDING_GRACE ago that have never checked in.
"""Agents registered more than PENDING_GRACE ago that have never checked
in, and hosts whose latest maintenance job failed or timed out.
Agents that stopped reporting are already flagged by the core overview
from their metrics, so they are not repeated here.
"""
now = now_utc()
return [
out = [
finding("warning", "agent_never_reported", h.name,
"host agent was registered but has never checked in; the install may have failed",
since=reg.created_at, ids={"host_id": h.id}, next="list_agents()")
for reg, h in await _agents(db)
if reg.last_seen_at is None and now - reg.created_at > PENDING_GRACE
]
out += [
finding("warning", "maintenance_failed", name,
f"maintenance {maintenance.LABELS[j.state].lower()}"
+ (f": {j.detail}" if j.detail else ""),
since=j.finished_at, ids={"host_id": j.host_id, "job_id": j.id},
next=f'get_maintenance_job(job="{j.id}")')
for j, name in await _failed_jobs(db, now)
]
return out
def register_tools(register) -> None:
register(list_agents, role=UserRole.viewer, read_only=True)
register(list_maintenance_plans, role=UserRole.viewer, read_only=True)
register(get_maintenance_job, role=UserRole.viewer, read_only=True)
register(get_host_senders, role=UserRole.viewer, read_only=True)
register(run_maintenance, role=UserRole.admin, read_only=False)
+11 -1
View File
@@ -21,10 +21,20 @@ SOURCE_MODULE = "host_agent"
HISTORY_METRICS = (
"cpu_pct", "mem_used_pct", "disk_used_pct_worst", "load_1m",
"net_rx_bps", "net_tx_bps", "disk_read_bps", "disk_write_bps",
"temp_c_max", "psi_mem_some_avg10",
"temp_c_max", "psi_mem_some_avg10", "nfs_exe_max_ms", "mounts_stalled",
)
async def has_nfs(session, host_name: str) -> bool:
"""Whether the host's agent has reported an NFS mount's delay (#5996)."""
from steward.models.metrics import PluginMetricLatest
return (await session.execute(
select(PluginMetricLatest.metric_name).where(
PluginMetricLatest.source_module == SOURCE_MODULE,
PluginMetricLatest.resource_name.startswith(host_name + ":"),
PluginMetricLatest.metric_name == "nfs_ops_per_sec").limit(1))).first() is not None
async def _latest_metrics_for_host(session, host_name: str) -> dict[str, dict[str, float]]:
"""{resource_name: {metric: value}} — latest sample per (resource, metric) for
a host + its sub-resources, from plugin_metric_latest (#5208). A host whose
@@ -0,0 +1,32 @@
# plugins/host_agent/migrations/versions/host_agent_004_downtime.py
"""host_agent: each host's downtime and what it was (#5756)
Revision ID: host_agent_004_downtime
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "host_agent_004_downtime"
down_revision: Union[str, None] = "host_agent_003_clock_offset"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"host_downtime",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="CASCADE"),
nullable=False),
sa.Column("kind", sa.String(16), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_host_downtime_host_id", "host_downtime", ["host_id"])
def downgrade() -> None:
op.drop_index("ix_host_downtime_host_id", table_name="host_downtime")
op.drop_table("host_downtime")
@@ -0,0 +1,34 @@
# plugins/host_agent/migrations/versions/host_agent_005_updates.py
"""host_agent: a host's pending updates and whether it needs a reboot (#5765)
Revision ID: host_agent_005_updates
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "host_agent_005_updates"
down_revision: Union[str, None] = "host_agent_004_downtime"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_COLUMNS = (
("updates_manager", sa.String(8)),
("updates_pending", sa.Integer()),
("updates_security", sa.Integer()),
("updates_checked_at", sa.DateTime(timezone=True)),
("updates_error", sa.String(200)),
("reboot_required", sa.Boolean()),
("reboot_required_since", sa.DateTime(timezone=True)),
("reboot_packages", sa.Text()),
)
def upgrade() -> None:
for name, kind in _COLUMNS:
op.add_column("host_agent_registrations", sa.Column(name, kind, nullable=True))
def downgrade() -> None:
for name, _kind in reversed(_COLUMNS):
op.drop_column("host_agent_registrations", name)
@@ -0,0 +1,21 @@
# plugins/host_agent/migrations/versions/host_agent_006_maint_helper.py
"""host_agent: whether a host has the maintenance helper (#5766)
Revision ID: host_agent_006_maint_helper
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "host_agent_006_maint_helper"
down_revision: Union[str, None] = "host_agent_005_updates"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("host_agent_registrations", sa.Column("maint_helper", sa.Boolean(), nullable=True))
def downgrade() -> None:
op.drop_column("host_agent_registrations", "maint_helper")
@@ -0,0 +1,47 @@
"""host_agent: a host's maintenance job, followed through its reboot (#5767)
Revision ID: host_agent_007_maint_jobs
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "host_agent_007_maint_jobs"
down_revision: Union[str, None] = "host_agent_006_maint_helper"
branch_labels: Union[str, Sequence[str], None] = None
# maintenance_windows (core 0010) must exist before the window_id key.
depends_on: Union[str, Sequence[str], None] = ("0010_maintenance_windows",)
def upgrade() -> None:
op.create_table(
"host_maintenance_jobs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="CASCADE"),
nullable=False),
sa.Column("state", sa.String(16), nullable=False),
sa.Column("state_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("reboot_policy", sa.String(16), nullable=False),
sa.Column("step_id", sa.String(64), nullable=True),
sa.Column("step_seen", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("node_id", sa.String(64), nullable=True),
sa.Column("drain_host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="SET NULL"),
nullable=True),
sa.Column("packages", sa.Integer(), nullable=True),
sa.Column("reboot_needed", sa.Boolean(), nullable=True),
sa.Column("back_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("detail", sa.Text(), nullable=True),
sa.Column("window_id", sa.String(36),
sa.ForeignKey("maintenance_windows.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_host_maintenance_jobs_host_id", "host_maintenance_jobs", ["host_id"])
op.create_index("ix_host_maintenance_jobs_state", "host_maintenance_jobs", ["state"])
op.create_index("ix_host_maintenance_jobs_step_id", "host_maintenance_jobs", ["step_id"])
def downgrade() -> None:
op.drop_table("host_maintenance_jobs")
@@ -0,0 +1,61 @@
"""host_agent: a group's maintenance cadence and its runs (#5768)
Revision ID: host_agent_008_maint_plans
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "host_agent_008_maint_plans"
down_revision: Union[str, None] = "host_agent_007_maint_jobs"
branch_labels: Union[str, Sequence[str], None] = None
# ansible_groups (core 0016) must exist before the group_id key.
depends_on: Union[str, Sequence[str], None] = ("0016_ansible_inventory_groups",)
def upgrade() -> None:
op.create_table(
"host_maintenance_plans",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("group_id", sa.String(36), sa.ForeignKey("ansible_groups.id", ondelete="CASCADE"),
nullable=False),
sa.Column("weekday", sa.Integer(), nullable=False),
sa.Column("at_time", sa.String(5), nullable=False),
sa.Column("time_zone", sa.String(64), nullable=False, server_default="UTC"),
sa.Column("window_minutes", sa.Integer(), nullable=False, server_default="240"),
sa.Column("reboot_policy", sa.String(16), nullable=False, server_default="if_required"),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("last_slot_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_table(
"host_maintenance_runs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("plan_id", sa.String(36),
sa.ForeignKey("host_maintenance_plans.id", ondelete="CASCADE"), nullable=False),
sa.Column("state", sa.String(16), nullable=False),
sa.Column("host_ids", sa.Text(), nullable=False, server_default="[]"),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column("skipped", sa.Text(), nullable=False, server_default="[]"),
sa.Column("settle_since", sa.DateTime(timezone=True), nullable=True),
sa.Column("detail", sa.Text(), nullable=True),
sa.Column("manual", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_host_maintenance_runs_plan_id", "host_maintenance_runs", ["plan_id"])
op.create_index("ix_host_maintenance_runs_state", "host_maintenance_runs", ["state"])
op.add_column("host_maintenance_jobs", sa.Column(
"run_id", sa.String(36), sa.ForeignKey("host_maintenance_runs.id", ondelete="SET NULL"),
nullable=True))
op.create_index("ix_host_maintenance_jobs_run_id", "host_maintenance_jobs", ["run_id"])
def downgrade() -> None:
op.drop_index("ix_host_maintenance_jobs_run_id", "host_maintenance_jobs")
op.drop_column("host_maintenance_jobs", "run_id")
op.drop_table("host_maintenance_runs")
op.drop_table("host_maintenance_plans")
@@ -0,0 +1,24 @@
# plugins/host_agent/migrations/versions/host_agent_009_drive_health.py
"""host_agent: each host's newest drive-health report (#5789)
Revision ID: host_agent_009_drive_health
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "host_agent_009_drive_health"
down_revision: Union[str, None] = "host_agent_008_maint_plans"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("host_agent_registrations", sa.Column("drive_health", sa.JSON(), nullable=True))
op.add_column("host_agent_registrations",
sa.Column("drive_health_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("host_agent_registrations", "drive_health_at")
op.drop_column("host_agent_registrations", "drive_health")
@@ -0,0 +1,32 @@
# plugins/host_agent/migrations/versions/host_agent_010_maint_check.py
"""host_agent: a maintenance job's check of what its update would change, and
when it drained its node (#6098)
Revision ID: host_agent_010_maint_check
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "host_agent_010_maint_check"
down_revision: Union[str, None] = "host_agent_009_drive_health"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("host_maintenance_jobs", sa.Column("docker_update", sa.Boolean(), nullable=True))
op.add_column("host_maintenance_jobs", sa.Column("checked", sa.JSON(), nullable=True))
op.add_column("host_maintenance_jobs",
sa.Column("drained_at", sa.DateTime(timezone=True), nullable=True))
# A job already past its drain when this lands still makes its node
# active again: before this, every job that drained went on to undrain.
op.execute("UPDATE host_maintenance_jobs SET drained_at = state_at "
"WHERE state IN ('rebooting', 'undraining') "
"AND node_id IS NOT NULL AND drain_host_id IS NOT NULL")
def downgrade() -> None:
op.drop_column("host_maintenance_jobs", "drained_at")
op.drop_column("host_maintenance_jobs", "checked")
op.drop_column("host_maintenance_jobs", "docker_update")
@@ -0,0 +1,23 @@
# plugins/host_agent/migrations/versions/host_agent_011_came_back.py
"""host_agent: when a job failed for not coming back into service in time
first found its host back (#6175)
Revision ID: host_agent_011_came_back
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "host_agent_011_came_back"
down_revision: Union[str, None] = "host_agent_010_maint_check"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("host_maintenance_jobs",
sa.Column("came_back_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("host_maintenance_jobs", "came_back_at")
@@ -0,0 +1,22 @@
# plugins/host_agent/migrations/versions/host_agent_012_ends_as.py
"""host_agent: what a job given up while its node may be drained finishes as,
once the node is active again
Revision ID: host_agent_012_ends_as
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "host_agent_012_ends_as"
down_revision: Union[str, None] = "host_agent_011_came_back"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("host_maintenance_jobs", sa.Column("ends_as", sa.String(16), nullable=True))
def downgrade() -> None:
op.drop_column("host_maintenance_jobs", "ends_as")
@@ -0,0 +1,28 @@
# plugins/host_agent/migrations/versions/host_agent_013_spread.py
"""host_agent: a maintenance run spreads swarm services back out after its
last host, and a plan can keep named services where they are
Revision ID: host_agent_013_spread
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "host_agent_013_spread"
down_revision: Union[str, None] = "host_agent_012_ends_as"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("host_maintenance_plans", sa.Column("spread", sa.Boolean, nullable=False,
server_default=sa.true()))
op.add_column("host_maintenance_plans", sa.Column("keep_in_place", sa.Text, nullable=False,
server_default=""))
op.add_column("host_maintenance_runs", sa.Column("rebalance", sa.JSON, nullable=True))
def downgrade() -> None:
op.drop_column("host_maintenance_runs", "rebalance")
op.drop_column("host_maintenance_plans", "keep_in_place")
op.drop_column("host_maintenance_plans", "spread")
+145 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, String, DateTime, Float, ForeignKey
from sqlalchemy import JSON, Boolean, Column, String, DateTime, Float, ForeignKey, Integer, Text
from steward.models.base import Base
@@ -34,6 +34,150 @@ class HostAgentRegistration(Base):
# and when that was. None until an agent that stamps its posts reports.
clock_offset_s = Column(Float, nullable=True)
clock_checked_at = Column(DateTime(timezone=True), nullable=True)
# What the package manager has waiting, from the agent's hourly check
# (agent ≥ 1.16.0, #5765). updates_security is None when the manager
# can't say; updates_error is why the last check failed.
updates_manager = Column(String(8), nullable=True)
updates_pending = Column(Integer, nullable=True)
updates_security = Column(Integer, nullable=True)
updates_checked_at = Column(DateTime(timezone=True), nullable=True)
updates_error = Column(String(200), nullable=True)
# Whether the host needs a reboot (None: it can't say), since when, and
# the packages that asked for it, comma-separated.
reboot_required = Column(Boolean, nullable=True)
reboot_required_since = Column(DateTime(timezone=True), nullable=True)
reboot_packages = Column(Text, nullable=True)
# Whether the host has the root maintenance helper (steward-maint.path and
# its request directory) that updates and reboots go through (#5766); None
# until an agent that says reports.
maint_helper = Column(Boolean, nullable=True)
# The host's newest drive-health report, as the root helper read it with
# smartctl (agent ≥ 1.20.0, #5789): {"checked_at", "smartctl", "drives"} or
# {"error"}. drives.py judges it; the figures are also metrics.
drive_health = Column(JSON, nullable=True)
drive_health_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False,
default=_utcnow, onupdate=_utcnow)
class HostDowntime(Base):
"""A span a host's agent was away, and what the agent said it was (#5756).
kind is "reboot" (the host went down and came back), "agent_stop" (the
agent stopped; the host stayed up), "unclean" (the host came back from a
boot nobody announced: power loss or a crash) or "lost_contact" (the agent
ran but couldn't reach Steward). ended_at is None while it is going on.
"""
__tablename__ = "host_downtime"
id = Column(String(36), primary_key=True, default=_uuid)
host_id = Column(String(36), ForeignKey("hosts.id", ondelete="CASCADE"),
nullable=False, index=True)
kind = Column(String(16), nullable=False)
started_at = Column(DateTime(timezone=True), nullable=False)
ended_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow)
class HostMaintenanceJob(Base):
"""One host's update, and its reboot when the update needs one (#5767).
Steward keeps the job here and the host's agent carries out each step
through its root helper; maintenance.py has the states and transitions.
step_id is the helper request id of the current step, and step_seen says a
result for it has arrived, so it stops being handed out. node_id and
drain_host_id are set when the host is a swarm node: the node to drain and
the manager whose agent drains it.
"""
__tablename__ = "host_maintenance_jobs"
id = Column(String(36), primary_key=True, default=_uuid)
host_id = Column(String(36), ForeignKey("hosts.id", ondelete="CASCADE"),
nullable=False, index=True)
state = Column(String(16), nullable=False, default="queued", index=True)
state_at = Column(DateTime(timezone=True), nullable=True)
reboot_policy = Column(String(16), nullable=False, default="if_required")
step_id = Column(String(64), nullable=True, index=True)
step_seen = Column(Boolean, nullable=False, default=False)
node_id = Column(String(64), nullable=True)
drain_host_id = Column(String(36), ForeignKey("hosts.id", ondelete="SET NULL"), nullable=True)
packages = Column(Integer, nullable=True)
reboot_needed = Column(Boolean, nullable=True)
back_at = Column(DateTime(timezone=True), nullable=True)
detail = Column(Text, nullable=True)
window_id = Column(String(36), ForeignKey("maintenance_windows.id", ondelete="SET NULL"),
nullable=True)
created_by = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow)
finished_at = Column(DateTime(timezone=True), nullable=True)
# The cadence run this job belongs to (#5768); None for Update now.
run_id = Column(String(36), ForeignKey("host_maintenance_runs.id", ondelete="SET NULL"),
nullable=True, index=True)
# What the check said the update would change (#6098): whether it
# upgrades Docker, and {pending, docker_packages, containers} with the
# containers then running outside the swarm. drained_at is when the job's
# drain finished, before the update or before the reboot.
docker_update = Column(Boolean, nullable=True)
checked = Column(JSON, nullable=True)
drained_at = Column(DateTime(timezone=True), nullable=True)
# A job failed for not coming back into service in time: when its host
# was first found back (#6175). From then on the job no longer flags it.
came_back_at = Column(DateTime(timezone=True), nullable=True)
# A job given up (failed or timed_out) while its node may be drained makes
# the node active again first; ends_as is what it finishes as after that.
ends_as = Column(String(16), nullable=True)
class HostMaintenancePlan(Base):
"""A group's maintenance cadence (#5768): every week on `weekday` at
`at_time` in `time_zone`, its hosts are updated one at a time, each
rebooting per `reboot_policy`. No host is started after `window_minutes`
from the run's start. last_slot_at is the last scheduled slot that fired;
Run now leaves it alone, so the schedule keeps its time. With `spread`, a
run that drained swarm nodes ends by spreading services back out, leaving
the services named in `keep_in_place` (comma-separated) where they are."""
__tablename__ = "host_maintenance_plans"
id = Column(String(36), primary_key=True, default=_uuid)
name = Column(String(128), nullable=False)
group_id = Column(String(36), ForeignKey("ansible_groups.id", ondelete="CASCADE"),
nullable=False)
weekday = Column(Integer, nullable=False) # 0 = Monday
at_time = Column(String(5), nullable=False) # "HH:MM"
time_zone = Column(String(64), nullable=False, default="UTC")
window_minutes = Column(Integer, nullable=False, default=240)
reboot_policy = Column(String(16), nullable=False, default="if_required")
enabled = Column(Boolean, nullable=False, default=True)
spread = Column(Boolean, nullable=False, default=True)
keep_in_place = Column(Text, nullable=False, default="")
last_slot_at = Column(DateTime(timezone=True), nullable=True)
created_by = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow)
class HostMaintenanceRun(Base):
"""One run of a plan: its hosts in order, one at a time (#5768).
host_ids is the order fixed at the start (JSON list); position is the host
being worked on; skipped is [{"host_id", "reason"}] for hosts the run
passed over. settle_since is when the current host's job finished and the
run began waiting for the host to be back in service. rebalance is the
spreading of swarm services back out once the last host is back
(rebalance.py): its state, the manager carrying it out, what it moved and
what it left and why; None while not reached or not wanted."""
__tablename__ = "host_maintenance_runs"
id = Column(String(36), primary_key=True, default=_uuid)
plan_id = Column(String(36), ForeignKey("host_maintenance_plans.id", ondelete="CASCADE"),
nullable=False, index=True)
state = Column(String(16), nullable=False, default="running", index=True)
host_ids = Column(Text, nullable=False, default="[]")
position = Column(Integer, nullable=False, default=0)
skipped = Column(Text, nullable=False, default="[]")
settle_since = Column(DateTime(timezone=True), nullable=True)
detail = Column(Text, nullable=True)
manual = Column(Boolean, nullable=False, default=False)
rebalance = Column(JSON, nullable=True)
started_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow)
finished_at = Column(DateTime(timezone=True), nullable=True)
+3
View File
@@ -16,4 +16,7 @@ tags:
config:
stale_after_seconds: 180
# How long a reboot or restart the agent announced explains its silence
# before "agent offline" fires (agent >= 1.14.0, #5756).
reboot_grace_seconds: 600
default_interval_seconds: 30
+374
View File
@@ -0,0 +1,374 @@
# plugins/host_agent/rebalance.py
"""A maintenance run spreads swarm services back out after its last host.
A run drains each swarm node in turn, and the swarm never moves a task back
on its own, so every run used to end with the services on whichever node was
drained first. Once the run's last host is back in service, Steward picks
services to move from the fullest node to the emptiest and hands the list to
a swarm manager's helper (maint.py `rebalance`), which force-updates each in
turn: spec unchanged, no image pulled, and the swarm starts it again on its
emptiest node.
A service moves only when the emptier node already holds the exact image the
service is pinned to (its agent reports every image digest it holds), so a
pruned image or a digest the registry has since deleted never strands one.
A service the plan keeps in place, one below its replicas, and one a
constraint places are left where they are, and the run says why.
The choice is model-free (`choose`); the database side is below it, driven by
cadence.advance, the agent's posts (handoffs, results) and the minute tick.
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta
from . import maintenance
# The first agent whose helper can rebalance, and that reports image digests.
REBALANCE_AGENT = (1, 26, 0)
# How many services one rebalance moves at most; the helper refuses more.
SERVICES_MAX = 48
# A node's list of images older than this isn't trusted (agents report it
# every 10 minutes).
DIGESTS_FRESH = timedelta(minutes=30)
# The helper starts no move after 40 min and gives each 5 to settle.
DEADLINE = timedelta(minutes=60)
STATES = ("handed", "done", "failed", "timed_out", "nothing")
WINDOW_NAME = "Maintenance: spreading swarm services back out"
def keep_list(text: str | None) -> list[str]:
"""A plan's keep_in_place as names, in the order written."""
out = []
for name in (text or "").replace("\n", ",").split(","):
name = name.strip()
if name and name not in out:
out.append(name)
return out
def _repo(image: str) -> str:
"""An image reference's repository, without tag or digest, in the short
form Docker reports (no docker.io/ or library/)."""
ref = image.split("@", 1)[0]
slash = ref.rfind("/")
colon = ref.rfind(":")
if colon > slash:
ref = ref[:colon]
for prefix in ("docker.io/", "index.docker.io/"):
if ref.startswith(prefix):
ref = ref[len(prefix):]
if ref.startswith("library/"):
ref = ref[len("library/"):]
return ref
def pinned(image: str | None, digest: str | None) -> str | None:
""""repo@sha256:…" for a service's image, or None when it isn't pinned."""
if not image or not digest or not digest.startswith("sha256:"):
return None
return f"{_repo(image)}@{digest}"
def held(digests) -> set:
"""A node's reported digests, in the same form as pinned()."""
return {f"{_repo(d)}@{d.split('@', 1)[1]}" for d in digests or []
if isinstance(d, str) and "@sha256:" in d}
def choose(services: list, nodes: dict, images: dict, keep=(),
limit: int = SERVICES_MAX) -> tuple[list, list]:
"""(services to move, [{"service", "reason"}] left where they are).
services: {"name", "mode", "desired", "running", "image" ("repo@sha256:…"
or None), "placement": [{"node_id", "running"}]}; nodes: {node id: name}
for the nodes to balance across (ready and active); images: {node id: set
of "repo@sha256:…"}, a node missing when its list is unknown.
While the fullest node has two or more tasks than the emptiest, a service
whose tasks all run on the fullest moves; one with several replicas there
first, as one node holding them all defeats the replicas. A move counts
as its share of the replicas going to the emptiest. What is reported left
is what kept the nodes from evening out: the services that would have
moved next but can't."""
if len(nodes) < 2:
return [], []
counts = {n: 0 for n in nodes}
for s in services:
for p in s.get("placement") or []:
if p.get("node_id") in counts:
counts[p["node_id"]] += int(p.get("running") or 0)
keep = set(keep)
order = sorted(services, key=lambda s: (-int(s.get("running") or 0), s["name"]))
moves: list = []
left: dict = {}
while len(moves) < limit:
src = max(counts, key=lambda n: (counts[n], n))
dst = min(counts, key=lambda n: (counts[n], n))
gap = counts[src] - counts[dst]
if gap < 2:
break
pick, blocked = None, {}
for s in order:
name, running = s["name"], int(s.get("running") or 0)
on = {p.get("node_id"): int(p.get("running") or 0) for p in s.get("placement") or []
if int(p.get("running") or 0) > 0}
if name in moves or s.get("mode") != "replicated" or set(on) != {src}:
continue
share = max(1, running // len(nodes))
if 2 * share > gap:
continue # it would only tip the balance the other way
if name in keep:
blocked[name] = "kept in place by the plan"
elif running < int(s.get("desired") or 0):
blocked[name] = "below its replicas"
elif not s.get("image"):
blocked[name] = "its image isn't pinned to a digest"
elif dst not in images:
blocked[name] = f"no image list from {nodes[dst]} (its agent needs 1.26.0)"
elif s["image"] not in images[dst]:
blocked[name] = f"its image isn't on {nodes[dst]}"
else:
pick = s
break
if pick is None:
left = blocked # what kept the nodes from evening out
break
share = max(1, int(pick.get("running") or 0) // len(nodes))
moves.append(pick["name"])
counts[src] -= share
counts[dst] += share
return moves, [{"service": n, "reason": r} for n, r in sorted(left.items())]
def can_rebalance(agent_version) -> bool:
try:
return tuple(int(x) for x in str(agent_version).split(".")[:3]) >= REBALANCE_AGENT
except ValueError:
return False
def step_id(run) -> str:
return f"{run.id}-rebalance"
def _set(run, **changes) -> None:
"""Change the run's rebalance record (a new dict, so the JSON column
sees the change)."""
run.rebalance = {**(run.rebalance or {}), **changes}
def handoff(run) -> dict | None:
"""The step to hand the manager's agent, until its result arrives."""
rb = run.rebalance or {}
if rb.get("state") != "handed" or rb.get("seen"):
return None
return {"id": rb["step_id"], "action": "rebalance", "services": rb.get("planned") or []}
def apply_result(run, event: dict, now: datetime) -> bool:
"""A `maintenance` event for the run's rebalance; True when it changed."""
rb = run.rebalance or {}
if rb.get("state") != "handed" or event.get("id") != rb.get("step_id"):
return False
state = event.get("state")
if state == "running":
if rb.get("seen"):
return False
_set(run, seen=True)
return True
if state not in ("done", "failed"):
return False
moved = [str(m)[:64] for m in event.get("moved") or [] if isinstance(m, str)][:SERVICES_MAX]
gone = [{"service": str(x.get("service"))[:64], "reason": str(x.get("reason"))[:160]}
for x in event.get("left") or [] if isinstance(x, dict)][:SERVICES_MAX]
ok = state == "done" and event.get("ok") is not False
_set(run, state="done" if ok else "failed", moved=moved, left=(rb.get("left") or []) + gone,
detail=str(event.get("detail") or "")[:maintenance.DETAIL_MAX] or None,
finished_at=now.isoformat())
return True
def check_deadline(run, now: datetime) -> bool:
rb = run.rebalance or {}
if rb.get("state") != "handed" or now - datetime.fromisoformat(rb["at"]) <= DEADLINE:
return False
_set(run, state="timed_out", finished_at=now.isoformat(),
detail=f"the manager didn't finish within {int(DEADLINE.total_seconds() // 60)} min")
return True
def outcome(run) -> tuple[str, str | None]:
"""How a run whose hosts are all done ends: ("done" | "failed", detail)."""
rb = run.rebalance or {}
state = rb.get("state")
left = rb.get("left") or []
tail = f"; {len(left)} left where they are" if left else ""
if state == "done":
n = len(rb.get("moved") or [])
return "done", f"spread {n} swarm service{'s' if n != 1 else ''} back out{tail}"
if state in ("failed", "timed_out"):
return "failed", f"spreading swarm services back out: {rb.get('detail') or state}"
if state == "nothing" and rb.get("detail"):
return "done", f"swarm services not spread back out: {rb['detail']}"
if state == "nothing" and left:
return "done", f"no swarm service could be spread back out{tail}"
return "done", None
def label(run) -> tuple[str, bool, bool] | None:
"""(text, bad, busy) for the run's swarm row on the page, or None when
the run has no rebalance."""
rb = run.rebalance or {}
state = rb.get("state")
if state is None:
return None
if state == "handed":
n = len(rb.get("planned") or [])
return f"Spreading {n} service{'s' if n != 1 else ''} back out", False, True
text = outcome(run)[1] or "Nothing to spread back out"
return text[:1].upper() + text[1:], state in ("failed", "timed_out"), False
# ── applied to the database ──────────────────────────────────────────────────
async def _freshest(session, model, key: str) -> list:
"""The newest row per `key` (several managers may report the swarm)."""
from sqlalchemy import select
rows: dict = {}
for r in (await session.execute(select(model))).scalars():
k = getattr(r, key)
if k not in rows or r.updated_at > rows[k].updated_at:
rows[k] = r
return list(rows.values())
async def _swarm_view(session, now: datetime) -> tuple[list, dict, dict] | None:
"""(services, nodes, images) as choose() takes them, from what the Docker
plugin last stored; None without it."""
from sqlalchemy import select
from steward.models.hosts import Host
models = maintenance.docker_models()
if models is None:
return None
services = [{"name": s.service_name, "mode": s.mode, "desired": s.desired,
"running": s.running, "image": pinned(s.image, s.image_digest),
"placement": json.loads(s.placement_json or "[]")}
for s in await _freshest(session, models.DockerSwarmService, "service_name")]
nodes = {n.node_id: n.hostname or n.node_id
for n in await _freshest(session, models.DockerSwarmNode, "node_id")
if n.status == "ready" and n.availability == "active"}
hosts = dict((await session.execute(select(Host.name, Host.id))).all())
by_name = {name.lower(): hid for name, hid in hosts.items()}
images = {}
for node_id, name in nodes.items():
host_id = by_name.get(name.lower())
disk = await session.get(models.DockerDiskUsage, host_id) if host_id else None
if disk is not None and now - disk.updated_at <= DIGESTS_FRESH:
got = held(json.loads(disk.digests_json or "[]"))
if got:
images[node_id] = got
return services, nodes, images
async def _manager(session, jobs: list) -> tuple[str | None, str | None]:
"""(host id, None) of a swarm manager whose helper can rebalance, the one
that drained the run's nodes first; (None, why) when there is none."""
from sqlalchemy import select
from .models import HostAgentRegistration
_, managers, _ = await maintenance._swarm(session)
regs = {r.host_id: r for r in (await session.execute(select(HostAgentRegistration).where(
HostAgentRegistration.host_id.in_(list(managers)),
HostAgentRegistration.maint_helper.is_(True)))).scalars()} if managers else {}
drained_by = [j.drain_host_id for j in jobs if j.drain_host_id]
for host_id in drained_by + sorted(regs):
reg = regs.get(host_id)
if reg is not None and can_rebalance(reg.agent_version):
return host_id, None
if regs:
return None, "no swarm manager's agent can do it yet: run Update agent on a manager"
return None, "no swarm manager has the maintenance helper"
async def begin(session, run, plan, now: datetime) -> bool:
"""The run's last host is back in service: hand a manager the services to
move, or record why there are none. True while a rebalance is handed out."""
from sqlalchemy import select
from .models import HostMaintenanceJob
if not plan.spread or run.rebalance is not None:
return False
jobs = (await session.execute(select(HostMaintenanceJob).where(
HostMaintenanceJob.run_id == run.id))).scalars().all()
if not any(maintenance.drains(j) for j in jobs):
return False # nothing was drained: nothing to put back
view = await _swarm_view(session, now)
if view is None:
return False
services, nodes, images = view
moves, left = choose(services, nodes, images, keep_list(plan.keep_in_place))
if not moves:
run.rebalance = {"state": "nothing", "left": left, "at": now.isoformat()}
return False
host_id, why = await _manager(session, jobs)
if host_id is None:
run.rebalance = {"state": "nothing", "left": left, "detail": why, "at": now.isoformat()}
return False
run.rebalance = {"state": "handed", "step_id": step_id(run), "host_id": host_id,
"at": now.isoformat(), "planned": moves, "left": left, "seen": False,
"window_id": await _window(session, plan, now)}
return True
async def _window(session, plan, now: datetime) -> str | None:
"""A window over the Docker figures while services restart, so their
restarts raise no alert; ended when the rebalance does."""
from steward.models.alerts import MaintenanceWindow
if plan.created_by is None:
return None
w = MaintenanceWindow(name=WINDOW_NAME, start_at=now, end_at=now + DEADLINE,
scope_source="docker", scope_resource=None, created_by=plan.created_by)
session.add(w)
await session.flush()
return w.id
async def finish(session, run, now: datetime) -> None:
"""The rebalance ended: its window ends, and it goes on the timeline."""
from steward.models.alerts import MaintenanceWindow
from . import timeline
rb = run.rebalance or {}
if rb.get("window_id"):
w = await session.get(MaintenanceWindow, rb["window_id"])
if w is not None:
w.end_at = max(w.start_at, now)
if rb.get("state") in ("done", "failed", "timed_out"):
await timeline.write(session, f"maintenance run {run.id}", timeline.run_entry(session, run))
async def running_runs(session) -> list:
from sqlalchemy import select
from .models import HostMaintenanceRun
return (await session.execute(select(HostMaintenanceRun).where(
HostMaintenanceRun.state == "running"))).scalars().all()
async def handoffs(session, host_id: str) -> list[dict]:
"""The rebalance steps this host's agent should carry out now."""
out = []
for run in await running_runs(session):
if (run.rebalance or {}).get("host_id") == host_id:
step = handoff(run)
if step is not None:
out.append(step)
return out
async def apply_event(session, event: dict, now: datetime) -> bool:
"""Apply a `maintenance` event to the running run whose rebalance it is."""
for run in await running_runs(session):
if apply_result(run, event, now):
if (run.rebalance or {}).get("state") != "handed":
await finish(session, run, now)
return True
return False
+556 -16
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import hashlib
import re
import time
from datetime import datetime, timezone
from typing import Any
@@ -16,6 +17,7 @@ from steward.models.users import UserRole
from sqlalchemy import select, or_, and_
from datetime import timedelta
from steward.core.audit import audit_request
from steward.core.formatting import worst_reading
from steward.core.health import ingest as ingest_tally
from steward.core.metric_series import points as metric_points
@@ -24,6 +26,8 @@ from steward.core.time_range import parse_range, RANGE_OPTIONS
from steward.hosts import service as host_svc
from steward.models.hosts import Host
from steward.models.metrics import PluginMetric
from .downtime import recent as recent_downtime, record_events
from . import drives as drive_health, maintenance
from .models import HostAgentRegistration
from .scheduler import arrivals
# Query helpers live in a model-free module so integration tests can import them
@@ -32,6 +36,7 @@ from .metrics_query import (
SOURCE_MODULE,
_history_for_host,
_latest_metrics_for_host,
has_nfs,
)
host_agent_bp = Blueprint("host_agent", __name__, template_folder="templates")
@@ -89,6 +94,139 @@ def pick_host_address(current: str | None, reported: str | None) -> str | None:
return None
# A board's Super-I/O inputs (Nuvoton/ITE: SYSTIN, CPUTIN, AUXTIN0-4, PCH_*)
# are thermistor pins that are often left unwired, and a floating one reads a
# junk constant. Wired, they read the board, which runs far below this.
_BOARD_INPUT = re.compile(r"^(SYSTIN|CPUTIN|AUXTIN\d*|PCH_)", re.IGNORECASE)
BOARD_INPUT_CEILING_C = 100
def counts_as_hottest(label: str, celsius: float) -> bool:
"""Whether a sensor's reading may set the host's temperature (#5759).
VDNT-Util01's SYSTIN read 114 °C and AUXTIN0-4 105-112, flat at every
load, while its CPU package moved 49-87 and CPUTIN read 49: unwired
inputs. A board input at or past 100 °C is one of those; below it, it
counts. No running host's sensor reads 0 or below, and chips shut down
before 125. Each sensor's own row is kept either way."""
if not 0 < celsius < 125:
return False
return not (_BOARD_INPUT.match(label) and celsius >= BOARD_INPUT_CEILING_C)
def is_thermometer(host_name: str, label: str, celsius: float) -> bool:
"""Whether a sensor's reading may count as the host's temperature: not one
judged unwired from how it moves against the CPU (sensors.py, #5783),
and a reading counts_as_hottest allows."""
from . import sensors
return label not in sensors.unwired(host_name) and counts_as_hottest(label, celsius)
def hottest_sensor(host_name: str, temps: dict[str, float]) -> list[str]:
"""Which sensor sets a host's temperature, as an alert on it says:
["CPU · Package id 0 at 86°C"], or [] with no thermometer among `temps`.
The chip leads the label when the host's agent has said which it is."""
from steward.core.formatting import metric_value
real = {label: c for label, c in temps.items() if is_thermometer(host_name, label, c)}
if not real:
return []
from .sensors import sensor_name
label = max(real, key=real.get)
return [f"{sensor_name(host_name, label)} at {metric_value('temp_c', real[label])}"]
async def name_hottest_sensor(session, resource: str) -> list[str]:
"""The sensor behind temp_c_max on host `resource` right now."""
from steward.models.metrics import PluginMetricLatest
prefix = f"{resource}:temp:"
rows = (await session.execute(select(PluginMetricLatest.resource_name, PluginMetricLatest.value).where(
PluginMetricLatest.source_module == "host_agent", PluginMetricLatest.metric_name == "temp_c",
PluginMetricLatest.resource_name.startswith(prefix, autoescape=True)))).all()
return hottest_sensor(resource, {name[len(prefix):]: value for name, value in rows})
def split_temps(host_name: str, temps: dict) -> tuple[dict, dict]:
"""(real, unwired): a host's sensors, hottest first, apart from the ones
is_thermometer leaves out. Unwired ones map to (reading, why); they read
a constant or run against the CPU, so the page lists them apart instead
of colouring them as the host's heat."""
from . import sensors
judged = sensors.unwired(host_name)
def by_heat(d: dict, value=lambda v: v) -> dict:
return dict(sorted(d.items(), key=lambda kv: -(value(kv[1]) if value(kv[1]) is not None else -999)))
real = {k: c for k, c in temps.items() if c is None or is_thermometer(host_name, k, c)}
unwired = {k: (c, judged.get(k, "reads a value no wired sensor gives"))
for k, c in temps.items() if k not in real}
return by_heat(real), by_heat(unwired, lambda v: v[0])
def _ts_or(value, fallback: datetime) -> datetime:
"""An agent-sent ISO time, or `fallback` when it isn't one."""
try:
got = _parse_ts(value)
except (AttributeError, TypeError, ValueError):
return fallback
return got if got.tzinfo else got.replace(tzinfo=timezone.utc)
def reboot_required(sample: dict):
"""Whether the sample says its host needs a reboot: from the reboot-required
marker (apt hosts) or the hourly update check (dnf's needs-restarting);
None when it can't say."""
reboot = sample.get("reboot") if isinstance(sample.get("reboot"), dict) else {}
if isinstance(reboot.get("required"), bool):
return reboot["required"]
upd = sample.get("updates") if isinstance(sample.get("updates"), dict) else {}
return upd["reboot_required"] if isinstance(upd.get("reboot_required"), bool) else None
def apply_updates(reg, sample: dict, now: datetime) -> bool:
"""Keep the registration's view of the host's updates and reboot current
from its newest sample (#5765); True when anything changed."""
before = (reg.updates_manager, reg.updates_pending, reg.updates_security,
reg.updates_checked_at, reg.updates_error, reg.reboot_required,
reg.reboot_required_since, reg.reboot_packages)
upd = sample.get("updates") if isinstance(sample.get("updates"), dict) else None
if upd:
reg.updates_checked_at = _ts_or(upd.get("checked_at"), now)
if upd.get("manager") in ("apt", "dnf"):
reg.updates_manager = upd["manager"]
if upd.get("error"):
reg.updates_error = str(upd["error"])[:200]
elif isinstance(upd.get("pending"), int):
reg.updates_pending = upd["pending"]
reg.updates_security = upd["security"] if isinstance(upd.get("security"), int) else None
reg.updates_error = None
required = reboot_required(sample)
if required is not None:
reg.reboot_required = required
if not required:
reg.reboot_required_since, reg.reboot_packages = None, None
else:
reboot = sample.get("reboot") if isinstance(sample.get("reboot"), dict) else {}
if reboot.get("since") is not None:
reg.reboot_required_since = _ts_or(reboot["since"], now)
elif reg.reboot_required_since is None:
reg.reboot_required_since = now
packages = reboot.get("packages")
if isinstance(packages, list):
reg.reboot_packages = ", ".join(str(p) for p in packages[:20])[:2000] or None
return before != (reg.updates_manager, reg.updates_pending, reg.updates_security,
reg.updates_checked_at, reg.updates_error, reg.reboot_required,
reg.reboot_required_since, reg.reboot_packages)
def apply_drives(reg, sample: dict, now: datetime) -> bool:
"""Keep the registration's copy of the host's newest drive report (#5789),
which the host page lists: model, state and why, beside the figures."""
report = sample.get("drives")
if not isinstance(report, dict) or report == reg.drive_health:
return False
reg.drive_health, reg.drive_health_at = report, _ts_or(report.get("checked_at"), now)
return True
def _expand_sample_to_metrics(
sample: dict, host_name: str, recorded_at: datetime
) -> list[PluginMetric]:
@@ -127,8 +265,36 @@ def _expand_sample_to_metrics(
if sample.get("uptime_secs") is not None:
row("uptime_secs", host_name, sample["uptime_secs"])
# What the host has waiting (#5765): the agent's hourly answer rides
# every sample, so a rule on it reads like any other host metric.
upd = sample.get("updates") if isinstance(sample.get("updates"), dict) else {}
if isinstance(upd.get("pending"), int):
row("updates_pending", host_name, upd["pending"])
if isinstance(upd.get("security"), int):
row("updates_security", host_name, upd["security"])
required = reboot_required(sample)
if required is not None:
row("reboot_required", host_name, 1 if required else 0)
# The agent's own footprint (#5764), so a runaway agent shows before it
# goes silent.
me = sample.get("agent") or {}
if me.get("rss_bytes") is not None:
row("agent_rss_bytes", host_name, me["rss_bytes"])
if me.get("cpu_pct") is not None:
row("agent_cpu_pct", host_name, me["cpu_pct"])
# The fullest disk is the fullest by space or by inodes (#5745): out of
# inodes, every file create fails with space to spare, so the disk rules
# and the overview, which read disk_used_pct_worst, see either.
worst_pct = 0.0
stalled = 0
for disk in sample.get("storage") or []:
# A mount that didn't answer in time has no figures (#5760); how many
# did is its own metric, so a rule can say "an NFS share stopped".
if disk.get("stalled"):
stalled += 1
continue
total = disk.get("total_bytes", 0)
used = disk.get("used_bytes", 0)
pct = 100.0 * used / total if total else 0.0
@@ -136,10 +302,38 @@ def _expand_sample_to_metrics(
row("disk_used_pct", mount_res, pct)
row("disk_used_bytes", mount_res, used)
row("disk_total_bytes", mount_res, total)
if disk.get("inodes_total"):
inode_pct = 100.0 * disk.get("inodes_used", 0) / disk["inodes_total"]
row("inodes_used_pct", mount_res, inode_pct)
pct = max(pct, inode_pct)
if pct > worst_pct:
worst_pct = pct
if sample.get("storage"):
row("disk_used_pct_worst", host_name, worst_pct)
# Every mount stalled says nothing about how full they are.
if stalled < len(sample["storage"]):
row("disk_used_pct_worst", host_name, worst_pct)
row("mounts_stalled", host_name, stalled)
# NFS delay per mount (agent ≥ 1.22.0, #5996): what its requests took
# since the last sample, and (agent ≥ 1.25.0, #6115) the bytes a second
# it sent and received. The host's slowest mount is its own metric, for
# a rule; recorded only when some mount finished a request, since a mount
# with none, idle or stuck, has no delay to give (mounts_stalled covers
# stuck).
slowest = None
for nfs in sample.get("nfs") or []:
mount_res = f"{host_name}:{nfs['mount']}"
for key, metric in (("ops_per_sec", "nfs_ops_per_sec"), ("rtt_ms", "nfs_rtt_ms"),
("exe_ms", "nfs_exe_ms"), ("read_ms", "nfs_read_ms"),
("write_ms", "nfs_write_ms"), ("retrans", "nfs_retrans"),
("timeouts", "nfs_timeouts"), ("tx_bps", "nfs_tx_bps"),
("rx_bps", "nfs_rx_bps")):
if isinstance(nfs.get(key), (int, float)):
row(metric, mount_res, nfs[key])
if isinstance(nfs.get("exe_ms"), (int, float)):
slowest = max(slowest or 0.0, nfs["exe_ms"])
if slowest is not None:
row("nfs_exe_max_ms", host_name, slowest)
# Per-core CPU. Sub-resources carry a ':' so the fleet widget filters them out.
for i, core_pct in enumerate(sample.get("cpu_cores") or []):
@@ -178,18 +372,26 @@ def _expand_sample_to_metrics(
row("disk_read_bps", host_name, dr_total)
row("disk_write_bps", host_name, dw_total)
# Temperatures — per sensor plus the host max for at-a-glance.
# Temperatures — per sensor plus the host max for at-a-glance. The max
# counts only readings that can be real (#5759), from sensors that behave
# like thermometers (#5783).
max_temp: float | None = None
from .sensors import note_chips
note_chips(host_name, sample.get("temps"))
for t in sample.get("temps") or []:
c = t.get("celsius")
if c is None:
continue
row("temp_c", f"{host_name}:temp:{t['label']}", c)
if max_temp is None or c > max_temp:
if is_thermometer(host_name, str(t["label"]), c) and (max_temp is None or c > max_temp):
max_temp = c
if max_temp is not None:
row("temp_c_max", host_name, max_temp)
# Each drive's health (#5789): the helper's hourly read rides every sample.
for metric, resource, value in drive_health.metric_rows(sample.get("drives"), host_name):
row(metric, resource, value)
# Pressure stall information (PSI) — host-level gauges (mem/cpu/io some|full).
for k, v in (sample.get("psi") or {}).items():
if isinstance(v, (int, float)):
@@ -225,11 +427,15 @@ async def ingest():
return _error(400, "malformed_payload", "missing 'samples'")
samples = payload.get("samples") or []
if not isinstance(samples, list) or not samples:
# What happened to the host (agent ≥ 1.14.0, #5756). A stopping agent's last
# post may carry its events alone, with no sample left to send.
events = payload.get("events") if isinstance(payload.get("events"), list) else []
if not isinstance(samples, list) or not (samples or events):
return _error(400, "malformed_payload", "'samples' must be a non-empty list")
token_hash = _hash_token(raw)
phases = _Phases()
maint_steps: list = []
samples, dropped = newest_samples(samples, MAX_SAMPLES_PER_INGEST)
if dropped:
current_app.logger.warning(
@@ -262,6 +468,7 @@ async def ingest():
latest_disk_ts: datetime | None = None
# Whether Docker answered, from the newest sample that says (agent ≥ 1.9.0).
docker_status: dict | None = None
newest_sample: dict | None = None
for sample in samples:
try:
recorded_at = _parse_ts(sample["ts"])
@@ -296,9 +503,9 @@ async def ingest():
latest_disk, latest_disk_ts = disk, recorded_at
accepted += 1
if latest_ts is None or recorded_at > latest_ts:
latest_ts = recorded_at
latest_ts, newest_sample = recorded_at, sample
if accepted == 0:
if accepted == 0 and not events:
await session.rollback()
return _error(400, "malformed_payload", "no valid samples")
# How far this host's clock is from Steward's, either way, so an
@@ -339,6 +546,26 @@ async def ingest():
host.name)
phases.mark("docker")
# Before last_seen_at moves: a boot after it that nobody announced
# was a restart without a shutdown.
if events:
await record_events(session, host.id, events, reg.last_seen_at)
# Maintenance jobs (#5767): results and boots move a job on, then
# whatever step is due for this host's agent goes back in the answer.
# Each in its own savepoint: a job going wrong never costs the samples.
try:
async with session.begin_nested():
if events:
await maintenance.apply_events(session, host.id, events, received_at)
if reg.maint_helper:
maint_steps = await maintenance.handoffs(
session, host.id, host.name, received_at,
payload.get("agent_version") or reg.agent_version)
except Exception:
maint_steps = []
current_app.logger.exception(
"host_agent ingest: maintenance jobs failed for host=%s", host.name)
md = payload.get("metadata") or {}
changed = False
if latest_ts and (reg.last_seen_at is None or latest_ts > reg.last_seen_at):
@@ -352,10 +579,18 @@ async def ingest():
if field in md and getattr(reg, field) != md[field]:
setattr(reg, field, md[field])
changed = True
# Whether the maintenance helper is installed (agent ≥ 1.17.0, #5766).
if isinstance(md.get("maint_helper"), bool) and reg.maint_helper != md["maint_helper"]:
reg.maint_helper = md["maint_helper"]
changed = True
host_ip = md.get("host_ip")
if host_ip and reg.host_ip != host_ip:
reg.host_ip = host_ip
changed = True
if newest_sample is not None and apply_updates(reg, newest_sample, received_at):
changed = True
if newest_sample is not None and apply_drives(reg, newest_sample, received_at):
changed = True
# Mirror the reported IP into Host.address only when it's blank.
new_address = pick_host_address(host.address, host_ip)
if new_address is not None:
@@ -389,7 +624,10 @@ async def ingest():
phases.report(host.name, accepted, len(metric_rows),
sum(len(c) for _, c in docker_snapshots),
sum(len(lines) for _, lines in docker_log_batches))
return jsonify({"ok": True, "samples_accepted": accepted, "samples_dropped": dropped}), 200
answer = {"ok": True, "samples_accepted": accepted, "samples_dropped": dropped}
if maint_steps:
answer["maintenance"] = maint_steps
return jsonify(answer), 200
# The agent gives up on a post after 10 s (agent.py); an ingest past this
@@ -506,6 +744,30 @@ async def agent_source():
return Response(body, mimetype="text/x-python")
# What a host installs besides agent.py: the units and the maintenance helper
# (#5766), served from one copy so install.sh and every playbook agree.
AGENT_FILES_DIR = Path(__file__).parent / "files"
AGENT_FILES = {
"maint.py": "text/x-python",
"install-deps.sh": "text/x-shellscript",
"steward-agent.service": "text/plain",
"steward-maint.service": "text/plain",
"steward-maint.path": "text/plain",
}
@host_agent_bp.get("/files/<name>")
@public_route("fetched by the installer and the agent playbooks; holds no secrets")
async def agent_file(name: str):
if name not in AGENT_FILES:
return _error(404, "not_found")
try:
body = (AGENT_FILES_DIR / name).read_text(encoding="utf-8")
except OSError:
return _error(500, "agent_missing")
return Response(body, mimetype=AGENT_FILES[name])
_FLEET_ORDER = {"stale": 0, "pending": 1, "up": 2}
@@ -645,9 +907,15 @@ def _split_host_metrics(host_name: str, latest: dict) -> tuple:
disks_io[suffix[len("diskio:"):]] = metrics
elif suffix.startswith("temp:"):
temps[suffix[len("temp:"):]] = metrics.get("temp_c")
elif suffix.startswith("drive:"):
continue # listed from the drive report (#5789)
else:
mounts[suffix] = metrics
cores.sort(key=lambda c: c[0])
# Fullest first, by the worse of space and inodes (#5810): a full
# /var/lib/docker must not sort after /boot.
mounts = dict(sorted(mounts.items(), key=lambda kv: (
-max(kv[1].get("disk_used_pct") or 0, kv[1].get("inodes_used_pct") or 0), kv[0])))
return hostlvl, cores, nets, disks_io, temps, mounts
@@ -663,9 +931,10 @@ async def host_detail(host_id: str):
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return _error(404, "not_found")
nfs = await has_nfs(session, host.name)
return await render_template(
"host_detail.html",
host=host, current_range=range_key, range_options=RANGE_OPTIONS,
host=host, current_range=range_key, range_options=RANGE_OPTIONS, has_nfs=nfs,
poll_seconds=_DETAIL_POLL_SECONDS, chart_poll_seconds=_CHART_POLL_SECONDS,
)
@@ -684,12 +953,16 @@ async def host_detail_metrics(host_id: str):
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
latest = await _latest_metrics_for_host(session, host.name)
hostlvl, cores, nets, disks_io, temps, mounts = _split_host_metrics(host.name, latest)
temps, unwired = split_temps(host.name, temps)
drive_report = reg.drive_health if reg else None
ls = reg.last_seen_at if reg else None
stale = ls is None or (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
return await render_template(
"_host_metrics.html",
host=host, reg=reg, stale=stale, hostlvl=hostlvl,
cores=cores, nets=nets, disks_io=disks_io, temps=temps, mounts=mounts,
cores=cores, nets=nets, disks_io=disks_io, temps=temps, unwired_temps=unwired, mounts=mounts,
drives=drive_health.drives_of(drive_report), drives_error=(drive_report or {}).get("error"),
drives_at=reg.drive_health_at if reg else None,
)
@@ -709,6 +982,23 @@ async def host_detail_charts(host_id: str):
return await render_template("_host_charts.html", series=series, range_key=range_key)
@host_agent_bp.get("/<host_id>/senders")
@require_role(UserRole.viewer)
async def host_detail_senders(host_id: str):
"""Who sent the host's traffic over the range (#6115), lazy-loaded below
the charts and refreshed with them."""
from .senders import senders
since, range_key = parse_range(request.args.get("range"))
async with current_app.db_sessionmaker() as session:
host = (await session.execute(
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return "", 404
raw_days = int(await get_setting(session, "metrics.retention.raw_days") or 7)
got = await senders(session, host.name, since, raw_days=raw_days)
return await render_template("_host_senders.html", got=got, range_key=range_key)
def _new_token_pair() -> tuple[str, str]:
raw = secrets.token_urlsafe(32)
return raw, _hash_token(raw)
@@ -717,7 +1007,7 @@ def _new_token_pair() -> tuple[str, str]:
@host_agent_bp.get("/vitals/<host_id>")
@require_role(UserRole.viewer)
async def host_vitals(host_id: str):
"""Compact live vitals strip (CPU/MEM/DISK/LOAD + pressure) for the Hosts hub.
"""Compact live vitals strip (CPU/MEM/DISK/LOAD/temperature + pressure) for the Hosts hub.
Self-contained fragment, polled by hosts/detail.html. Renders nothing until
the agent reports — the Agent panel below then carries provisioning.
@@ -732,15 +1022,16 @@ async def host_vitals(host_id: str):
latest = await _latest_metrics_for_host(db, host.name) if reg else {}
norms = await host_svc.host_norms(db, host.name) if reg else {}
# The last 6 h for the at-a-glance sparklines (cpu/mem/load host-level,
# The last 6 h for the at-a-glance sparklines (cpu/mem/load/temp host-level,
# disk from the root mount), averaged into ~120 points in SQL, so the
# line spans the whole window and only what is drawn is fetched.
series_raw: dict[str, list[float]] = {"cpu": [], "mem": [], "disk": [], "load": []}
series_raw: dict[str, list[float]] = {"cpu": [], "mem": [], "disk": [], "load": [], "temp": []}
if reg:
since = datetime.now(timezone.utc) - timedelta(hours=6)
for key, resource, metric in (("cpu", host.name, "cpu_pct"),
("mem", host.name, "mem_used_pct"),
("load", host.name, "load_1m"),
("temp", host.name, "temp_c_max"),
("disk", host.name + ":/", "disk_used_pct")):
series_raw[key] = [v for _t, v in await metric_points(
db, SOURCE_MODULE, resource, metric, since, target=120)]
@@ -750,6 +1041,8 @@ async def host_vitals(host_id: str):
# At-a-glance disk = the root filesystem (what people actually care about),
# not the "worst" mount which is opaque. Worst stays on the full-metrics page.
disk_root = (latest.get(host.name + ":/", {}) or {}).get("disk_used_pct")
# Out of inodes, "/" is full with space to spare (#5745): named when worse.
inodes_root = (latest.get(host.name + ":/", {}) or {}).get("inodes_used_pct")
sparks = {k: sparkline_svg(v, height=24, fill_width=True) for k, v in series_raw.items()}
# Normalize load by core count so it's comparable across hardware (load/core
# as %). Cores derived from the per-core CPU sub-resources already collected.
@@ -765,14 +1058,21 @@ async def host_vitals(host_id: str):
reporting = bool(reg) and ls is not None
stale = reporting and (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
cpu, mem = hostlvl.get("cpu_pct"), hostlvl.get("mem_used_pct")
# The hottest sensor that counts (temp_c_max leaves out unwired inputs),
# named so the tile says what it is reading.
temp = hostlvl.get("temp_c_max")
prefix = host.name + ":temp:"
hottest = hottest_sensor(host.name, {r[len(prefix):]: m["temp_c"] for r, m in latest.items()
if r.startswith(prefix) and m.get("temp_c") is not None})
thresholds = current_app.config.get("THRESHOLDS", {})
worst = worst_reading((("CPU", cpu, "cpu"), ("Memory", mem, "mem"), ("Disk /", disk_root, "disk"),
("Load per core", load_per_core, "load")),
("Inodes on /", inodes_root, "disk"), ("Load per core", load_per_core, "load"),
("Temperature", temp, "temp")),
thresholds, host_svc.host_normals(norms, thresholds))
# Each tile's grade; CPU, memory and load within the host's own normal
# for the hour read healthy, and say what is usual (#5742).
grades = host_svc.host_grades({"cpu": cpu, "mem": mem, "disk": disk_root, "load": load_per_core},
norms, thresholds)
grades = host_svc.host_grades({"cpu": cpu, "mem": mem, "disk": host_svc.fuller(disk_root, inodes_root),
"load": load_per_core}, norms, thresholds)
usual = [f"{label} {value:.0f}% is {grades[kind][1]}"
for label, value, kind in (("CPU", cpu, "cpu"), ("Memory", mem, "mem"),
("load per core", load_per_core, "load"))
@@ -780,6 +1080,7 @@ async def host_vitals(host_id: str):
return await render_template(
"_host_vitals.html",
reg=reg, cpu=cpu, mem=mem, disk_root=disk_root, load_per_core=load_per_core,
inodes_root=inodes_root, temp=temp, hottest=hottest[0].rsplit(" at ", 1)[0] if hottest else None,
sparks=sparks, psi=psi, reporting=reporting, stale=stale, worst=worst,
grades=grades, usual=usual,
clock=clock_words(reg.clock_offset_s if reg else None, current_app.config.get("THRESHOLDS", {})),
@@ -808,6 +1109,15 @@ async def host_panel(host_id: str):
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
target = (await db.execute(select(AnsibleTarget).where(
AnsibleTarget.host_id == host_id))).scalar_one_or_none()
downtime = await recent_downtime(db, host_id, datetime.now(timezone.utc)) if reg else []
# Maintenance (#5767): the running or latest job, and whether one can start.
maint_job = await maintenance.latest_job(db, host_id, datetime.now(timezone.utc)) if reg else None
maint_blocker = None
if reg is not None:
try:
await maintenance.plan_job(db, host, reg)
except maintenance.CannotStart as exc:
maint_blocker = str(exc)
ls = reg.last_seen_at if reg else None
# "Deployed" is gated on the agent actually checking in (last_seen_at), NOT on
@@ -819,7 +1129,10 @@ async def host_panel(host_id: str):
ansible_cfg = current_app.config.get("ANSIBLE", {})
return await render_template(
"panel.html",
host=host, reg=reg, reporting=reporting, stale=stale, target=target,
host=host, reg=reg, reporting=reporting, stale=stale, target=target, downtime=downtime,
maint_job=maint_job, maint_blocker=maint_blocker,
maint_summary=maintenance.summary(maint_job) if maint_job else None,
maint_failed=maint_job is not None and maintenance.still_failed(maint_job),
ansible_available=has_capability("ansible.run_playbook"),
managed_key_set=bool((ansible_cfg.get("ssh_public_key") or "").strip()),
)
@@ -904,6 +1217,30 @@ async def rotate_token(host_id: str):
return redirect(url_for("host_agent.fleet") + f"?new_token={raw}&host_id={host_id}")
@host_agent_bp.post("/fleet/<host_id>/maintain")
@require_role(UserRole.admin)
async def start_maintenance(host_id: str):
"""Update this host now, rebooting it only if the update needs it (#5767).
The job starts with the agent's next post and is followed on the panel."""
form = await request.form
policy = "never" if form.get("reboot_policy") == "never" else "if_required"
user_id = session.get("user_id")
async with current_app.db_sessionmaker() as db:
host = await db.get(Host, host_id)
if host is None:
return _error(404, "not_found")
reg = (await db.execute(select(HostAgentRegistration).where(
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
try:
job = await maintenance.create_job(db, host, reg, user_id, policy)
except maintenance.CannotStart as exc:
return _error(409, "cannot_start", f"{host.name} can't be updated now: {exc}")
await db.commit()
await audit_request("host_agent.maintenance", "host", host_id,
{"job_id": job.id, "reboot_policy": policy})
return redirect(f"/hosts/{host_id}")
@host_agent_bp.post("/fleet/<host_id>/delete")
@require_role(UserRole.admin)
async def delete_registration(host_id: str):
@@ -1133,3 +1470,206 @@ 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, then the spreading of its swarm services back out."""
import json as _json
from . import rebalance
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 = maintenance.still_failed(job)
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})
swarm = rebalance.label(run)
if swarm is not None:
rb = run.rebalance or {}
said = [f"moved: {', '.join(rb['moved'])}"] if rb.get("moved") else []
said += [f"{x['service']}: {x['reason']}" for x in rb.get("left") or []]
rows.append({"host_id": None, "name": "Swarm services", "text": swarm[0], "bad": swarm[1],
"busy": swarm[2], "detail": "; ".join(said) or 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()
spread, keep = _spread_form(form)
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,
spread=spread, keep_in_place=keep,
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"))
def _spread_form(form) -> tuple[bool, str]:
"""(spread, keep_in_place) from a plan form: the box, and the services
named to stay where they are, tidied to one comma-separated list."""
from .rebalance import keep_list
return form.get("spread") == "on", ", ".join(keep_list(form.get("keep_in_place")))[:2000]
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>/spread")
@require_role(UserRole.admin)
async def maintenance_plan_spread(plan_id: str):
"""Whether the plan's runs end by spreading swarm services back out, and
which services stay where they are."""
form = await request.form
spread, keep = _spread_form(form)
async def act(db, plan):
plan.spread, plan.keep_in_place = spread, keep
resp = await _plan_action(plan_id, act)
await audit_request("host_agent.maintenance_plan_spread", "maintenance_plan", plan_id,
{"spread": spread, "keep_in_place": keep})
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
+68 -1
View File
@@ -12,6 +12,7 @@ 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__)
@@ -36,6 +37,7 @@ 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.
@@ -52,6 +54,11 @@ def seconds_since_seen(regs: Iterable, host_names: dict[str, str],
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:
@@ -62,6 +69,8 @@ def seconds_since_seen(regs: Iterable, host_names: dict[str, str],
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
@@ -88,9 +97,10 @@ async def record_seconds_since_seen(app) -> None:
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):
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)
@@ -109,3 +119,60 @@ def make_task(app) -> ScheduledTask:
interval_seconds=60,
run_on_startup=False,
)
def make_sensor_task(app) -> ScheduledTask:
"""Re-judges which temperature sensors are thermometers (#5783), hourly
and at startup, from the hourly averages: verdicts live in memory, so a
restart rebuilds them before the first hour is out."""
async def _judge():
from . import sensors
try:
async with app.db_sessionmaker() as session:
found = await sensors.refresh(session, datetime.now(timezone.utc))
for host, labels in found.items():
logger.info("host_agent: %s sensors not counted as temperature: %s",
host, ", ".join(sorted(labels)))
except Exception:
logger.exception("host_agent: judging temperature sensors failed")
return ScheduledTask(
name="host_agent_sensors",
coro_factory=_judge,
interval_seconds=3600,
run_on_startup=True,
)
def make_maintenance_task(app) -> ScheduledTask:
"""Gives up on maintenance jobs past their deadline (#5767), starts due
maintenance plans and moves their runs on one host at a time (#5768), and
records each host's maintenance_failed for an alert rule. A job's steps
move on from its agent's posts; the tick moves on only Steward's own waits
(the 5 minutes after a drain, the containers seen running again, #6098)."""
async def _tick():
from . import cadence, maintenance
now = datetime.now(timezone.utc)
try:
async with app.db_sessionmaker() as session:
async with session.begin():
gave_up = await maintenance.tick(session, now)
said = await cadence.tick(session, now)
batch = MetricBatch()
for name, value in (await cadence.failed_hosts(session, now)).items():
batch.add(source_module="host_agent", resource_name=name,
metric_name="maintenance_failed", value=value, recorded_at=now)
await batch.record(session)
if gave_up:
logger.warning("host_agent: %d maintenance job(s) timed out", gave_up)
for line in said:
logger.info("host_agent maintenance: %s", line)
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,
)
+113
View File
@@ -0,0 +1,113 @@
"""Who sends a host's network traffic (#6115): its containers and NFS mounts,
ranked against the host's own total over a window.
A host's net_tx_bps (net_rx_bps) is what its physical interfaces carried; each
container's comes from Docker ingest (resource "<host>/<service or container>") and each
NFS mount's from the agent (resource "<host>:<mount>"). All three are read in
the same time buckets, so the busiest bucket of the host's total can be put
down to who was sending in it. What no container or mount accounts for is the
host itself (an apt download, a backup run from the host, a swarm ingress
hand-off) or a container's traffic that never left the host.
Imports only the core metric model, like metrics_query, so the docker plugin
being off leaves just the NFS mounts and the host.
"""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from steward.models.metrics import PluginMetric, PluginMetricLatest
SOURCE_MODULE = "host_agent"
DIRECTIONS = ("tx", "rx")
# Buckets a window is cut into: a 1 h window reads per 60 s sample, 24 h per
# 12 minutes. Narrow the window to see a spike sharper.
BUCKETS = 120
# A swarm task's container, "<service>.<slot or node id>.<task id>", is shown
# by its service, so its tasks over the window add up to one sender.
_TASK = re.compile(r"^(?P<svc>.+?)\.(?:\d+|[a-z0-9]{25})\.[a-z0-9]{25}$")
def sender_name(container: str) -> str:
m = _TASK.match(container)
return m["svc"] if m else container
def rank(host: dict, senders: dict, limit: int = 10) -> dict:
"""`host` is {bucket: bytes a second}; `senders` is {(kind, name):
{bucket: bytes a second}}. The host's average, its peak and when, and each
sender's average over the host's buckets (a bucket it is missing from
counts as 0), its own peak and what it sent in the host's peak bucket,
biggest at the host's peak first. `unaccounted_*` is what the host carried
beyond them all, never below 0."""
if not host:
return {"host": None, "senders": [], "more": 0, "unaccounted_avg_bps": None,
"unaccounted_at_peak_bps": None}
n = len(host)
peak_at = max(host, key=lambda b: host[b])
rows = []
for (kind, name), series in senders.items():
top = max(series, key=lambda b: series[b])
rows.append({"kind": kind, "name": name,
"avg_bps": sum(v for b, v in series.items() if b in host) / n,
"peak_bps": series[top], "peak_at": top,
"at_host_peak_bps": series.get(peak_at, 0.0)})
rows.sort(key=lambda r: (r["at_host_peak_bps"], r["avg_bps"]), reverse=True)
host_avg = sum(host.values()) / n
return {
"host": {"avg_bps": host_avg, "peak_bps": host[peak_at], "peak_at": peak_at},
"senders": rows[:limit],
"more": max(0, len(rows) - limit),
"unaccounted_avg_bps": max(0.0, host_avg - sum(r["avg_bps"] for r in rows)),
"unaccounted_at_peak_bps": max(0.0, host[peak_at] - sum(r["at_host_peak_bps"] for r in rows)),
}
async def senders(session, host_name: str, since: datetime, direction: str = "tx",
limit: int = 10, raw_days: int = 7) -> dict:
"""rank() over the raw samples since `since` (no earlier than the raw
window: the hourly rollup keeps no per-bucket sender to rank). Adds the
window actually read and its bucket width."""
if direction not in DIRECTIONS:
raise ValueError(f"direction is one of {', '.join(DIRECTIONS)}")
now = datetime.now(timezone.utc)
since = max(since, now - timedelta(days=raw_days))
width = max(60, int((now - since).total_seconds() / BUCKETS))
bucket = func.date_bin(func.make_interval(0, 0, 0, 0, 0, 0, width), PluginMetric.recorded_at,
func.to_timestamp(0)).label("bucket")
metric = f"net_{direction}_bps"
async def named(source, metric_name, prefix):
# The resources under the host, from the small latest-value table: a
# prefix LIKE on plugin_metrics itself can't use its index (the
# column's collation isn't C) and reads the whole table.
return list((await session.execute(
select(PluginMetricLatest.resource_name).where(
PluginMetricLatest.source_module == source, PluginMetricLatest.metric_name == metric_name,
PluginMetricLatest.resource_name.startswith(prefix, autoescape=True),
PluginMetricLatest.recorded_at >= since))).scalars())
async def series(source, metric_name, resources):
if not resources:
return []
return (await session.execute(
select(PluginMetric.resource_name, bucket, func.avg(PluginMetric.value))
.where(PluginMetric.source_module == source, PluginMetric.resource_name.in_(resources),
PluginMetric.metric_name == metric_name, PluginMetric.recorded_at >= since)
.group_by(PluginMetric.resource_name, bucket))).all()
host: dict = {}
for _, b, v in await series(SOURCE_MODULE, metric, [host_name]):
host[b] = float(v)
out: dict = {}
for res, b, v in await series("docker", metric, await named("docker", metric, host_name + "/")):
key = ("container", sender_name(res[len(host_name) + 1:]))
out.setdefault(key, {})
out[key][b] = out[key].get(b, 0.0) + float(v) # a service's tasks add up
nfs = f"nfs_{direction}_bps"
for res, b, v in await series(SOURCE_MODULE, nfs, await named(SOURCE_MODULE, nfs, host_name + ":/")):
out.setdefault(("nfs", res[len(host_name) + 1:]), {})[b] = float(v)
return {"since": since, "bucket_s": width, "direction": direction, **rank(host, out, limit)}
+218
View File
@@ -0,0 +1,218 @@
"""Which of a host's temperature sensors are thermometers (#5783).
A board's spare thermistor inputs often have nothing wired to them, and the
kernel reports them anyway. No flag says so; their behaviour does. A
thermometer in a host warms as its CPU warms. An open input reads the chip's
own reference, which drifts the other way as the chip warms, or holds still:
VDNT-Util01's SYSTIN ran at r = −0.81 against its CPU package over a week,
while CPUTIN beside it ran at +0.83 (#5782).
So each sensor is judged against the host's CPU over its hourly averages, by
a scheduled task, and the verdicts are kept here for ingest and the pages to
read. Until a host has enough history, or when its CPU never moved enough to
tell, nothing is judged and routes.counts_as_hottest's value rule decides.
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta
from math import sqrt
logger = logging.getLogger(__name__)
# Hours of history judged, the shared hours a verdict needs, and how far the
# CPU must have moved in them for "it didn't follow" to mean anything.
WINDOW = timedelta(days=7)
MIN_HOURS = 72
REFERENCE_SWING_C = 10.0
# Falling as the CPU warms, this strongly, is no thermometer's behaviour.
# A wired board or drive sensor may barely follow the CPU (r near 0), so
# only a clear negative counts, not a weak one.
FALLS_R = -0.5
# Held within this over days while the CPU moved: a constant, not a reading.
FLAT_C = 0.5
# The host's CPU, in order of preference: Intel coretemp's package, AMD
# k10temp's control/die/CCD readings, then the PECI agent.
_REFERENCES = (re.compile(r"^Package id \d+$", re.I), re.compile(r"^(Tctl|Tdie)$", re.I),
re.compile(r"^Tccd\d+$", re.I), re.compile(r"^PECI Agent \d+$", re.I))
# {host name: {sensor label: why it isn't a thermometer}}, from the last refresh.
_unwired: dict[str, dict[str, str]] = {}
# {resource: {hour: average}} over the last WINDOW, and the hour the next
# refresh reads raw samples from. Reading the whole week took over three
# minutes on the live instance, so it is read once at startup, and each later
# refresh reads only the hours since the last one.
_hours: dict[str, dict[datetime, float]] = {}
_read_from: datetime | None = None
def unwired(host_name: str) -> dict[str, str]:
"""The sensors judged not to be thermometers on a host, with why."""
return _unwired.get(host_name, {})
# {host name: {sensor label: hwmon chip}}, as the host's latest sample said.
# A sensor's metric is keyed by its label alone; the chip only names it.
_chips: dict[str, dict[str, str]] = {}
# What each hwmon driver measures, in the words a page uses. A driver not
# listed is shown by its own name.
_CHIP_WORDS = (
(re.compile(r"^(coretemp|k10temp|zenpower|cpu_thermal)$"), "CPU"),
(re.compile(r"^nvme$"), "NVMe"),
(re.compile(r"^drivetemp$"), "Drive"),
(re.compile(r"^(nct\d+|it\d+|w83\w+|f71\w+)$"), "Board"),
(re.compile(r"^pch_\w+$"), "Chipset"),
(re.compile(r"^(amdgpu|radeon|nouveau|i915|xe)$"), "GPU"),
(re.compile(r"^(spd5118|jc42)$"), "Memory"),
(re.compile(r"^iwlwifi"), "Wi-Fi"),
(re.compile(r"^acpitz$"), "ACPI"),
)
def note_chips(host_name: str, temps) -> None:
"""Remember which chip each of a host's sensors is on, from a sample's
temps. An agent that doesn't say leaves what is known as it is."""
seen = {str(t["label"]): str(t["chip"]) for t in temps or [] if t.get("chip")}
if seen:
_chips[host_name] = seen
def chip_word(chip: str) -> str:
"""What a hwmon chip measures ("nvme" → "NVMe"), or its own name."""
for pattern, word in _CHIP_WORDS:
if pattern.match(chip):
return word
return chip
def sensor_name(host_name: str, label: str) -> str:
"""A sensor as a page names it: its chip, then its label, as in
"NVMe · Sensor 2". The label alone when the chip isn't known or the
label already carries it ("nvme_temp1")."""
chip = _chips.get(host_name, {}).get(label)
if not chip or label.lower().startswith(chip.lower()):
return label
return f"{chip_word(chip)} · {label}"
def reference_label(labels) -> str | None:
"""The sensor that reads the host's CPU, or None when it has none."""
labels = sorted(labels)
for pattern in _REFERENCES:
found = [label for label in labels if pattern.match(label)]
if found:
return found[0]
return None
def pearson(xs: list[float], ys: list[float]) -> float | None:
"""Pearson's r, or None when either series doesn't move."""
n = len(xs)
mx, my = sum(xs) / n, sum(ys) / n
sxx = sum((x - mx) ** 2 for x in xs)
syy = sum((y - my) ** 2 for y in ys)
if sxx == 0 or syy == 0:
return None
return sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / sqrt(sxx * syy)
def judge(reference: dict[datetime, float], sensor: dict[datetime, float]) -> str | None:
"""Why `sensor` isn't a thermometer, judged against the host's CPU over
the hours both have; None when it behaves like one, or when there isn't
enough to tell (too few hours, or a CPU that never moved)."""
hours = sorted(set(reference) & set(sensor))
if len(hours) < MIN_HOURS:
return None
cpu = [reference[h] for h in hours]
got = [sensor[h] for h in hours]
if max(cpu) - min(cpu) < REFERENCE_SWING_C:
return None
if max(got) - min(got) < FLAT_C:
return f"holds at {sum(got) / len(got):.0f} °C while the CPU moved {min(cpu):.0f}–{max(cpu):.0f} °C"
r = pearson(cpu, got)
if r is not None and r <= FALLS_R:
return f"falls as the CPU warms (r {r:.2f} over {len(hours)} h)"
return None
def judge_hosts(rows) -> dict[str, dict[str, str]]:
"""{host: {label: why}} from hourly rows of (resource, bucket, value_avg),
where resource is "<host>:temp:<label>"."""
series: dict[str, dict[str, dict[datetime, float]]] = {}
for resource, bucket, value in rows:
host, sep, label = resource.partition(":temp:")
if sep and value is not None:
series.setdefault(host, {}).setdefault(label, {})[bucket] = value
out: dict[str, dict[str, str]] = {}
for host, sensors in series.items():
ref = reference_label(sensors)
if ref is None:
continue
for label, points in sensors.items():
if label == ref:
continue
why = judge(sensors[ref], points)
if why:
out.setdefault(host, {})[label] = why
return out
def merge_hours(hours: dict[str, dict[datetime, float]], rows, since: datetime) -> None:
"""Fold (resource, hour, average) rows into `hours`, a newer read of an
hour replacing an older one (the hour that was still filling last time),
and drop the hours older than `since`."""
for resource, bucket, value in rows:
if value is not None:
hours.setdefault(resource, {})[bucket] = float(value)
for resource in list(hours):
kept = {h: v for h, v in hours[resource].items() if h >= since}
if kept:
hours[resource] = kept
else:
del hours[resource]
async def refresh(session, now: datetime) -> dict[str, dict[str, str]]:
"""Re-judge every host's sensors from the last WINDOW of hourly averages,
and keep the verdicts for ingest and the pages.
Hourly rollups exist only for hours older than the raw window
(metrics.retention.raw_days, 7 days by default), so the recent hours are
averaged from the raw samples here. Retention deletes the raw rows it
rolls up, so the two never cover the same hour. The sensors are named
from plugin_metric_latest, so each series is read by its index rather
than by a pattern over every host_agent row.
The first refresh reads the whole WINDOW; later ones read raw samples
from the start of the hour the last one ended in, so that hour is
re-read whole."""
global _unwired, _read_from
from sqlalchemy import func, select
from steward.models.metrics import PluginMetric as M
from steward.models.metrics import PluginMetricHourly as H
from steward.models.metrics import PluginMetricLatest as L
since = now - WINDOW
names = (await session.execute(
select(L.resource_name).where(L.source_module == "host_agent", L.metric_name == "temp_c"))).scalars().all()
if not names:
_unwired = {}
return _unwired
read_from = since if _read_from is None or _read_from < since else _read_from
hour = func.date_trunc("hour", M.recorded_at)
rows = list((await session.execute(
select(M.resource_name, hour, func.avg(M.value))
.where(M.source_module == "host_agent", M.metric_name == "temp_c",
M.resource_name.in_(names), M.recorded_at >= read_from)
.group_by(M.resource_name, hour))).all())
if _read_from is None:
rows += (await session.execute(
select(H.resource_name, H.bucket, H.value_avg)
.where(H.source_module == "host_agent", H.metric_name == "temp_c",
H.resource_name.in_(names), H.bucket >= since))).all()
merge_hours(_hours, rows, since)
_read_from = now.replace(minute=0, second=0, microsecond=0)
_unwired = judge_hosts((r, h, v) for r, pts in _hours.items() for h, v in pts.items())
return _unwired
@@ -114,6 +114,52 @@
<span style="color:var(--text-muted);">{{ m.get('disk_used_bytes')|bytes }} / {{ m.get('disk_total_bytes')|bytes }}{% if m.get('disk_used_pct') is not none %} · {{ "%.0f"|format(m.get('disk_used_pct')) }}%{% endif %}</span>
</div>
{{ bar(m.get('disk_used_pct'), 'disk') }}
{% if m.get('nfs_ops_per_sec') is not none %}
{# NFS delay (#5996): what a request took since the agent's last sample. #}
{% set exe = m.get('nfs_exe_ms') %}
<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.2rem;">
NFS {% if m.get('nfs_ops_per_sec') > 0 and exe is not none -%}
<span class="{{ 'tone-crit' if exe >= 1000 else ('tone-warn' if exe >= 100 else '') }}">{{ "%.1f"|format(exe) }} ms a request</span> · {{ "%.3g"|format(m.get('nfs_ops_per_sec')) }}/s
{%- else %}idle{% endif %}
{%- if m.get('nfs_retrans') %} · <span class="tone-warn">{{ m.get('nfs_retrans')|int }} sent again</span>{% endif %}
{%- if m.get('nfs_timeouts') %} · <span class="tone-crit">{{ m.get('nfs_timeouts')|int }} timed out</span>{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{# ── Drive health (#5789): each drive as the root helper read it with
smartctl, hourly; what needs attention first (drives.py judges them). ──── #}
{% set drive_badge = {"failing": ("crit", "Failing"), "unreadable": ("warn", "Unreadable"),
"worn": ("warn", "Worn"), "watch": ("warn", "Watch"), "ok": ("ok", "OK"),
"unavailable": ("inactive", "No SMART")} %}
{% if drives or drives_error %}
<div class="card">
<div class="section-title" style="margin-bottom:0.5rem;display:flex;justify-content:space-between;gap:0.75rem;">
<span>Drive health</span>
{% if drives_at %}<span style="font-weight:400;color:var(--text-dim);font-size:0.72rem;">read {{ drives_at|ago }}</span>{% endif %}
</div>
{% if drives_error %}
<p class="field-hint" style="margin:0;">Not read: {{ drives_error }}.</p>
{% endif %}
{% for d in drives %}
{% set tone, label = drive_badge[d.state] %}
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:0.75rem;font-size:0.8rem;padding:0.2rem 0;">
<span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ d.model }}">
{{ status_badge(tone, label) }}
<span style="font-family:ui-monospace,monospace;">{{ d.device }}</span>
{% if d.model %}<span style="color:var(--text-dim);">{{ d.model }}{% if d.capacity_bytes %} · {{ d.capacity_bytes|bytes }}{% endif %}</span>{% endif %}
{% if d.why %}<span style="color:var(--text-muted);"> — {{ d.why }}</span>{% endif %}
</span>
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;">
{%- set parts = [] -%}
{%- if d.get("life_used_pct") is not none %}{% set _ = parts.append("%d%% life used"|format(d.life_used_pct)) %}{% endif -%}
{%- if d.get("bad_sectors") is not none %}{% set _ = parts.append("%d bad sectors"|format(d.bad_sectors)) %}{% endif -%}
{%- if d.get("temp_c") is not none %}{% set _ = parts.append("%d°C"|format(d.temp_c)) %}{% endif -%}
{{ parts|join(" · ") if parts else "—" }}
</span>
</div>
{% endfor %}
</div>
@@ -159,6 +205,14 @@
</div>
{% endfor %}
</div>
{% if unwired_temps %}
{# Sensors that aren't thermometers: they run against the CPU or hold a
constant (#5783), or read a value no wired sensor gives (#5759). Left
out of the host's temperature. Each reason is said once, after its
sensors, hottest first within it. #}
<p class="field-hint" style="margin:0.5rem 0 0;">Not counted, likely nothing wired to them:
{% for why, group in unwired_temps.items()|groupby("1.1") %}{% for label, v in group %}{{ label }} {{ "%.0f"|format(v[0]) }}°C{{ ", " if not loop.last }}{% endfor %} ({{ why }}){{ "; " if not loop.last }}{% endfor %}.</p>
{% endif %}
</div>
{% endif %}
@@ -0,0 +1,38 @@
{# Who sent the host's traffic over the range (#6115): its containers and NFS
mounts at the host's busiest moment, biggest first. Lazy-loaded below the
charts and refreshed with them. #}
<div class="section-title" style="margin-bottom:0.5rem;">Who's sending — last {{ range_key }}</div>
{% if not got.host %}
<p class="field-hint" style="margin:0;">No outbound traffic recorded for this host in the last {{ range_key }}.</p>
{% else %}
{% set top = got.senders[0] if got.senders else None %}
<p style="margin:0 0 0.6rem;">
Busiest at {{ got.host.peak_at|when }}: <strong>{{ got.host.peak_bps|rate }}</strong> out (average {{ got.host.avg_bps|rate }}).
{% if top and top.at_host_peak_bps >= got.unaccounted_at_peak_bps %}Most of it from <strong>{{ top.name }}</strong> ({{ top.at_host_peak_bps|rate }}).
{% else %}Most of it from <strong>no container or NFS mount</strong>: the host itself.{% endif %}
</p>
<table class="table">
<thead><tr><th>Sender</th><th>At the peak</th><th>Average</th><th>Its own peak</th></tr></thead>
<tbody>
{% for r in got.senders %}
<tr>
<td><span title="{{ r.name }}">{{ "NFS " if r.kind == "nfs" }}{{ r.name }}</span></td>
<td style="font-variant-numeric:tabular-nums;">{{ r.at_host_peak_bps|rate }}</td>
<td style="font-variant-numeric:tabular-nums;">{{ r.avg_bps|rate }}</td>
<td style="font-variant-numeric:tabular-nums;">{{ r.peak_bps|rate }} · {{ r.peak_at|when }}</td>
</tr>
{% endfor %}
<tr>
<td style="color:var(--text-muted);">The host itself, or none of the above</td>
<td style="font-variant-numeric:tabular-nums;">{{ got.unaccounted_at_peak_bps|rate }}</td>
<td style="font-variant-numeric:tabular-nums;">{{ got.unaccounted_avg_bps|rate }}</td>
<td>—</td>
</tr>
</tbody>
</table>
<p class="field-hint" style="margin:0.5rem 0 0;">
Read in {{ (got.bucket_s / 60)|round|int }}-minute steps; a shorter range shows a spike sharper.
{% if got.more %}{{ got.more }} more sent less. {% endif %}
A container's traffic to another on this host counts for it without leaving the host.
</p>
{% endif %}
+13 -4
View File
@@ -1,10 +1,11 @@
{# Host health header — live polled into the host hub: live or stale, the worst
reading against Settings → Thresholds, last seen, then CPU / memory / disk /
load tiles graded by the same cutoffs (`grades`; CPU, memory and load within
load tiles, and the hottest sensor that counts with its name, graded by the same cutoffs (`grades`; CPU, memory and load within
the host's own normal for the hour read healthy, and `usual` says so). Renders nothing until the agent
reports (the Agent panel below then shows provisioning). #}
{% from "_macros.html" import status_badge, stat_tile %}
{% macro pct(v) %}{{ '%.0f%%'|format(v) if v is not none else '—' }}{% endmacro %}
{% macro deg(v) %}{{ '%.0f°C'|format(v) if v is not none else '—' }}{% endmacro %}
{% set grades = grades or {} %}
{% if reporting %}
<section class="card host-health" aria-label="Host health">
@@ -12,7 +13,7 @@
{{ status_badge('stale' if stale else 'up', 'Stale' if stale else 'Live') }}
<span class="host-health-summary">
{%- if stale %}No report since {{ reg.last_seen_at|ago }}; the readings below are from then.
{%- elif worst %}<span class="tone-{{ worst[2] }}">{{ worst[0] }} {{ pct(worst[1]) }}</span> is past its {{ "critical" if worst[2] == "crit" else "warning" }} threshold.
{%- elif worst %}<span class="tone-{{ worst[2] }}">{{ worst[0] }} {{ deg(worst[1]) if worst[0] == "Temperature" else pct(worst[1]) }}</span> is past its {{ "critical" if worst[2] == "crit" else "warning" }} threshold.
{%- elif usual %}Every reading is within its thresholds or its usual level for this hour.
{%- else %}Every reading is within its thresholds.{% endif -%}
</span>
@@ -21,12 +22,20 @@
{%- if reg.agent_version %}{{ " · " if not stale }}agent v{{ reg.agent_version }}{% endif -%}
{%- if clock and clock[0] %} · <span{% if clock[1] in ('warn', 'crit') %} class="tone-{{ clock[1] }}" title="Further from Steward's clock than Settings → Thresholds allows: check what keeps this host's time in sync"{% endif %}>{{ clock[0] }}</span>{% endif -%}
</span>
{# Charts, every mount, interface, disk and temperature sensor. #}
<a href="/plugins/host_agent/{{ reg.host_id }}/" class="btn btn-sm btn-ghost" style="margin-left:auto;">Full metrics →</a>
</div>
<div class="stat-strip host-health-tiles" style="margin-bottom:0;">
<div class="stat-strip host-health-tiles" style="margin-bottom:0;--tiles:{{ 5 if temp is not none else 4 }};">
{{ stat_tile("CPU", pct(cpu), state=(grades.cpu or [status_class(cpu, 'cpu')])[0], hint=sparks.cpu|safe) }}
{{ stat_tile("Memory", pct(mem), state=(grades.mem or [status_class(mem, 'mem')])[0], hint=sparks.mem|safe) }}
{{ stat_tile("Disk /", pct(disk_root), state=(grades.disk or [status_class(disk_root, 'disk')])[0], hint=sparks.disk|safe) }}
{%- set inodes_worse = inodes_root is defined and inodes_root is not none and (disk_root is none or inodes_root > disk_root) %}
{{ stat_tile("Disk /", pct(disk_root) ~ (" · inodes " ~ pct(inodes_root) if inodes_worse else ""),
state=(grades.disk or [status_class(disk_root, 'disk')])[0], hint=sparks.disk|safe) }}
{{ stat_tile("Load per core", pct(load_per_core), state=(grades.load or [status_class(load_per_core, 'load')])[0], hint=sparks.load|safe) }}
{% if temp is not none %}
{%- set temp_hint %}{{ sparks.temp|safe }}{% if hottest %}<span class="stat-tile-sensor" title="{{ hottest }}">{{ hottest }}</span>{% endif %}{% endset %}
{{ stat_tile("Temperature", deg(temp), state=status_class(temp, 'temp'), hint=temp_hint) }}
{% endif %}
</div>
{% if usual and not stale %}
<p class="field-hint" style="margin-top:0.6rem;">{{ usual | join("; ") | capitalize }}.</p>
@@ -35,7 +35,22 @@
<div class="section-title" style="margin-bottom:0.5rem;">Load &amp; pressure — last <span class="hm-chart-range">{{ current_range }}</span></div>
<div style="height:220px;"><canvas id="chart-pressure"></canvas></div>
</div>
{% if has_nfs %}
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">NFS delay (ms) — last <span class="hm-chart-range">{{ current_range }}</span></div>
<div style="height:220px;"><canvas id="chart-nfs"></canvas></div>
</div>
{% endif %}
</div>
<div id="hm-senders" class="card" style="margin-top:1rem;margin-bottom:0;"
hx-get="/plugins/host_agent/{{ host.id }}/senders"
hx-trigger="load, every {{ chart_poll_seconds }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
<div class="section-title" style="margin-bottom:0.5rem;">Who's sending</div>
<p class="field-hint" style="margin:0;">Loading…</p>
</div>
{% if "syslog" in enabled_plugins %}<p id="hm-marks-note" class="field-hint" hidden style="margin:0.5rem 0 0;"></p>{% endif %}
{# Hidden poller: fetches the history series and applies it to the charts above
(no visible swap). Lazy on load → never blocks paint; refreshed slowly + on
range change. #}
@@ -79,6 +94,10 @@
{ key: "psi_mem_some_avg10", label: "Mem PSI %", color: stewardColor("--red") },
{ key: "temp_c_max", label: "Temp °C", color: stewardColor("--yellow") },
] },
"chart-nfs": { ymax: undefined, ds: [
{ key: "nfs_exe_max_ms", label: "Slowest mount, ms a request", color: stewardColor("--blue") },
{ key: "mounts_stalled", label: "Mounts not answering", color: stewardColor("--red") },
] },
};
const charts = {};
for (const id of Object.keys(defs)) {
@@ -102,6 +121,11 @@
ch.update("none");
}
if (rangeKey) document.querySelectorAll(".hm-chart-range").forEach((s) => { s.textContent = rangeKey; });
{% if "syslog" in enabled_plugins %}
// The host's own syslog errors over the same span (#6002).
stewardMarkCharts("/plugins/syslog/marks?host_id={{ host.id }}&range=" + encodeURIComponent(rangeKey || ""),
Object.values(charts), document.getElementById("hm-marks-note"));
{% endif %}
};
})();
</script>
+30 -28
View File
@@ -11,7 +11,9 @@ AGENT_VERSION="{{ agent_version }}"
AGENT_USER="steward-agent"
AGENT_DIR="/usr/local/lib/steward-agent"
CONF_FILE="/etc/steward-agent.conf"
UNIT_FILE="/etc/systemd/system/steward-agent.service"
UNIT_DIR="/etc/systemd/system"
UNITS="steward-agent.service steward-maint.service steward-maint.path"
FILES_URL="${STEWARD_URL}/plugins/host_agent/files"
# ── preflight ────────────────────────────────────────────────────────────────
[ "$(id -u)" = "0" ] || { echo "must run as root (use sudo)"; exit 1; }
@@ -20,9 +22,10 @@ command -v python3 >/dev/null 2>&1 || { echo "python3 not found — install py
# Handle --uninstall
if [ "${1:-}" = "--uninstall" ]; then
systemctl disable --now steward-agent.service 2>/dev/null || true
rm -f "$UNIT_FILE" "$CONF_FILE"
rm -rf "$AGENT_DIR"
systemctl disable --now steward-maint.path steward-agent.service 2>/dev/null || true
for u in $UNITS; do rm -f "$UNIT_DIR/$u"; done
rm -f "$CONF_FILE"
rm -rf "$AGENT_DIR" /var/lib/steward-agent /var/lib/steward-maint
systemctl daemon-reload
# keep the system user — removing it risks orphaned files elsewhere
echo "uninstalled"
@@ -36,7 +39,7 @@ fi
# ── drop the agent file ──────────────────────────────────────────────────────
mkdir -p "$AGENT_DIR"
curl -sSL "${STEWARD_URL}/plugins/host_agent/agent.py" -o "$AGENT_DIR/agent.py"
curl -fsSL "${STEWARD_URL}/plugins/host_agent/agent.py" -o "$AGENT_DIR/agent.py"
chmod 0755 "$AGENT_DIR/agent.py"
chown root:root "$AGENT_DIR/agent.py"
@@ -53,31 +56,30 @@ EOF
chown "root:$AGENT_USER" "$CONF_FILE"
chmod 0640 "$CONF_FILE"
# ── write systemd unit ───────────────────────────────────────────────────────
cat > "$UNIT_FILE" <<EOF
[Unit]
Description=Steward host agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$AGENT_USER
ExecStart=/usr/bin/python3 $AGENT_DIR/agent.py
Restart=on-failure
RestartSec=10
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadOnlyPaths=/proc /sys
[Install]
WantedBy=multi-user.target
EOF
# ── the maintenance helper and the units (#5766) ─────────────────────────────
# One copy, served by Steward, the same the playbooks install. The agent may
# write only its request directory; the helper (root) and its results don't
# belong to it.
curl -fsSL "${FILES_URL}/maint.py" -o "$AGENT_DIR/maint.py"
chmod 0755 "$AGENT_DIR/maint.py"
chown root:root "$AGENT_DIR/maint.py"
# Its packages (smartctl for drive health, #5789), with whichever package
# manager this host has; one that can't be installed is said, never fatal.
curl -fsSL "${FILES_URL}/install-deps.sh" -o "$AGENT_DIR/install-deps.sh"
chmod 0755 "$AGENT_DIR/install-deps.sh"
chown root:root "$AGENT_DIR/install-deps.sh"
"$AGENT_DIR/install-deps.sh" || true
install -d -o root -g root -m 0755 /var/lib/steward-agent /var/lib/steward-maint /var/lib/steward-maint/results
install -d -o "$AGENT_USER" -g "$AGENT_USER" -m 0700 /var/lib/steward-agent/requests
for u in $UNITS; do
curl -fsSL "${FILES_URL}/$u" -o "$UNIT_DIR/$u"
chmod 0644 "$UNIT_DIR/$u"
done
systemctl daemon-reload
systemctl enable --now steward-agent.service
systemctl enable --now steward-maint.path
systemctl enable steward-agent.service
systemctl restart steward-agent.service
echo
echo "Steward host agent $AGENT_VERSION installed and running."
@@ -0,0 +1,173 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs, empty_state, page_header %}
{% block title %}Maintenance — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), ("Maintenance", "")]) }}{% endblock %}
{% block content %}
{# A group's updates and reboots on a weekly cadence, one host at a time
(#5768). What needs attention first: a run going now, a run that failed;
then the plans with their recent runs. #}
{% macro run_hosts(rows) -%}
<div style="display:flex;flex-direction:column;gap:0.15rem;font-size:0.82rem;">
{% for r in rows %}
<div style="display:flex;gap:0.6rem;align-items:baseline;" {% if r.detail %}title="{{ r.detail }}"{% endif %}>
{% if r.host_id %}<a href="/hosts/{{ r.host_id }}" style="min-width:10rem;">{{ r.name }}</a>{% else %}<span style="min-width:10rem;">{{ r.name }}</span>{% endif %}
<span class="{{ 'tone-warn' if r.bad else '' }}" style="{{ '' if r.bad or r.busy else 'color:var(--text-muted);' }}">{{ r.text }}</span>
</div>
{% endfor %}
</div>
{%- endmacro %}
{{ page_header("Maintenance") }}
<p style="color:var(--text-muted);font-size:0.84rem;margin:-0.4rem 0 1.25rem;">
Each plan updates an inventory group's hosts once a week, one host at a time. A host reboots
only when its update needs it, unless the plan says otherwise. A swarm node is drained before
a reboot, or before the update when it upgrades Docker, given 5 minutes for its tasks to move,
and made active again after. Containers running outside the swarm must be running again
within 10 minutes. The next host starts once the last is back in service. A failure stops the
run, and the host's <em>Maintenance failed</em> figure can drive an alert rule. Once every host
is back, a run that drained swarm nodes spreads the services back out: only those whose image
the emptier node already holds, never one the plan keeps in place.
</p>
{% if live %}
{% for l in live %}
<div class="card">
<h3 style="margin-bottom:0.5rem;">Running: {{ l.plan }} <span style="font-size:0.78rem;color:var(--text-muted);font-weight:normal;">· started {{ l.run.started_at|ago }}{% if l.run.manual %} · run now{% endif %}</span></h3>
{{ run_hosts(l.rows) }}
</div>
{% endfor %}
{% endif %}
{% for f in failed %}
<div class="card" style="border-color:color-mix(in srgb,var(--yellow) 40%,var(--border));">
<span class="tone-warn">Failed: {{ f.plan }}</span>
<span style="font-size:0.82rem;color:var(--text-muted);">· {{ f.run.finished_at|ago }} · {{ f.run.detail or "no detail" }}</span>
</div>
{% endfor %}
{% if plans %}
{% for p in plans %}
<div class="card">
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
<h3 style="margin-bottom:0.2rem;">{{ p.plan.name }}{% if not p.plan.enabled %} <span style="font-size:0.78rem;color:var(--text-muted);font-weight:normal;">· paused</span>{% endif %}</h3>
{% if session.user_role == 'admin' %}
<div style="display:flex;gap:0.4rem;">
<form method="post" action="/plugins/host_agent/maintenance/plans/{{ p.plan.id }}/run" style="margin:0;"
data-confirm="Run {{ p.plan.name }} now? Its {{ p.hosts }} host{{ 's' if p.hosts != 1 }} are updated one at a time; the schedule keeps its time.">{{ csrf_field() }}
<button type="submit" class="btn btn-sm">Run now</button>
</form>
<form method="post" action="/plugins/host_agent/maintenance/plans/{{ p.plan.id }}/toggle" style="margin:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-ghost">{{ 'Pause' if p.plan.enabled else 'Resume' }}</button>
</form>
<form method="post" action="/plugins/host_agent/maintenance/plans/{{ p.plan.id }}/delete" style="margin:0;"
data-confirm="Delete {{ p.plan.name }} and its run history? Hosts' own job history stays.">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</div>
{% endif %}
</div>
<div style="font-size:0.82rem;color:var(--text-muted);margin-bottom:0.6rem;">
Group <a href="/hosts/?group={{ p.group|urlencode }}">{{ p.group }}</a> ({{ p.hosts }} host{{ 's' if p.hosts != 1 }})
· {{ p.when }}
· reboot {{ {'if_required': 'only when required', 'always': 'always', 'never': 'never'}[p.plan.reboot_policy] }}
· {% if p.plan.spread %}spreads swarm services back out{% if p.plan.keep_in_place %}, keeping {{ p.plan.keep_in_place }} in place{% endif %}{% else %}leaves swarm services where the run put them{% endif %}
{% if p.next %}· next {{ p.next|when }}{% endif %}
</div>
{% if session.user_role == 'admin' %}
<details style="margin-bottom:0.5rem;">
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);">After the run</summary>
<form method="post" action="/plugins/host_agent/maintenance/plans/{{ p.plan.id }}/spread"
style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;margin:0.4rem 0 0 1rem;">{{ csrf_field() }}
<label style="display:flex;gap:0.4rem;align-items:center;font-size:0.84rem;">
<input type="checkbox" name="spread" {% if p.plan.spread %}checked{% endif %}> Spread swarm services back out
</label>
<div class="form-group" style="margin-bottom:0;">
<label>Keep in place</label>
<input type="text" name="keep_in_place" value="{{ p.plan.keep_in_place }}" maxlength="2000"
placeholder="web_proxy, metrics_app" style="width:22rem;" title="Swarm service names, comma-separated">
</div>
<button type="submit" class="btn btn-sm">Save</button>
</form>
</details>
{% endif %}
{% for r in p.runs %}
<details {% if loop.first %}open{% endif %} style="margin-top:0.3rem;">
<summary style="cursor:pointer;font-size:0.82rem;">
<span class="{{ 'tone-warn' if r.run.state == 'failed' else '' }}">{{ {'running': 'Running', 'done': 'Done', 'failed': 'Failed', 'stopped': 'Stopped'}[r.run.state] }}</span>
<span style="color:var(--text-muted);">· {{ r.run.started_at|when }}{% if r.run.manual %} · run now{% endif %}{% if r.run.detail %} · {{ r.run.detail|truncate(100) }}{% endif %}</span>
</summary>
<div style="margin:0.3rem 0 0.4rem 1rem;">{{ run_hosts(r.rows) }}</div>
</details>
{% else %}
<p style="font-size:0.82rem;color:var(--text-dim);margin:0;">No runs yet.</p>
{% endfor %}
</div>
{% endfor %}
{% else %}
<div class="card">{{ empty_state("No maintenance plans yet." ~ (" Add one below." if session.user_role == 'admin' else "")) }}</div>
{% endif %}
{% if session.user_role == 'admin' %}
<div class="card">
<h3 style="margin-bottom:0.6rem;">Add a plan</h3>
{% if groups %}
<form method="post" action="/plugins/host_agent/maintenance/plans"
style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
<div class="form-group" style="margin-bottom:0;">
<label>Name</label>
<input type="text" name="name" required maxlength="128" placeholder="Docker hosts, weekly" style="width:13rem;">
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Group</label>
<select name="group_id" required>
{% for g in groups %}<option value="{{ g.id }}">{{ g.name }}</option>{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Day</label>
<select name="weekday">
{% for d in weekdays %}<option value="{{ loop.index0 }}" {% if loop.index0 == 6 %}selected{% endif %}>{{ d }}</option>{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Time</label>
<input type="time" name="at_time" value="03:00" required style="width:7rem;">
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Time zone</label>
<input type="text" name="time_zone" value="{{ default_zone }}" required style="width:11rem;">
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Window (min)</label>
<input type="number" name="window_minutes" value="240" min="30" max="1440" style="width:6rem;">
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Reboot</label>
<select name="reboot_policy">
<option value="if_required" selected>Only when required</option>
<option value="always">Always</option>
<option value="never">Never</option>
</select>
</div>
<label style="display:flex;gap:0.4rem;align-items:center;font-size:0.84rem;margin-bottom:0.45rem;">
<input type="checkbox" name="spread" checked> Spread swarm services back out after
</label>
<div class="form-group" style="margin-bottom:0;">
<label>Keep in place</label>
<input type="text" name="keep_in_place" maxlength="2000" placeholder="web_proxy, metrics_app"
style="width:14rem;" title="Swarm service names, comma-separated">
</div>
<button type="submit" class="btn">Add plan</button>
</form>
<p style="font-size:0.78rem;color:var(--text-dim);margin:0.6rem 0 0;">
Hosts whose agent has no maintenance helper are skipped and listed; run Update agent on them.
No host is started once the window has passed.
</p>
{% else %}
<p style="font-size:0.85rem;color:var(--text-dim);margin:0;">
Plans work on inventory groups. Add one under <a href="/ansible/inventory/">Ansible → Inventory</a>.
</p>
{% endif %}
</div>
{% endif %}
{% endblock %}
+65 -4
View File
@@ -14,10 +14,52 @@
</div>
{% if reporting %}
{# Reporting: live vitals are shown by the vitals strip above; this panel is
the way to the full metrics, with the lifecycle actions folded under
"Manage agent" (#5278). #}
<a href="/plugins/host_agent/{{ host.id }}/" class="btn btn-sm btn-ghost">Full metrics →</a>
{# Reporting: live vitals, and the way to the full metrics, are in the
vitals strip at the top of the page; this panel holds what the host has
waiting, with the lifecycle actions folded under "Manage agent" (#5278). #}
{# What the host has waiting, from the agent's hourly check (#5765): a
needed reboot first, then the updates. #}
{% if reg.updates_checked_at or reg.reboot_required or maint_job %}
<div style="margin-top:0.8rem;font-size:0.82rem;display:flex;flex-direction:column;gap:0.2rem;">
{# Steward's own maintenance job on this host (#5767): a running one first. #}
{% if maint_job %}
{% set running = maint_job.state not in ('done', 'failed', 'timed_out') %}
<div class="{{ 'tone-warn' if running or maint_failed else '' }}" title="{{ maint_job.detail or '' }}">
<a href="/plugins/host_agent/maintenance" style="color:inherit;">Maintenance</a>: {{ maint_summary }}
<span style="color:var(--text-muted);">· {% if running %}since {{ maint_job.state_at|ago }}{% else %}{{ maint_job.finished_at|ago }}{% endif %}{% if maint_failed and maint_job.detail %} · {{ maint_job.detail|truncate(90) }}{% endif %}</span>
</div>
{% endif %}
{% if reg.reboot_required %}
<div class="tone-warn" title="{{ reg.reboot_packages or '' }}">
Reboot required{% if reg.reboot_required_since %} since {{ reg.reboot_required_since|ago }}{% endif %}{% if reg.reboot_packages %}
<span style="color:var(--text-muted);">· {{ reg.reboot_packages|truncate(80) }}</span>{% endif %}
</div>
{% endif %}
{% if reg.updates_checked_at %}
<div>
{% if reg.updates_error %}<span class="tone-warn">Update check failed: {{ reg.updates_error }}</span>
{% elif reg.updates_pending %}<span class="{{ 'tone-warn' if reg.updates_security else '' }}">{{ reg.updates_pending }} update{{ 's' if reg.updates_pending != 1 }} waiting{% if reg.updates_security %} ({{ reg.updates_security }} security){% endif %}</span>
{% else %}Up to date{% endif %}
<span style="color:var(--text-muted);">· checked {{ reg.updates_checked_at|ago }}</span>
</div>
{% endif %}
</div>
{% endif %}
{# What the agent said happened while it was away, last 7 days (#5756). #}
{% if downtime %}
<div style="margin-top:0.8rem;">
<div style="font-size:0.78rem;color:var(--text-muted);margin-bottom:0.3rem;">Downtime, last 7 days</div>
{% for d in downtime %}
<div style="display:flex;gap:0.6rem;align-items:baseline;font-size:0.82rem;padding:0.15rem 0;">
<span class="{{ 'tone-warn' if d.ongoing or d.kind == 'unclean' else '' }}" style="min-width:12rem;">{{ d.label }}</span>
<span style="color:var(--text-muted);">{{ d.started_at|when }}</span>
<span style="color:var(--text-muted);margin-left:auto;">{{ 'going on, ' if d.ongoing }}{{ d.seconds|duration }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if session.user_role == 'admin' %}
<details style="margin-top:0.7rem;">
@@ -43,6 +85,25 @@
Link an Ansible target (below) to update the agent from here.
</p>
{% endif %}
{# Update now (#5767): the agent's helper updates the host and reboots it
only if the update needs it, draining its swarm node first. #}
{% if maint_blocker %}
<p style="font-size:0.8rem;margin:0.6rem 0 0;color:var(--text-dim);">Can't update from here: {{ maint_blocker }}.</p>
{% else %}
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/maintain"
style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin:0.6rem 0 0;"
data-confirm="Update {{ host.name }} now? It reboots only if the update needs it. A swarm node is drained first when it reboots or Docker is upgraded.">{{ csrf_field() }}
<button type="submit" class="btn btn-sm">Update now</button>
<label style="font-size:0.8rem;color:var(--text-muted);display:flex;gap:0.3rem;align-items:center;">
<input type="checkbox" name="reboot_policy" value="never"> never reboot
</label>
</form>
{% endif %}
{# The root helper that carries out scheduled updates and reboots (#5766). #}
<p style="font-size:0.8rem;margin:0.6rem 0 0;color:var(--text-dim);">
{% if reg.maint_helper %}Maintenance helper installed: Steward can update and reboot this host.
{% else %}No maintenance helper yet: Update agent installs it, so Steward can update and reboot this host.{% endif %}
</p>
{% if ansible_available and target and managed_key_set %}
<details style="margin-top:0.7rem;">
+130
View File
@@ -0,0 +1,130 @@
# plugins/host_agent/timeline.py
"""What the host agent puts on the timeline: each maintenance job once it
ends, the swarm services a maintenance run spread back out after its last
host, and each stretch a host was away (a reboot, a crash, lost contact, the
agent restarting). One entry per job and per downtime row, kept up to date
as the row changes: a downtime that ends gets its length, a failed job
whose host came back late reads as back.
"""
from __future__ import annotations
import logging
from datetime import datetime
from sqlalchemy import select
from steward.timeline import catalog
from steward.timeline.record import record_or_update, username
from . import downtime, maintenance
logger = logging.getLogger(__name__)
TYPES = (
("maintenance.done", "Maintenance run", "automation", "host_os", True,
"A host's agent updated it, and rebooted it when the update needed one."),
("maintenance.failed", "Maintenance failed", "automation", "host_os", True,
"A maintenance job failed or timed out."),
("maintenance.rebalanced", "Swarm services spread back out", "automation", "service", True,
"After a maintenance run, its swarm services were moved back onto the nodes it had drained."),
("host.rebooted", "Host rebooted", "observed", "host_os", True,
"A host went down and came back, announced or not (a crash, a power cut)."),
("host.lost_contact", "Agent lost contact", "observed", "network", True,
"A host's agent ran but couldn't reach Steward."),
("host.agent_restarted", "Agent stopped or restarted", "observed", "host_os", False,
"The agent stopped or restarted while its host stayed up."),
)
def register() -> None:
for t in TYPES:
catalog.register_type(*t)
from .models import HostDowntime, HostMaintenanceJob, HostMaintenanceRun
catalog.register_ref("maintenance_job", HostMaintenanceJob)
catalog.register_ref("maintenance_run", HostMaintenanceRun)
catalog.register_ref("host_downtime", HostDowntime)
async def _host_name(session, host_id: str) -> str | None:
from steward.models.hosts import Host
host = await session.get(Host, host_id)
return host.name if host is not None else None
async def job_entry(session, job, host_name: str | None = None):
"""The entry for a finished maintenance job, written or brought up to date."""
if job.state not in maintenance.FINISHED:
return None
host_name = host_name or await _host_name(session, job.host_id) or job.host_id
return await record_or_update(
session, ("maintenance_job", job.id),
type="maintenance.failed" if maintenance.still_failed(job) else "maintenance.done",
at=job.created_at, until=job.finished_at,
summary=f"Maintenance on {host_name}: {maintenance.summary(job)}",
hosts=[host_name], subject=host_name,
detail={"state": job.state, "packages": job.packages, "rebooted": job.back_at is not None,
"note": job.detail, "url": f"/hosts/{job.host_id}"},
actor=await username(session, job.created_by) or "Steward")
async def run_entry(session, run):
"""The entry for a run's spreading of swarm services back out, once it ended."""
from . import rebalance
from .models import HostMaintenancePlan
rb = run.rebalance or {}
if rb.get("state") not in ("done", "failed", "timed_out"):
return None
plan = await session.get(HostMaintenancePlan, run.plan_id)
summary = rebalance.outcome(run)[1] or "swarm services spread back out"
return await record_or_update(
session, ("maintenance_run", run.id), type="maintenance.rebalanced",
at=datetime.fromisoformat(rb["at"]),
until=datetime.fromisoformat(rb["finished_at"]) if rb.get("finished_at") else None,
summary=f"{plan.name if plan else 'Maintenance'}: {summary}",
hosts=[], subject=None,
detail={"state": rb["state"], "moved": rb.get("moved") or [], "left": rb.get("left") or [],
"note": rb.get("detail"), "url": "/plugins/host_agent/maintenance"},
actor=await username(session, plan.created_by if plan else None) or "Steward")
_DOWNTIME_TYPES = {"reboot": "host.rebooted", "unclean": "host.rebooted",
"lost_contact": "host.lost_contact", "agent_stop": "host.agent_restarted"}
async def downtime_entry(session, row, host_name: str | None = None):
"""The entry for one stretch a host was away, written or brought up to date."""
from steward.core.formatting import human_duration
host_name = host_name or await _host_name(session, row.host_id) or row.host_id
seen = downtime.describe(row, row.ended_at or row.started_at)
said = seen["label"][:1].lower() + seen["label"][1:]
said += f" for {human_duration(seen['seconds'])}" if row.ended_at is not None else ", not back yet"
return await record_or_update(
session, ("host_downtime", row.id),
type=_DOWNTIME_TYPES.get(row.kind, "host.rebooted"),
at=row.started_at, until=row.ended_at, summary=f"{host_name}: {said}",
hosts=[host_name], subject=host_name,
detail={"kind": row.kind, "url": f"/hosts/{row.host_id}"}, actor="Steward")
async def write(session, what: str, coro) -> None:
"""Run one entry writer in a savepoint: the source's own row matters more
than its entry, so a failure is logged and nothing else is undone."""
try:
async with session.begin_nested():
await coro
except Exception:
logger.exception("Timeline: the entry for %s could not be written", what)
async def backfill(session, since) -> list:
"""Entries for the jobs and downtime that predate the timeline."""
from .models import HostDowntime, HostMaintenanceJob
out = []
for job in (await session.execute(select(HostMaintenanceJob).where(
HostMaintenanceJob.created_at >= since,
HostMaintenanceJob.state.in_(maintenance.FINISHED)))).scalars().all():
out.append(await job_entry(session, job))
for row in (await session.execute(select(HostDowntime).where(
HostDowntime.started_at >= since))).scalars().all():
out.append(await downtime_entry(session, row))
return out
+10
View File
@@ -68,3 +68,13 @@ def get_audit_entities() -> dict:
from .models import PrometheusTarget, PrometheusWidgetTemplate
return {"prometheus_target": (PrometheusTarget, "/plugins/prometheus/targets/{}"),
"prometheus_template": (PrometheusWidgetTemplate, "/plugins/prometheus/templates/custom:{}")}
def register_mcp_tools(register) -> None:
from .mcp_tools import register_tools
register_tools(register)
async def mcp_overview(db) -> list[dict]:
from .mcp_tools import mcp_overview as findings
return await findings(db)
+114
View File
@@ -0,0 +1,114 @@
"""The prometheus plugin's MCP tools and its get_overview findings (#5840).
Registered through register_mcp_tools() in __init__.py, so they exist only
while the plugin is enabled. They read what the scraper stored; nothing
reaches a target, and a target's bearer token (or a password in its URL)
is never returned.
"""
from __future__ import annotations
from sqlalchemy import select
from steward.core.metric_series import latest_values
from steward.mcp.tools._shape import age_s, bounded, clamp_limit, db_session, iso, now_utc, pick, redact_url
from steward.mcp.tools.overview import finding
from steward.models.metrics import PluginMetricLatest
from steward.models.users import UserRole
from .models import PrometheusTarget
from .routes import group_series
from .service import OWN_METRICS, SOURCE, target_state
REACHES = ("down", "stale", "pending", "up", "paused")
_ORDER = {r: i for i, r in enumerate(REACHES)}
def _target_view(t: PrometheusTarget, own: dict, now) -> dict:
state = target_state(t, now)
return {"target_id": t.id, "name": t.name, "url": redact_url(t.url), "enabled": t.enabled,
"reach": state.reach, "says": state.says, "interval_s": t.interval_seconds,
"scraped_at": iso(t.last_scraped_at), "ok_at": iso(t.last_ok_at), "ok_age_s": age_s(t.last_ok_at, now),
"series_kept": t.last_kept, "series_dropped": t.last_dropped, "series_capped": t.last_capped,
"series_cap": t.series_cap, "keep": list(t.keep or []), "drop": list(t.drop or []),
"scrape_ms": own.get("scrape_ms")}
async def list_prometheus_targets() -> dict:
"""The /metrics endpoints Steward scrapes, not answering first.
`reach`: "down" (the last scrape failed; `says` is why), "stale" (not
scraped recently), "pending", "up" or "paused". `series_kept` /
`series_dropped` count the newest scrape's series the keep/drop patterns
let through or left out; `series_capped` those past `series_cap`, which
were not stored. get_prometheus_target lists a target's series;
query_metrics(source_module="prometheus", resource=…, metric=…) reads
one's history.
"""
now = now_utc()
async with db_session() as db:
targets = list((await db.execute(select(PrometheusTarget).order_by(PrometheusTarget.name))).scalars())
rows = (await db.execute(select(PluginMetricLatest).where(
PluginMetricLatest.source_module == SOURCE,
PluginMetricLatest.resource_name.in_([t.name for t in targets] or [""]),
PluginMetricLatest.metric_name == "scrape_ms"))).scalars()
own = {r.resource_name: {"scrape_ms": r.value} for r in rows}
views = [_target_view(t, own.get(t.name, {}), now) for t in targets]
views.sort(key=lambda v: (_ORDER[v["reach"]], v["name"].lower()))
return {"targets": views}
async def get_prometheus_target(target: str, search: str = "", limit: int = 100) -> dict:
"""One target's stored series, grouped by metric name, each with the
labels that tell its series apart and the newest value in its unit.
target: its name or id (list_prometheus_targets).
search: part of a metric name; empty for all.
limit: metric groups to return, default 100, max 500.
A state set (a series per state, one of them 1) is folded to the
current state. `resource` is what query_metrics takes for a series'
history (source_module "prometheus").
"""
now = now_utc()
async with db_session() as db:
t = pick(target, list((await db.execute(select(PrometheusTarget))).scalars()),
kind="Prometheus target", list_tool="list_prometheus_targets")
latest = await latest_values(db, SOURCE, t.name, include_children=True)
own = {r.metric_name: r.value for r in latest if r.resource_name == t.name and r.metric_name in OWN_METRICS}
groups = group_series(t.name, latest)
if search:
groups = [g for g in groups if search.lower() in g["metric"].lower()]
def row(r: dict, states: bool) -> dict:
if states:
return {"labels": r["labels"], "resource": r["resource"], "state": r["text"], "states": r["states"]}
return {"labels": r["labels"], "resource": r["resource"], "value": r["value"],
"shown": r["text"] or iso(r["moment"]), "at": iso(r["at"])}
shaped = [{"metric": g["metric"], "series": [row(r, g["states"]) for r in g["rows"][:50]],
**({"more_series": len(g["rows"]) - 50} if len(g["rows"]) > 50 else {})} for g in groups]
out = {"target": _target_view(t, own, now)}
out.update(bounded(shaped, clamp_limit(limit, 100, 500), key="metrics",
more="Pass a `search`, or a larger `limit`."))
return out
def target_findings(views: list[dict]) -> list[dict]:
"""A target whose last scrape failed. Stale and paused are the plugin or
the admin's choice, not the endpoint, so they are left out."""
return [finding("warning", "prometheus_target_down", v["name"],
f"Prometheus target {v['name']} isn't answering: {v['says']}",
since=v["_ok_at"], ids={"target_id": v["target_id"]},
next=f'get_prometheus_target(target="{v["name"]}")')
for v in views if v["reach"] == "down"]
async def mcp_overview(db) -> list[dict]:
now = now_utc()
targets = list((await db.execute(select(PrometheusTarget))).scalars())
return target_findings([_target_view(t, {}, now) | {"_ok_at": t.last_ok_at} for t in targets])
def register_tools(register) -> None:
for fn in (list_prometheus_targets, get_prometheus_target):
register(fn, role=UserRole.viewer, read_only=True)
@@ -0,0 +1,23 @@
"""A target whose token was refused stops being scraped until it is saved
Revision ID: prometheus_003_refused
Revises: prometheus_002_templates
Create Date: 2026-11-01
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "prometheus_003_refused"
down_revision: Union[str, None] = "prometheus_002_templates"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("prometheus_targets", sa.Column("refused", sa.Boolean, nullable=False, server_default=sa.false()))
def downgrade() -> None:
op.drop_column("prometheus_targets", "refused")
@@ -0,0 +1,23 @@
"""A target can take its token from another plugin's sign-in
Revision ID: prometheus_004_token_from
Revises: prometheus_003_refused
Create Date: 2026-11-01
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "prometheus_004_token_from"
down_revision: Union[str, None] = "prometheus_003_refused"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("prometheus_targets", sa.Column("token_from", sa.String(50), nullable=True))
def downgrade() -> None:
op.drop_column("prometheus_targets", "token_from")
@@ -0,0 +1,32 @@
"""Every target holds its own token: one that took it from a sign-in goes
Revision ID: prometheus_005_own_tokens
Revises: prometheus_004_token_from
Create Date: 2026-11-01
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "prometheus_005_own_tokens"
down_revision: Union[str, None] = "prometheus_004_token_from"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Left in place, such a target would ask with no token, and some services ban
# an address for that. Its newest values go with it; history ages out. On a new
# database the metric tables may not exist yet, and there is nothing to remove.
if sa.inspect(op.get_bind()).has_table("plugin_metric_latest"):
op.execute("""
DELETE FROM plugin_metric_latest l USING prometheus_targets t
WHERE t.token_from IS NOT NULL AND l.source_module = 'prometheus'
AND (l.resource_name = t.name OR left(l.resource_name, length(t.name) + 1) = t.name || ':')""")
op.execute("DELETE FROM prometheus_targets WHERE token_from IS NOT NULL")
op.drop_column("prometheus_targets", "token_from")
def downgrade() -> None:
op.add_column("prometheus_targets", sa.Column("token_from", sa.String(50), nullable=True))
+3 -1
View File
@@ -17,7 +17,8 @@ class PrometheusTarget(Base):
patterns ("fabledarchives_*") an admin chooses: Steward does the
filtering, the endpoint serves everything. The last_* columns are the
newest scrape's outcome, written by the scraper, so a page can say why a
target isn't answering without asking it.
target isn't answering without asking it. `refused` says its token was
refused, which stops the scraping until the target is saved or resumed.
"""
__tablename__ = "prometheus_targets"
@@ -31,6 +32,7 @@ class PrometheusTarget(Base):
drop: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
series_cap: Mapped[int] = mapped_column(Integer, nullable=False, default=1000)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
refused: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
last_scraped_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+29 -6
View File
@@ -20,12 +20,12 @@ from steward.models.users import UserRole
from . import library
from .models import PrometheusTarget, PrometheusWidgetTemplate
from .scraper import ScrapeError, scrape
from .service import (OWN_METRICS, SOURCE, FormError, fold_states, form_values, map_families,
from .service import (OWN_METRICS, SOURCE, FormError, fold_states, form_values, is_moment, map_families,
parse_form, patterns, shown_value, target_state)
prometheus_bp = Blueprint("prometheus", __name__, template_folder="templates")
_REACH_ORDER = {"down": 0, "stale": 1, "pending": 2, "up": 3, "off": 4}
_REACH_ORDER = {"down": 0, "stale": 1, "pending": 2, "up": 3, "paused": 4}
def _actor() -> tuple[str | None, str]:
@@ -40,7 +40,8 @@ def view(target: PrometheusTarget, own: dict, now: datetime) -> dict:
"""What a page says about a target: its state in words and the numbers
from its newest scrape."""
state = target_state(target, now)
return {"id": target.id, "name": target.name, "url": target.url, "reach": state.reach,
return {"id": target.id, "name": target.name, "url": target.url, "enabled": target.enabled,
"reach": state.reach,
"says": state.says, "scraped_at": target.last_scraped_at, "ok_at": target.last_ok_at,
"interval": target.interval_seconds, "kept": target.last_kept,
"dropped": target.last_dropped, "capped": target.last_capped, "cap": target.series_cap,
@@ -79,8 +80,7 @@ def _typed(form, target: PrometheusTarget | None = None) -> dict:
return {"name": form.get("name", ""), "url": form.get("url", ""),
"interval_seconds": form.get("interval_seconds", ""), "series_cap": form.get("series_cap", ""),
"keep": form.get("keep", ""), "drop": form.get("drop", ""),
"verify_tls": bool(form.get("verify_tls")), "enabled": bool(form.get("enabled")),
"has_token": bool(target and target.token)}
"verify_tls": bool(form.get("verify_tls")), "has_token": bool(target and target.token)}
async def _name_taken(db, name: str, other_than: str | None = None) -> bool:
@@ -153,6 +153,7 @@ async def save_target(target_id: str):
# Where it is or what is kept may have changed: the last outcome no
# longer describes it, so the page says "not scraped yet" until the next scrape.
target.last_scraped_at = target.last_ok_at = target.last_error = None
target.refused = False
target.last_kept = target.last_dropped = target.last_capped = 0
if old_name != target.name:
# Its history is stored under its name, and its labelled series
@@ -176,6 +177,28 @@ async def save_target(target_id: str):
return redirect(f"/plugins/prometheus/targets/{target_id}")
@prometheus_bp.post("/targets/<target_id>/toggle")
@require_role(UserRole.admin)
async def toggle_target(target_id: str):
"""Pause a target, or resume a paused one, from its page or the list."""
async with current_app.db_sessionmaker() as db:
async with db.begin():
target = await _target_or_404(db, target_id)
target.enabled = not target.enabled
if target.enabled:
# Resumed: it reads "not scraped yet" until the next scrape,
# rather than stale from before the pause.
target.last_scraped_at = target.last_error = None
target.refused = False
enabled, name = target.enabled, target.name
# A rate needs two readings in a row; one from before the pause isn't one.
from . import scheduler
scheduler.RATES.forget(target_id)
await log_audit(current_app, *_actor(), "prometheus.target_resumed" if enabled else "prometheus.target_paused",
entity_type="prometheus_target", entity_id=target_id, detail={"name": name})
return redirect(request.referrer or f"/plugins/prometheus/targets/{target_id}")
@prometheus_bp.post("/targets/<target_id>/delete")
@require_role(UserRole.admin)
async def delete_target(target_id: str):
@@ -304,7 +327,7 @@ async def series_detail(target_id: str):
series = await points(db, SOURCE, resource, metric,
datetime.now(timezone.utc) - timedelta(hours=hours))
labels = resource[len(target.name) + 1:] if resource != target.name else ""
stamp_axis = metric.endswith("_timestamp_seconds")
stamp_axis = is_moment(metric)
return await render_template(
"prometheus/series.html", target=target, resource=resource, metric=metric, labels=labels,
now=shown_value(metric, now_row.value if now_row else None), hours=hours, ranges=RANGES,
+3 -2
View File
@@ -40,7 +40,7 @@ def make_scrape_task(app: "Quart") -> ScheduledTask:
def is_due(target, now: datetime) -> bool:
"""Whether a target's interval has elapsed since its last scrape, with
half a tick of slack so tick jitter doesn't push it back a whole tick."""
if not target.enabled:
if not target.enabled or target.refused:
return False
if target.last_scraped_at is None:
return True
@@ -115,7 +115,7 @@ async def record(app: "Quart", targets, results, now: datetime) -> None:
from steward.core.alerts import MetricBatch
from .models import PrometheusTarget
from .scraper import ScrapeError
from .scraper import ScrapeError, TokenRefused
from .service import (IS_UP, OWN_METRICS, SCRAPE_MS, SERIES_CAPPED, SERIES_KEPT, SOURCE,
map_families)
@@ -132,6 +132,7 @@ async def record(app: "Quart", targets, results, now: datetime) -> None:
if not isinstance(result, ScrapeError):
logger.warning("Prometheus scrape of %s failed: %r", target.name, result)
row.last_error = str(result) or "The scrape failed."
row.refused = isinstance(result, TokenRefused)
batch.add(source_module=SOURCE, resource_name=row.name, metric_name=IS_UP,
value=0.0, recorded_at=now)
continue
+7 -2
View File
@@ -18,6 +18,11 @@ class ScrapeError(Exception):
"""Why a scrape got nothing, said to the person."""
class TokenRefused(ScrapeError):
"""The endpoint refused the token. Asking again won't change that, and
Home Assistant, for one, bans an address after a few refusals."""
async def scrape(url: str, token: str = "", verify_tls: bool = True,
transport: httpx.AsyncBaseTransport | None = None) -> dict[str, prometheus.Family]:
"""The endpoint's families. `transport` stands in for the network in tests."""
@@ -29,8 +34,8 @@ async def scrape(url: str, token: str = "", verify_tls: bool = True,
transport=transport, follow_redirects=False) as client:
async with client.stream("GET", url, headers=headers) as response:
if response.status_code in (401, 403):
raise ScrapeError(f"Answered {response.status_code}: the token was refused. "
"Check the token, or make a new one on the service.")
raise TokenRefused(f"Answered {response.status_code}: the token was refused. "
"Check the token, or make a new one on the service.")
if response.status_code != 200:
raise ScrapeError(f"Answered {response.status_code} {response.reason_phrase}, not metrics.")
body = bytearray()
+28 -14
View File
@@ -122,13 +122,23 @@ def fold_states(metric: str, rows: list[dict]) -> list[dict] | None:
return folded
# A series named for a moment holds Unix seconds, not a span of time: the
# convention's *_timestamp_seconds, and the *_time_seconds some exporters use
# (Home Assistant's homeassistant_last_updated_time_seconds).
MOMENT_SUFFIXES = ("_timestamp_seconds", "_time_seconds")
def is_moment(metric: str) -> bool:
return metric.endswith(MOMENT_SUFFIXES)
def shown_value(metric: str, value: float | None) -> dict:
"""A series' value in its unit, from the Prometheus naming conventions.
A *_timestamp_seconds is a moment: it comes back as `moment` for the page
to show as "3m ago" in the reader's zone; 0 there means "never"."""
A moment (is_moment) comes back as `moment` for the page to show as
"3m ago" in the reader's zone; 0 there means "never"."""
if value is None:
return {"text": "—", "moment": None}
if metric.endswith("_timestamp_seconds"):
if is_moment(metric):
if value <= 0:
return {"text": "never", "moment": None}
return {"text": "", "moment": datetime.fromtimestamp(value, timezone.utc)}
@@ -184,11 +194,12 @@ def map_families(target: str, families: dict[str, prometheus.Family], keep: list
# ── The form ─────────────────────────────────────────────────────────────────
def patterns(text: str) -> list[str]:
"""Name patterns from a textarea: one per line or comma-separated."""
"""Name patterns from a textarea: one per line, or separated by commas or
spaces. A metric name never holds whitespace, so a paste that turned line
breaks into spaces still reads as the patterns it was."""
out: list[str] = []
for line in (text or "").replace(",", "\n").splitlines():
p = line.strip()
if p and p not in out:
for p in (text or "").replace(",", " ").split():
if p not in out:
out.append(p)
return out
@@ -197,11 +208,10 @@ def form_values(target: PrometheusTarget | None) -> dict:
"""The form's fields for an existing target. The token is never sent back
to the browser; a blank token field on save keeps the stored one."""
if target is None:
return {"interval_seconds": 60, "series_cap": 1000, "verify_tls": True, "enabled": True,
"keep": "", "drop": ""}
return {"interval_seconds": 60, "series_cap": 1000, "verify_tls": True, "keep": "", "drop": ""}
return {"name": target.name, "url": target.url, "interval_seconds": target.interval_seconds,
"series_cap": target.series_cap, "verify_tls": target.verify_tls,
"enabled": target.enabled, "keep": "\n".join(target.keep or []),
"keep": "\n".join(target.keep or []),
"drop": "\n".join(target.drop or []), "has_token": bool(target.token)}
@@ -217,7 +227,8 @@ def _bounded(form, key: str, default: int, low: int, high: int, says: str) -> in
def parse_form(form, existing: PrometheusTarget | None = None) -> dict:
"""The submitted form as the columns to store, the token encrypted.
Raises FormError saying what to fix."""
Whether it is paused isn't among them: Pause/Resume sets that, and a new
target starts scraping. Raises FormError saying what to fix."""
name = (form.get("name") or "").strip()
url = (form.get("url") or "").strip()
if not name:
@@ -241,7 +252,7 @@ def parse_form(form, existing: PrometheusTarget | None = None) -> dict:
return {"name": name, "url": url, "token": token,
"verify_tls": bool(form.get("verify_tls")), "interval_seconds": interval,
"keep": patterns(form.get("keep") or ""), "drop": patterns(form.get("drop") or ""),
"series_cap": cap, "enabled": bool(form.get("enabled"))}
"series_cap": cap}
# ── What the newest scrape says ──────────────────────────────────────────────
@@ -253,13 +264,16 @@ STALE_SCRAPES = 3
@dataclass
class TargetState:
reach: str # up | down | stale | pending | off
reach: str # up | down | stale | pending | paused
says: str # one line, in words
def target_state(target: PrometheusTarget, now: datetime) -> TargetState:
if not target.enabled:
return TargetState("off", "Not scraped: turned off.")
return TargetState("paused", "Paused: not scraped until it's resumed.")
if target.refused:
return TargetState("down", f"{target.last_error} Steward stopped asking, as some services ban an "
"address after a few refusals. Save a new token to start again.")
if target.last_scraped_at is None:
return TargetState("pending", "Not scraped yet.")
if (now - target.last_scraped_at).total_seconds() > STALE_SCRAPES * max(target.interval_seconds, 1):

Some files were not shown because too many files have changed in this diff Show More