#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>
1156 lines
51 KiB
Python
1156 lines
51 KiB
Python
# 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.health import ingest as ingest_tally
|
||
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.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 .models import HostAgentRegistration
|
||
from .scheduler import arrivals
|
||
# 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])
|
||
# Load per core as a %, the figure the pages grade, so it can learn a
|
||
# normal (#5742): a load of 6 is idle on 32 cores and busy on 4.
|
||
if "1m" in load and sample.get("cpu_cores"):
|
||
row("load_pct", host_name, 100.0 * load["1m"] / len(sample["cpu_cores"]))
|
||
|
||
if sample.get("uptime_secs") is not None:
|
||
row("uptime_secs", host_name, sample["uptime_secs"])
|
||
|
||
# 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
|
||
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 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)
|
||
|
||
# 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 []
|
||
# 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()
|
||
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")
|
||
# The agent reached us: that holds even if storing its post fails (#5514).
|
||
arrivals(current_app)[reg.host_id] = received_at
|
||
|
||
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 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
|
||
# 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")
|
||
|
||
# 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)
|
||
|
||
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])
|
||
ingest_tally.done(phases.elapsed_ms(), ok=False)
|
||
raise
|
||
except Exception:
|
||
ingest_tally.done(phases.elapsed_ms(), ok=False)
|
||
raise
|
||
# Counted for Steward's own health (#5467). The early returns above (a
|
||
# bad token, no valid samples) answer the agent and aren't counted.
|
||
ingest_tally.done(phases.elapsed_ms())
|
||
|
||
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 elapsed_ms(self) -> float:
|
||
return (time.monotonic() - self.start) * 1000.0
|
||
|
||
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 <canvas> 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("/<host_id>/")
|
||
@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("/<host_id>/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("/<host_id>/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/<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.
|
||
|
||
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 {}
|
||
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,
|
||
# 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")
|
||
# 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.
|
||
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")
|
||
thresholds = current_app.config.get("THRESHOLDS", {})
|
||
worst = worst_reading((("CPU", cpu, "cpu"), ("Memory", mem, "mem"), ("Disk /", disk_root, "disk"),
|
||
("Inodes on /", inodes_root, "disk"), ("Load per core", load_per_core, "load")),
|
||
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": 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"))
|
||
if grades[kind][1]]
|
||
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,
|
||
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", {})),
|
||
)
|
||
|
||
|
||
@host_agent_bp.get("/panel/<host_id>")
|
||
@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()
|
||
downtime = await recent_downtime(db, host_id, datetime.now(timezone.utc)) if reg else []
|
||
|
||
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, downtime=downtime,
|
||
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/<host_id>/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/<host_id>/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/<host_id>/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))
|