feat(homeassistant): it shows system problems and installs updates on a weekly slot, and leaves device readings alone (#6748, #6749, #6747)
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:
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user