feat(homeassistant): it shows system problems and installs updates on a weekly slot, and leaves device readings alone (#6748, #6749, #6747)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Failing after 1m54s
CI / publish (push) Skipped

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>
This commit is contained in:
2026-11-01 14:16:42 -05:00
co-authored by Claude Opus 5.5
parent 4c9ed02652
commit f7a856cb90
31 changed files with 835 additions and 503 deletions
@@ -0,0 +1,30 @@
"""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.
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))
+1 -3
View File
@@ -13,8 +13,7 @@ from steward.models.base import Base
class PrometheusTarget(Base):
"""A /metrics endpoint Steward scrapes, edited on the Prometheus page.
The bearer token is stored encrypted, or `token_from` names the plugin whose
sign-in supplies it at each scrape (its metrics.scrape_token). keep and drop are lists of name
The bearer token is stored encrypted. keep and drop are lists of name
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
@@ -27,7 +26,6 @@ class PrometheusTarget(Base):
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
url: Mapped[str] = mapped_column(String(500), nullable=False)
token: Mapped[str] = mapped_column(Text, nullable=False, default="")
token_from: Mapped[str | None] = mapped_column(String(50), nullable=True)
verify_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
interval_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=60)
keep: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
+5 -12
View File
@@ -12,14 +12,13 @@ from sqlalchemy import delete, func, select, update
from steward.auth.middleware import require_role
from steward.core.audit import log_audit
from steward.core.crypto import decrypt_secret
from steward.core.metric_series import latest_values, points
from steward.core.prometheus import TokenUnavailable
from steward.models.metrics import PluginMetric, PluginMetricLatest
from steward.models.users import UserRole
from . import library
from .models import PrometheusTarget, PrometheusWidgetTemplate
from .scheduler import target_token
from .scraper import ScrapeError, scrape
from .service import (OWN_METRICS, SOURCE, FormError, fold_states, form_values, is_moment, map_families,
parse_form, patterns, shown_value, target_state)
@@ -81,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")), "has_token": bool(target and target.token),
"token_from": target.token_from if target else None}
"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:
@@ -237,15 +235,10 @@ async def test_target():
existing = await db.get(PrometheusTarget, form["target_id"])
url = (form.get("url") or "").strip()
token = (form.get("token") or "").strip()
if not token and existing is not None and existing.token and not form.get("clear_token"):
token = decrypt_secret(existing.token, context=f"prometheus target {existing.name} token")
found: dict = {}
if not token and existing is not None and not form.get("clear_token"):
try:
token = await target_token(current_app, existing, url)
except TokenUnavailable as exc:
found["error"] = str(exc)
if found:
pass
elif not url.startswith(("http://", "https://")):
if not url.startswith(("http://", "https://")):
found["error"] = "Enter the address first: http://… or https://…"
else:
try:
+6 -34
View File
@@ -48,33 +48,14 @@ def is_due(target, now: datetime) -> bool:
return elapsed >= target.interval_seconds - TICK_SECONDS / 2
def _token_source(name: str):
from steward.core.plugin_manager import loaded_plugin_submodule
return loaded_plugin_submodule(name, "metrics")
async def target_token(app: "Quart", target, url: str) -> str:
"""The token to ask `url` with: the target's own, or one from the plugin whose
sign-in it uses. Raises TokenUnavailable when that plugin has none to give."""
from steward.core.crypto import decrypt_secret
from steward.core.prometheus import TokenUnavailable
if not target.token_from:
return decrypt_secret(target.token, context=f"prometheus target {target.name} token")
source = _token_source(target.token_from)
if source is None:
raise TokenUnavailable(f"Its token comes from the {target.token_from} plugin, which isn't turned on.")
return await source.scrape_token(app, url)
async def scrape_due(app: "Quart", scrape=None) -> None:
"""Scrape every due target at once, then write each one's outcome to its
row and its series to the metric tables. `scrape` stands in for
scraper.scrape in tests."""
from steward.core.prometheus import TokenUnavailable
from steward.core.crypto import decrypt_secret
from .models import PrometheusTarget
from .scraper import ScrapeError, TokenRefused
from .scraper import ScrapeError
from .scraper import scrape as fetch
scrape = scrape or fetch
@@ -87,15 +68,11 @@ async def scrape_due(app: "Quart", scrape=None) -> None:
async def one(t):
started = time.monotonic()
async def fetched():
return await scrape(t.url, await target_token(app, t, t.url), t.verify_tls)
token = decrypt_secret(t.token, context=f"prometheus target {t.name} token")
try:
families = await asyncio.wait_for(fetched(), SCRAPE_DEADLINE_SECONDS)
families = await asyncio.wait_for(scrape(t.url, token, t.verify_tls),
SCRAPE_DEADLINE_SECONDS)
return families, (time.monotonic() - started) * 1000
except TokenUnavailable as exc:
return ScrapeError(str(exc)), (time.monotonic() - started) * 1000
except TimeoutError:
return ScrapeError(f"Gave up after {SCRAPE_DEADLINE_SECONDS} s."), \
(time.monotonic() - started) * 1000
@@ -104,11 +81,6 @@ async def scrape_due(app: "Quart", scrape=None) -> None:
results = await asyncio.gather(*(one(t) for t in targets))
await record(app, targets, results, datetime.now(timezone.utc))
# A refused sign-in is the lending plugin's to stop, so one Try again or sign-in
# there starts everything that uses it.
for t, (result, _) in zip(targets, results):
if t.token_from and isinstance(result, TokenRefused) and (source := _token_source(t.token_from)):
await source.token_refused(app)
async def forget_gone(session, target: str, kept: set[tuple[str, str]]) -> None:
@@ -160,7 +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) and not row.token_from
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
-6
View File
@@ -23,10 +23,6 @@ class TokenRefused(ScrapeError):
Home Assistant, for one, bans an address after a few refusals."""
class NoMetricsHere(ScrapeError):
"""The address answered 404: nothing is serving metrics there."""
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."""
@@ -40,8 +36,6 @@ async def scrape(url: str, token: str = "", verify_tls: bool = True,
if response.status_code in (401, 403):
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 == 404:
raise NoMetricsHere(f"Answered 404 {response.reason_phrase}, not metrics.")
if response.status_code != 200:
raise ScrapeError(f"Answered {response.status_code} {response.reason_phrase}, not metrics.")
body = bytearray()
+1 -2
View File
@@ -212,7 +212,7 @@ def form_values(target: PrometheusTarget | None) -> dict:
return {"name": target.name, "url": target.url, "interval_seconds": target.interval_seconds,
"series_cap": target.series_cap, "verify_tls": target.verify_tls,
"keep": "\n".join(target.keep or []),
"drop": "\n".join(target.drop or []), "has_token": bool(target.token), "token_from": target.token_from}
"drop": "\n".join(target.drop or []), "has_token": bool(target.token)}
def _bounded(form, key: str, default: int, low: int, high: int, says: str) -> int:
@@ -250,7 +250,6 @@ def parse_form(form, existing: PrometheusTarget | None = None) -> dict:
else:
token = ""
return {"name": name, "url": url, "token": token,
"token_from": None if typed or existing is None else existing.token_from,
"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}
@@ -37,12 +37,8 @@
<div class="field">
<label class="field-label" for="prom-token">Bearer token</label>
<input class="input" id="prom-token" name="token" type="password" autocomplete="new-password"
placeholder="{{ 'from a sign-in; type to change' if v.token_from else ('kept; type to change' if v.has_token else 'none') }}">
{% if v.token_from %}
<span class="field-hint">Comes from the sign-in on the {{ v.token_from }} plugin's page, so that one user covers this too. Typing a token here uses that instead.</span>
{% else %}
placeholder="{{ 'kept; type to change' if v.has_token else 'none' }}">
<span class="field-hint">Sent as "Authorization: Bearer …". Blank {{ "keeps the saved one" if v.has_token else "sends none" }}.</span>
{% endif %}
{% if v.has_token %}
<label style="display:flex;gap:0.5rem;align-items:center;margin-top:0.3rem;cursor:pointer;">
<input type="checkbox" name="clear_token" value="on"> <span class="field-hint">Remove the saved token</span>