# plugins/host_agent/routes.py from __future__ import annotations import asyncio import hashlib import time from datetime import datetime, timezone from typing import Any from pathlib import Path import secrets from quart import Blueprint, current_app, jsonify, request, Response, render_template, redirect, url_for, session from steward.auth.middleware import public_route, require_role from steward.models.users import UserRole from sqlalchemy import select, or_, and_ from datetime import timedelta from steward.core.formatting import worst_reading from steward.core.metric_series import points as metric_points from steward.core.settings import get_setting, public_base_url from steward.core.time_range import parse_range, RANGE_OPTIONS from steward.models.hosts import Host from steward.models.metrics import PluginMetric from .models import HostAgentRegistration # Query helpers live in a model-free module so integration tests can import them # without the plugin-loader double-registration guard tripping (see metrics_query). from .metrics_query import ( SOURCE_MODULE, _history_for_host, _latest_metrics_for_host, ) host_agent_bp = Blueprint("host_agent", __name__, template_folder="templates") def _hash_token(raw: str) -> str: return hashlib.sha256(raw.encode("utf-8")).hexdigest() def _parse_ts(ts: str) -> datetime: if ts.endswith("Z"): ts = ts[:-1] + "+00:00" return datetime.fromisoformat(ts) def clock_offset(sent_at, received_at: datetime) -> float | None: """Seconds this host's clock is ahead of Steward's (negative: behind), from the agent's sent_at and the moment Steward received the post. It carries the request's own latency, well under a second on a LAN. None when the post has no usable sent_at (an agent older than 1.12.0).""" if not isinstance(sent_at, str): return None try: sent = _parse_ts(sent_at) except ValueError: return None if sent.tzinfo is None: return None return round((sent - received_at).total_seconds(), 2) def clock_words(offset: float | None, thresholds: dict) -> tuple[str, str]: """('clock 0.3s ahead', 'ok') or ('clock 2m 10s behind', 'warn'): how far a host's clock is from Steward's, graded by Settings → Thresholds' clock drift, either way.""" from steward.core.formatting import human_duration, status_class if offset is None: return "", "unknown" off = abs(offset) words = "clock in step" if off < 1 else \ f"clock {human_duration(off)} {'ahead' if offset > 0 else 'behind'}" return words, status_class(off, "clock", thresholds) def pick_host_address(current: str | None, reported: str | None) -> str | None: """Return the address to store on the Host, or None for no change. The agent-reported IP fills Host.address only when the current value is blank — an admin-typed address (DNS name, management IP) is never overwritten. The reported IP is always kept on the registration regardless, so admins can still see drift. """ if reported and not (current or "").strip(): return reported return None def _expand_sample_to_metrics( sample: dict, host_name: str, recorded_at: datetime ) -> list[PluginMetric]: rows: list[PluginMetric] = [] def row(metric: str, resource: str, value: float) -> None: rows.append(PluginMetric( source_module=SOURCE_MODULE, resource_name=resource, metric_name=metric, value=float(value), recorded_at=recorded_at, )) if sample.get("cpu_pct") is not None: row("cpu_pct", host_name, sample["cpu_pct"]) mem = sample.get("mem") or {} if mem.get("total_bytes"): total = mem["total_bytes"] available = mem.get("available_bytes", 0) used_pct = 100.0 * (total - available) / total if total else 0.0 row("mem_used_pct", host_name, used_pct) row("mem_available_bytes", host_name, available) row("swap_used_bytes", host_name, mem.get("swap_used_bytes", 0)) load = sample.get("load") or {} for key in ("1m", "5m", "15m"): if key in load: row(f"load_{key}", host_name, load[key]) if sample.get("uptime_secs") is not None: row("uptime_secs", host_name, sample["uptime_secs"]) worst_pct = 0.0 for disk in sample.get("storage") or []: total = disk.get("total_bytes", 0) used = disk.get("used_bytes", 0) pct = 100.0 * used / total if total else 0.0 mount_res = f"{host_name}:{disk['mount']}" row("disk_used_pct", mount_res, pct) row("disk_used_bytes", mount_res, used) row("disk_total_bytes", mount_res, total) if pct > worst_pct: worst_pct = pct if sample.get("storage"): row("disk_used_pct_worst", host_name, worst_pct) # 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 []): if core_pct is not None: row("cpu_pct", f"{host_name}:core{i}", core_pct) # Richer memory breakdown. if mem.get("cached_bytes") is not None: row("mem_cached_bytes", host_name, mem["cached_bytes"]) if mem.get("buffers_bytes") is not None: row("mem_buffers_bytes", host_name, mem["buffers_bytes"]) # Network throughput — per interface plus a host-level total for the fleet view. net_rx_total = net_tx_total = 0.0 for iface in sample.get("net") or []: rx, tx = iface.get("rx_bps", 0.0), iface.get("tx_bps", 0.0) res = f"{host_name}:net:{iface['iface']}" row("net_rx_bps", res, rx) row("net_tx_bps", res, tx) net_rx_total += rx net_tx_total += tx if sample.get("net"): row("net_rx_bps", host_name, net_rx_total) row("net_tx_bps", host_name, net_tx_total) # Disk I/O — per device plus host-level total. dr_total = dw_total = 0.0 for dev in sample.get("diskio") or []: rd, wr = dev.get("read_bps", 0.0), dev.get("write_bps", 0.0) res = f"{host_name}:diskio:{dev['device']}" row("disk_read_bps", res, rd) row("disk_write_bps", res, wr) dr_total += rd dw_total += wr if sample.get("diskio"): 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. max_temp: float | None = None 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: max_temp = c if max_temp is not None: row("temp_c_max", host_name, max_temp) # 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)): row(f"psi_{k}", host_name, v) return rows def _error(status: int, code: str, detail: str | None = None) -> tuple[Any, int]: body: dict = {"ok": False, "error": code} if detail: body["detail"] = detail return jsonify(body), status @host_agent_bp.post("/ingest") @public_route("the agent authenticates with its bearer token") async def ingest(): # Taken first, so the clock offset carries as little of this handler as it can. received_at = datetime.now(timezone.utc) auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer "): return _error(401, "invalid_token") raw = auth[len("Bearer "):].strip() if not raw: return _error(401, "invalid_token") try: payload = await request.get_json(force=True) except Exception: return _error(400, "malformed_payload", "invalid JSON") if not isinstance(payload, dict) or "samples" not in payload: return _error(400, "malformed_payload", "missing 'samples'") samples = payload.get("samples") or [] if not isinstance(samples, list) or not samples: return _error(400, "malformed_payload", "'samples' must be a non-empty list") token_hash = _hash_token(raw) phases = _Phases() samples, dropped = newest_samples(samples, MAX_SAMPLES_PER_INGEST) if dropped: current_app.logger.warning( "host_agent ingest: kept the newest %d of %d samples for token=%s…", len(samples), len(samples) + dropped, token_hash[:8]) try: async with current_app.db_sessionmaker() as session: reg = (await session.execute( select(HostAgentRegistration).where( HostAgentRegistration.token_hash == token_hash) )).scalar_one_or_none() if reg is None: return _error(401, "invalid_token") host = (await session.execute( select(Host).where(Host.id == reg.host_id) )).scalar_one_or_none() if host is None: return _error(401, "invalid_token") accepted = 0 metric_rows: list[PluginMetric] = [] latest_ts: datetime | None = None docker_snapshots: list[tuple[datetime, list]] = [] docker_log_batches: list[tuple[datetime, list]] = [] latest_swarm: dict | None = None latest_swarm_ts: datetime | None = None latest_disk: dict | None = None latest_disk_ts: datetime | None = None # Whether Docker answered, from the newest sample that says (agent ≥ 1.9.0). docker_status: dict | None = None for sample in samples: try: recorded_at = _parse_ts(sample["ts"]) except (KeyError, ValueError): continue metric_rows.extend(_expand_sample_to_metrics(sample, host.name, recorded_at)) # An empty list is a real answer: Docker is up with no containers. docker = sample.get("docker") if isinstance(docker, list): docker_snapshots.append((recorded_at, docker)) status = sample.get("docker_status") if status in ("ok", "unavailable", "error") and ( docker_status is None or recorded_at > docker_status["at"]): docker_status = { "status": status, "detail": sample.get("docker_detail"), "at": recorded_at, "swarm_manager": sample.get("swarm_manager")} # Container logs are time-series (append every line, not newest-only); # each record carries its own Docker ts, recorded_at is the fallback. docker_logs = sample.get("docker_logs") if isinstance(docker_logs, list) and docker_logs: docker_log_batches.append((recorded_at, docker_logs)) # Swarm is current-state, not time-series — keep only the newest # sample's topology (a manager re-reports it every interval). swarm = sample.get("swarm") if isinstance(swarm, dict) and ( latest_swarm_ts is None or recorded_at > latest_swarm_ts): latest_swarm, latest_swarm_ts = swarm, recorded_at # Disk usage is current-state too — keep only the newest sample's. disk = sample.get("docker_disk") if isinstance(disk, dict) and ( latest_disk_ts is None or recorded_at > latest_disk_ts): latest_disk, latest_disk_ts = disk, recorded_at accepted += 1 if latest_ts is None or recorded_at > latest_ts: latest_ts = recorded_at if accepted == 0: 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 # alert rule reads "> 30" (milestone 490). offset = clock_offset(payload.get("sent_at"), received_at) if offset is not None: metric_rows.append(PluginMetric( source_module=SOURCE_MODULE, resource_name=host.name, metric_name="clock_off_secs", value=abs(offset), recorded_at=received_at)) phases.mark("parse") # Through the alert pipeline, so rules on host metrics can fire. from steward.core.alerts import record_metrics phases.size = f"samples={accepted} metric_rows={len(metric_rows)}" await record_metrics(session, metric_rows, phases) # Hand host-scoped container data to the docker plugin if it's enabled # (opportunistic synergy via the capability registry — no hard import, # no-op when docker is disabled). A failure here must never sink the # whole ingest, so the metrics above still land. if (docker_snapshots or latest_swarm is not None or docker_status is not None or latest_disk is not None or docker_log_batches): from steward.core.capabilities import has_capability, invoke_capability if has_capability("docker.persist_host_samples"): try: # SAVEPOINT so a docker-side failure rolls back only the # docker writes, leaving the host metrics intact to commit. async with session.begin_nested(): await invoke_capability( "docker.persist_host_samples", UserRole.admin, session, host, docker_snapshots, latest_swarm, latest_disk, docker_log_batches, docker_status, phases=phases, ) except Exception: current_app.logger.exception( "host_agent ingest: docker persist failed for host=%s", host.name) phases.mark("docker") md = payload.get("metadata") or {} changed = False if latest_ts and (reg.last_seen_at is None or latest_ts > reg.last_seen_at): reg.last_seen_at = latest_ts changed = True version = payload.get("agent_version") if version and reg.agent_version != version: reg.agent_version = version changed = True for field in ("kernel", "distro", "arch"): if field in md and getattr(reg, field) != md[field]: setattr(reg, field, md[field]) changed = True host_ip = md.get("host_ip") if host_ip and reg.host_ip != host_ip: reg.host_ip = host_ip 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: host.address = new_address if changed: reg.updated_at = datetime.now(timezone.utc) if offset is not None: reg.clock_offset_s, reg.clock_checked_at = offset, received_at clock_th = current_app.config.get("THRESHOLDS", {}).get("clock") or {"warn": 30} if abs(offset) > clock_th["warn"]: current_app.logger.warning( "host_agent ingest: host=%s clock is %.1fs %s Steward's", host.name, abs(offset), "ahead of" if offset > 0 else "behind") await session.commit() phases.mark("commit") except asyncio.CancelledError: # The agent hung up (its 10 s timeout) and the server dropped the # request: say how far it got before re-raising. phases.cancelled(token_hash[:8]) raise 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 # The agent gives up on a post after 10 s (agent.py); an ingest past this # says where its time went, so a slow phase shows in the logs (#5149). SLOW_INGEST_SECONDS = 2.0 # How many samples one ingest writes (#5149). An agent that could not post # sends its whole buffer (up to 21 samples, ~4,000 metric rows); on the live # instance that did not finish inside the agent's 10 s, the rollback left the # buffer full, and every later post failed the same way. The newest samples # are kept: the older ones would be lost to the agent's ring buffer anyway. # Five matches the agent's MAX_SAMPLES_PER_POST (1.10+), so its backlog posts # land whole: ~1,000 metric rows at the 1,300-4,000 rows/s measured once # plugin_metrics got time-ordered ids, with posts answering in 0.4-1.3 s (#5181). MAX_SAMPLES_PER_INGEST = 5 def newest_samples(samples: list, limit: int) -> tuple[list, int]: """(the newest `limit` samples, oldest first; how many were left out). Ordered by their ISO-8601 `ts` (the agent writes them all in one form); one without a ts sorts oldest and is rejected later as malformed.""" if len(samples) <= limit: return samples, 0 ordered = sorted(samples, key=lambda s: str(s.get("ts") or "") if isinstance(s, dict) else "") return ordered[-limit:], len(samples) - limit class _Phases: """Wall-clock per phase of one ingest request.""" def __init__(self): self.start = self.last = time.monotonic() self.took: dict[str, float] = {} self.size = "" def mark(self, name: str) -> None: now = time.monotonic() self.took[name] = self.took.get(name, 0.0) + (now - self.last) self.last = now def cancelled(self, who: str) -> None: current_app.logger.warning( "host_agent ingest: dropped after %.1fs for token=%s… (completed: %s) %s", time.monotonic() - self.start, who, " ".join(f"{k}={v:.1f}s" for k, v in self.took.items()) or "nothing", self.size) def report(self, host: str, samples: int, metrics: int, containers: int, log_lines: int) -> None: total = time.monotonic() - self.start if total < SLOW_INGEST_SECONDS: return current_app.logger.warning( "host_agent ingest: slow %.1fs for host=%s (%s) samples=%d metric_rows=%d " "containers=%d log_lines=%d", total, host, " ".join(f"{k}={v:.1f}s" for k, v in self.took.items()), samples, metrics, containers, log_lines) AGENT_SOURCE_PATH = Path(__file__).parent / "agent.py" def _agent_version() -> str: try: for line in AGENT_SOURCE_PATH.read_text().splitlines(): if line.startswith("AGENT_VERSION"): return line.split("=", 1)[1].strip().strip('"').strip("'") except OSError: pass return "unknown" @host_agent_bp.get("/install.sh") @public_route("fetched by curl on the host being enrolled; carries an enrolment token") async def install_script(): token = request.args.get("token", "") if not token: return _error(401, "invalid_token") async with current_app.db_sessionmaker() as session: reg = (await session.execute( select(HostAgentRegistration).where( HostAgentRegistration.token_hash == _hash_token(token)) )).scalar_one_or_none() if reg is None: return _error(401, "invalid_token") host = (await session.execute( select(Host).where(Host.id == reg.host_id) )).scalar_one_or_none() if host is None: return _error(401, "invalid_token") url = public_base_url(request) rendered = await render_template( "install.sh.j2", url=url, token=token, agent_version=_agent_version(), host_name=host.name, host_address=host.address, ) return Response(rendered, mimetype="text/plain") @host_agent_bp.get("/agent.py") @public_route("fetched by the installer and by agent self-update; holds no secrets") async def agent_source(): try: body = AGENT_SOURCE_PATH.read_text(encoding="utf-8") except OSError: return _error(500, "agent_missing") return Response(body, mimetype="text/x-python") _FLEET_ORDER = {"stale": 0, "pending": 1, "up": 2} def fleet_state(reg, now: datetime, stale_after: int) -> str: """An agent as the fleet table shows it: "up" while it reports, "stale" once its last report is older than stale_after, "pending" if it never has.""" if reg.last_seen_at is None: return "pending" return "stale" if (now - reg.last_seen_at).total_seconds() > stale_after else "up" def fleet_rows(regs, hosts_by_id: dict, now: datetime, stale_after: int) -> list[dict]: """The fleet table: silent agents first, then never-reported, then live, each by host name.""" rows = [] for r in regs: host = hosts_by_id.get(r.host_id) rows.append({"reg": r, "host": host, "name": host.name if host else r.host_id, "state": fleet_state(r, now, stale_after)}) return sorted(rows, key=lambda x: (_FLEET_ORDER[x["state"]], x["name"].lower())) def _stale_after_seconds() -> int: return int( current_app.config.get("PLUGINS", {}) .get(SOURCE_MODULE, {}) .get("stale_after_seconds", 180) ) @host_agent_bp.get("/widget/history") @require_role(UserRole.viewer) async def widget_history(): host_id = request.args.get("host_id", "") try: hours = max(1, min(168, int(request.args.get("hours", "6")))) except ValueError: hours = 6 # wid (the dashboard widget row id) keeps the id unique when several # history widgets share a page; fall back to the host id. wid = request.args.get("wid", "") or host_id cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) series: dict[str, list[list]] = {"cpu_pct": [], "mem_used_pct": [], "disk_root": []} host = None if host_id: 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 not None: points = (await session.execute( select(PluginMetric).where( PluginMetric.source_module == SOURCE_MODULE, PluginMetric.recorded_at >= cutoff, or_( and_(PluginMetric.resource_name == host.name, PluginMetric.metric_name.in_(("cpu_pct", "mem_used_pct"))), and_(PluginMetric.resource_name == host.name + ":/", PluginMetric.metric_name == "disk_used_pct"), ), ).order_by(PluginMetric.recorded_at) )).scalars().all() # Disk = root (/), consistent with the host panel — not "worst". # Epoch-ms x values let the chart use a plain linear axis (no # Chart.js date adapter needed), matching the host-detail charts. for p in points: key = "disk_root" if p.resource_name != host.name else p.metric_name series[key].append([int(p.recorded_at.timestamp() * 1000), round(p.value, 2)]) # Downsample: the agent reports every few seconds, so a multi-hour window is # hundreds–thousands of points — a noisy, unreadable line. Bucket-average to a # readable point count, keeping the shape. series = {k: _downsample(v) for k, v in series.items()} # Stable y-axis ceiling. All three series are percentages, so snap the top up # to the next 10% band (floor 20, cap 100). Auto-scaling to the exact peak made # the chart "zoom" jump on every poll as the peak drifted a few %; snapping to # a band keeps the axis steady across refreshes while still filling the panel. peak = max((p[1] for s in series.values() for p in s), default=0.0) y_max = min(100, max(20, ((int(peak) // 10) + 1) * 10)) return await render_template( "widget_history.html", host=host, series=series, hours=hours, wid=wid, y_max=y_max, ) def _downsample(series: list[list], target: int = 120) -> list[list]: """Bucket-average a time series [[epoch_ms, value], …] down to ~target points. Consecutive samples are grouped into equal buckets and averaged (x = bucket midpoint). Cheap, order-preserving, and smooths the high-frequency noise that makes dense agent series hard to read. """ n = len(series) if n <= target: return series bucket = (n + target - 1) // target out: list[list] = [] for i in range(0, n, bucket): chunk = series[i:i + bucket] mid = chunk[len(chunk) // 2][0] avg = sum(p[1] for p in chunk) / len(chunk) out.append([mid, round(avg, 2)]) return out # Full-metrics refresh cadences. The agent pushes every ~30s by default, so # polling the current snapshot at 15s keeps it ≤ one cadence stale; the history # charts move slowly over a multi-hour/day window, so they refresh less often. _DETAIL_POLL_SECONDS = 15 _CHART_POLL_SECONDS = 60 def _split_host_metrics(host_name: str, latest: dict) -> tuple: """Split a host's latest snapshot into (hostlvl, cores, nets, disks_io, temps, mounts) for the current-state fragment.""" hostlvl = latest.get(host_name, {}) cores: list = [] nets: dict = {} disks_io: dict = {} temps: dict = {} mounts: dict = {} plen = len(host_name) + 1 for res, metrics in latest.items(): if res == host_name: continue suffix = res[plen:] if suffix.startswith("core"): try: idx = int(suffix[4:]) except ValueError: idx = 0 cores.append((idx, metrics.get("cpu_pct"))) elif suffix.startswith("net:"): nets[suffix[len("net:"):]] = metrics elif suffix.startswith("diskio:"): disks_io[suffix[len("diskio:"):]] = metrics elif suffix.startswith("temp:"): temps[suffix[len("temp:"):]] = metrics.get("temp_c") else: mounts[suffix] = metrics cores.sort(key=lambda c: c[0]) return hostlvl, cores, nets, disks_io, temps, mounts @host_agent_bp.get("//") @require_role(UserRole.viewer) async def host_detail(host_id: str): """Full-metrics shell: header + range toggle only. The current-state and history sections stream in via HTMX (host_detail_metrics / host_detail_charts) so the heavy history query never blocks first paint and the data live-updates.""" _, 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 _error(404, "not_found") return await render_template( "host_detail.html", host=host, current_range=range_key, range_options=RANGE_OPTIONS, poll_seconds=_DETAIL_POLL_SECONDS, chart_poll_seconds=_CHART_POLL_SECONDS, ) @host_agent_bp.get("//metrics") @require_role(UserRole.viewer) async def host_detail_metrics(host_id: str): """Current-state fragment (gauges/cores/filesystems/IO/temps) — polled live so the page shows the latest snapshot the server holds without a full reload.""" 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 reg = (await session.execute(select(HostAgentRegistration).where( 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) 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, ) @host_agent_bp.get("//charts") @require_role(UserRole.viewer) async def host_detail_charts(host_id: str): """History-charts fragment — lazy-loaded so the date_bin query never blocks the page shell; refreshed on a slow cadence and on range change.""" 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) series = await _history_for_host(session, host.name, since, raw_days=raw_days) return await render_template("_host_charts.html", series=series, range_key=range_key) def _new_token_pair() -> tuple[str, str]: raw = secrets.token_urlsafe(32) return raw, _hash_token(raw) @host_agent_bp.get("/vitals/") @require_role(UserRole.viewer) async def host_vitals(host_id: str): """Compact live vitals strip (CPU/MEM/DISK/LOAD + 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. """ async with current_app.db_sessionmaker() as db: host = (await db.execute( select(Host).where(Host.id == host_id))).scalar_one_or_none() if host is None: return "", 404 reg = (await db.execute(select(HostAgentRegistration).where( HostAgentRegistration.host_id == host_id))).scalar_one_or_none() latest = await _latest_metrics_for_host(db, host.name) if reg else {} # The last 6 h for the at-a-glance sparklines (cpu/mem/load 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": []} 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"), ("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)] from steward.core.status import sparkline_svg hostlvl = latest.get(host.name, {}) # 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") 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. cores = sum(1 for k in latest if k.startswith(host.name + ":core")) load1 = hostlvl.get("load_1m") load_per_core = round(load1 / cores * 100) if (cores and load1 is not None) else None psi = { "cpu": hostlvl.get("psi_cpu_some_avg10"), "mem": hostlvl.get("psi_mem_some_avg10"), "io": hostlvl.get("psi_io_some_avg10"), } ls = reg.last_seen_at if reg else None 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") worst = worst_reading((("CPU", cpu, "cpu"), ("Memory", mem, "mem"), ("Disk /", disk_root, "disk"), ("Load per core", load_per_core, "load")), current_app.config.get("THRESHOLDS", {})) return await render_template( "_host_vitals.html", reg=reg, cpu=cpu, mem=mem, disk_root=disk_root, load_per_core=load_per_core, sparks=sparks, psi=psi, reporting=reporting, stale=stale, worst=worst, clock=clock_words(reg.clock_offset_s if reg else None, current_app.config.get("THRESHOLDS", {})), ) @host_agent_bp.get("/panel/") @require_role(UserRole.viewer) async def host_panel(host_id: str): """Per-host agent management panel embedded into the Hosts hub via HTMX. Lifecycle actions (update / rotate / remove / re-provision) when the host reports, otherwise provisioning — tied to the host's linked Ansible target. The live vitals are a separate strip (host_vitals); this panel is management only. Self-contained fragment (no base.html) so it can be hx-swapped in. """ from steward.core.capabilities import has_capability from steward.models.ansible_inventory import AnsibleTarget async with current_app.db_sessionmaker() as db: host = (await db.execute( select(Host).where(Host.id == host_id))).scalar_one_or_none() if host is None: return "", 404 reg = (await db.execute(select(HostAgentRegistration).where( 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() ls = reg.last_seen_at if reg else None # "Deployed" is gated on the agent actually checking in (last_seen_at), NOT on # the registration row — that row is minted before the playbook runs, so a # failed provision would otherwise look deployed. reporting = bool(reg) and ls is not None stale = reporting and (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds() ansible_cfg = current_app.config.get("ANSIBLE", {}) return await render_template( "panel.html", host=host, reg=reg, reporting=reporting, stale=stale, target=target, ansible_available=has_capability("ansible.run_playbook"), managed_key_set=bool((ansible_cfg.get("ssh_public_key") or "").strip()), ) @host_agent_bp.get("/fleet/") @require_role(UserRole.admin) async def fleet(): from steward.core.capabilities import has_capability from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup ansible_available = has_capability("ansible.run_playbook") async with current_app.db_sessionmaker() as db: regs = (await db.execute(select(HostAgentRegistration))).scalars().all() hosts_by_id = { h.id: h for h in (await db.execute( select(Host).where(Host.id.in_([r.host_id for r in regs])))).scalars().all() } if regs else {} targets, groups = [], [] if ansible_available: targets = (await db.execute( select(AnsibleTarget).order_by(AnsibleTarget.name))).scalars().all() groups = (await db.execute( select(AnsibleGroup).order_by(AnsibleGroup.name))).scalars().all() new_token = request.args.get("new_token") new_host_id = request.args.get("host_id") install_url = None if new_token and new_host_id: install_url = f"{public_base_url(request)}/plugins/host_agent/install.sh?token={new_token}" managed_key_set = bool( (current_app.config.get("ANSIBLE", {}).get("ssh_public_key") or "").strip()) now, stale_after = datetime.now(timezone.utc), _stale_after_seconds() return await render_template( "settings_list.html", registrations=fleet_rows(regs, hosts_by_id, now, stale_after), new_token=new_token, install_url=install_url, ansible_available=ansible_available, deploy_targets=targets, deploy_groups=groups, managed_key_set=managed_key_set, ) @host_agent_bp.post("/fleet//register") @require_role(UserRole.admin) async def register_host(host_id: str): """Issue an agent token for an existing host, for a manual install: the fleet page then shows the one-line install command, once. Hosts are added on Hosts → Add host; this is only the agent half (#5282).""" async with current_app.db_sessionmaker() as session: host = await session.get(Host, host_id) if host is None: return _error(404, "not_found") existing = (await session.execute( select(HostAgentRegistration).where( HostAgentRegistration.host_id == host.id))).scalar_one_or_none() if existing is not None: return _error(400, "already_registered") raw, hashed = _new_token_pair() session.add(HostAgentRegistration(host_id=host.id, token_hash=hashed)) await session.commit() return redirect(url_for("host_agent.fleet") + f"?new_token={raw}&host_id={host_id}") @host_agent_bp.post("/fleet//rotate-token") @require_role(UserRole.admin) async def rotate_token(host_id: str): async with current_app.db_sessionmaker() as session: reg = (await session.execute( select(HostAgentRegistration).where( HostAgentRegistration.host_id == host_id))).scalar_one_or_none() if reg is None: return _error(404, "not_found") raw, hashed = _new_token_pair() reg.token_hash = hashed reg.token_created_at = datetime.now(timezone.utc) await session.commit() return redirect(url_for("host_agent.fleet") + f"?new_token={raw}&host_id={host_id}") @host_agent_bp.post("/fleet//delete") @require_role(UserRole.admin) async def delete_registration(host_id: str): async with current_app.db_sessionmaker() as session: reg = (await session.execute( select(HostAgentRegistration).where( HostAgentRegistration.host_id == host_id))).scalar_one_or_none() if reg is not None: await session.delete(reg) await session.commit() return redirect(url_for("host_agent.fleet")) # ── Deploy via Ansible (plugin↔core synergy via the capability registry) ────── async def _ensure_host_for_target(db, target) -> "Host": """Find or create the Host that an AnsibleTarget should report under.""" if getattr(target, "host_id", None): h = await db.get(Host, target.host_id) if h: return h h = (await db.execute( select(Host).where(Host.name == target.name))).scalar_one_or_none() if h is None: h = Host(name=target.name, address=getattr(target, "address", "") or "") db.add(h) await db.flush() return h async def _mint_registration_token(db, host) -> str: """Create or rotate the host's agent registration; return the raw token. Tokens are stored hashed (unrecoverable), so deploying always mints a fresh token — the run installs the agent with it. Existing agents get rotated. """ reg = (await db.execute(select(HostAgentRegistration).where( HostAgentRegistration.host_id == host.id))).scalar_one_or_none() raw, hashed = _new_token_pair() if reg is None: db.add(HostAgentRegistration(host_id=host.id, token_hash=hashed)) else: reg.token_hash = hashed reg.token_created_at = datetime.now(timezone.utc) return raw @host_agent_bp.post("/deploy") @require_role(UserRole.admin) async def deploy_via_ansible(): """Install/update the agent on Ansible inventory targets via the bundled install playbook — the 'curl | sh becomes a button' synergy. Uses the core "ansible.run_playbook" capability (no hard import of the runner) and injects a freshly-minted per-host token as an inventory hostvar. """ from steward.core.capabilities import has_capability, invoke_capability from steward.ansible.inventory_gen import ( fetch_scope_targets, generate_inventory, inventory_to_yaml) from steward.ansible.sources import BUILTIN_SOURCE_NAME if not has_capability("ansible.run_playbook"): return _error(400, "ansible_unavailable", "Ansible is not available") form = await request.form scope = (form.get("inventory_scope", "") or "").strip() try: interval = max(5, int(form.get("agent_interval", "30"))) except (TypeError, ValueError): interval = 30 if not (scope.startswith("steward:target:") or scope.startswith("steward:group:") or scope == "steward:all"): return _error(400, "bad_scope", "Choose a target or group") url = public_base_url(request) async with current_app.db_sessionmaker() as db: targets = await fetch_scope_targets(db, scope) if not targets: return _error(400, "no_targets", "No Ansible targets in that scope") # The read above autobegins the session transaction (SQLAlchemy 2.0), so # mint into it directly and commit — a nested db.begin() would double-begin. tokens: dict[str, str] = {} for t in targets: host = await _ensure_host_for_target(db, t) tokens[t.name] = await _mint_registration_token(db, host) inv = generate_inventory(targets) # built while ORM objects are still live # Per-host token as a host var; steward_url goes in as an extra-var below. for name, tok in tokens.items(): inv["_meta"]["hostvars"].setdefault(name, {})["steward_token"] = tok inventory_content = inventory_to_yaml(inv) await db.commit() actor_role = UserRole(session.get("user_role", "viewer")) run, _source, err = await invoke_capability( "ansible.run_playbook", actor_role, current_app._get_current_object(), # type: ignore[attr-defined] source_name=BUILTIN_SOURCE_NAME, playbook_path="host_agent/install.yml", inventory_content=inventory_content, inventory_scope=scope, redact=list(tokens.values()), # extra-vars outrank the playbook's `vars:` defaults; token stays per-host. params={"extra_vars_map": {"steward_url": url, "agent_interval": interval}}, triggered_by=session.get("user_id"), ) if err: return _error(400, "deploy_failed", err) return redirect(url_for("ansible.run_detail", run_id=run.id)) @host_agent_bp.post("/provision") @require_role(UserRole.admin) async def provision_via_ansible(): """First-contact provisioning: create the steward account + install the managed key + NOPASSWD sudo, then install the agent — over bootstrap (password) auth. After this, hosts are reachable with the managed key. The bootstrap password is passed to the runner as a connection override and is NOT persisted on the run row. """ from steward.core.capabilities import has_capability, invoke_capability from steward.ansible.inventory_gen import ( fetch_scope_targets, generate_inventory, inventory_to_yaml) from steward.ansible.sources import BUILTIN_SOURCE_NAME if not has_capability("ansible.run_playbook"): return _error(400, "ansible_unavailable", "Ansible is not available") ansible_cfg = current_app.config.get("ANSIBLE", {}) pubkey = (ansible_cfg.get("ssh_public_key") or "").strip() if not pubkey: return _error(400, "no_managed_key", "Generate a managed SSH key in Settings → Ansible first") steward_user = (ansible_cfg.get("ssh_user") or "steward").strip() or "steward" form = await request.form scope = (form.get("inventory_scope", "") or "").strip() boot_user = (form.get("bootstrap_user", "") or "").strip() boot_password = form.get("bootstrap_password", "") or "" try: interval = max(5, int(form.get("agent_interval", "30"))) except (TypeError, ValueError): interval = 30 if not (scope.startswith("steward:target:") or scope.startswith("steward:group:") or scope == "steward:all"): return _error(400, "bad_scope", "Choose a target or group") if not boot_user or not boot_password: return _error(400, "missing_bootstrap", "Bootstrap user and password are required for first contact") url = public_base_url(request) async with current_app.db_sessionmaker() as db: targets = await fetch_scope_targets(db, scope) if not targets: return _error(400, "no_targets", "No Ansible targets in that scope") # The read above autobegins the session transaction (SQLAlchemy 2.0), so # mint into it directly and commit — a nested db.begin() would double-begin. tokens: dict[str, str] = {} for t in targets: host = await _ensure_host_for_target(db, t) tokens[t.name] = await _mint_registration_token(db, host) inv = generate_inventory(targets) # built while ORM objects are still live # Only the per-host token is a host var; the globals go in as extra-vars # below (extra-vars outrank play vars, host vars do not). for name, tok in tokens.items(): inv["_meta"]["hostvars"].setdefault(name, {})["steward_token"] = tok inventory_content = inventory_to_yaml(inv) await db.commit() actor_role = UserRole(session.get("user_role", "viewer")) run, _source, err = await invoke_capability( "ansible.run_playbook", actor_role, current_app._get_current_object(), # type: ignore[attr-defined] source_name=BUILTIN_SOURCE_NAME, playbook_path="host_agent/provision.yml", inventory_content=inventory_content, inventory_scope=scope, redact=list(tokens.values()), # extra_vars_map → a JSON -e @file: highest precedence (beats play vars) # and space-safe (steward_pubkey carries a comment with a space). params={"extra_vars_map": { "steward_url": url, "steward_pubkey": pubkey, "steward_user": steward_user, "agent_interval": interval, }}, triggered_by=session.get("user_id"), connection={"user": boot_user, "password": boot_password}, ) if err: return _error(400, "provision_failed", err) return redirect(url_for("ansible.run_detail", run_id=run.id)) @host_agent_bp.post("/update") @require_role(UserRole.admin) async def update_via_ansible(): """Refresh agent.py on already-installed hosts via the bundled update playbook. Connects as the managed steward account — no token rotation, no config rewrite (the host keeps its existing identity).""" from steward.core.capabilities import has_capability, invoke_capability from steward.ansible.sources import BUILTIN_SOURCE_NAME if not has_capability("ansible.run_playbook"): return _error(400, "ansible_unavailable", "Ansible is not available") form = await request.form scope = (form.get("inventory_scope", "") or "").strip() if not (scope.startswith("steward:target:") or scope.startswith("steward:group:") or scope == "steward:all"): return _error(400, "bad_scope", "Choose a target or group") url = public_base_url(request) actor_role = UserRole(session.get("user_role", "viewer")) run, _source, err = await invoke_capability( "ansible.run_playbook", actor_role, current_app._get_current_object(), # type: ignore[attr-defined] source_name=BUILTIN_SOURCE_NAME, playbook_path="host_agent/update.yml", inventory_scope=scope, params={"extra_vars": [f"steward_url={url}"]}, triggered_by=session.get("user_id"), ) if err: return _error(400, "update_failed", err) return redirect(url_for("ansible.run_detail", run_id=run.id))