feat(homeassistant): its page offers the metrics scrape under the same sign-in, so one user covers everything (#6744)
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>
This commit is contained in:
@@ -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")
|
||||
@@ -13,7 +13,8 @@ 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. keep and drop are lists of name
|
||||
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
|
||||
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
|
||||
@@ -26,6 +27,7 @@ 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,13 +12,14 @@ 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)
|
||||
@@ -80,7 +81,8 @@ 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)}
|
||||
"verify_tls": bool(form.get("verify_tls")), "has_token": bool(target and target.token),
|
||||
"token_from": target.token_from if target else None}
|
||||
|
||||
|
||||
async def _name_taken(db, name: str, other_than: str | None = None) -> bool:
|
||||
@@ -235,10 +237,15 @@ 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 url.startswith(("http://", "https://")):
|
||||
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://")):
|
||||
found["error"] = "Enter the address first: http://… or https://…"
|
||||
else:
|
||||
try:
|
||||
|
||||
@@ -48,14 +48,33 @@ 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.crypto import decrypt_secret
|
||||
from steward.core.prometheus import TokenUnavailable
|
||||
|
||||
from .models import PrometheusTarget
|
||||
from .scraper import ScrapeError
|
||||
from .scraper import ScrapeError, TokenRefused
|
||||
from .scraper import scrape as fetch
|
||||
|
||||
scrape = scrape or fetch
|
||||
@@ -68,11 +87,15 @@ async def scrape_due(app: "Quart", scrape=None) -> None:
|
||||
|
||||
async def one(t):
|
||||
started = time.monotonic()
|
||||
token = decrypt_secret(t.token, context=f"prometheus target {t.name} token")
|
||||
|
||||
async def fetched():
|
||||
return await scrape(t.url, await target_token(app, t, t.url), t.verify_tls)
|
||||
|
||||
try:
|
||||
families = await asyncio.wait_for(scrape(t.url, token, t.verify_tls),
|
||||
SCRAPE_DEADLINE_SECONDS)
|
||||
families = await asyncio.wait_for(fetched(), 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
|
||||
@@ -81,6 +104,11 @@ 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:
|
||||
@@ -132,7 +160,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)
|
||||
row.refused = isinstance(result, TokenRefused) and not row.token_from
|
||||
batch.add(source_module=SOURCE, resource_name=row.name, metric_name=IS_UP,
|
||||
value=0.0, recorded_at=now)
|
||||
continue
|
||||
|
||||
@@ -23,6 +23,10 @@ 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."""
|
||||
@@ -36,6 +40,8 @@ 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)}
|
||||
"drop": "\n".join(target.drop or []), "has_token": bool(target.token), "token_from": target.token_from}
|
||||
|
||||
|
||||
def _bounded(form, key: str, default: int, low: int, high: int, says: str) -> int:
|
||||
@@ -250,6 +250,7 @@ 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,8 +37,12 @@
|
||||
<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="{{ 'kept; type to change' if v.has_token else 'none' }}">
|
||||
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 %}
|
||||
<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