feat(timeline): record a change by hand from the Timeline, a host's page or MCP log_change (#6350)
A change Steward can't see happen (a config edited, a disk swapped) is recorded by whoever made it: what was done, its area, when (in the reader's own zone), the hosts, what it was done to and a detail. Its author or an admin edits or deletes it; recording, editing and deleting are audited and those audit rows stay off the timeline. log_change records one as "<user> via MCP", editable by the token's user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,39 @@
|
||||
"""The timeline: everything that happened to the infrastructure, in one list."""
|
||||
"""The timeline: everything that happened to the infrastructure, in one
|
||||
list, and changes recorded on it by hand."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from steward.mcp._context import current_caller
|
||||
from steward.models.timeline import TimelineEntry
|
||||
from steward.models.users import UserRole
|
||||
from steward.timeline import catalog, config as tl_config, service as tl
|
||||
from steward.timeline import catalog, changes, config as tl_config, service as tl
|
||||
|
||||
from ._audit import audit_mcp, mcp_username
|
||||
from ._shape import age_s, bounded, clamp_limit, db_session, iso, lookback
|
||||
|
||||
|
||||
def _entry(v: dict) -> dict:
|
||||
e = v["entry"]
|
||||
return {
|
||||
"entry_id": e.id,
|
||||
"at": iso(e.at),
|
||||
"age_s": age_s(e.at),
|
||||
"until": iso(e.until),
|
||||
"type": e.type,
|
||||
"kind": v["kind"],
|
||||
"area": v["area"],
|
||||
"summary": e.summary,
|
||||
"note": v["note"],
|
||||
"hosts": e.hosts,
|
||||
"subject": e.subject,
|
||||
"by": e.actor,
|
||||
"url": v["url"],
|
||||
"details_kept": not v["gone"],
|
||||
"ref": {"type": e.ref_type, "id": e.ref_id} if e.ref_type else None,
|
||||
}
|
||||
|
||||
|
||||
async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str = "",
|
||||
range: str = "7d", include_hidden: bool = False, limit: int = 50) -> dict:
|
||||
"""What happened to the infrastructure, newest first, from every source in
|
||||
@@ -48,25 +74,7 @@ async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str
|
||||
rows = (await db.execute(stmt.order_by(TimelineEntry.at.desc(), TimelineEntry.id.desc())
|
||||
.limit(limit + 1))).scalars().all()
|
||||
items = await tl.views(db, cfg, rows)
|
||||
entries = [
|
||||
{
|
||||
"at": iso(v["entry"].at),
|
||||
"age_s": age_s(v["entry"].at),
|
||||
"until": iso(v["entry"].until),
|
||||
"type": v["entry"].type,
|
||||
"kind": v["kind"],
|
||||
"area": v["area"],
|
||||
"summary": v["entry"].summary,
|
||||
"note": v["note"],
|
||||
"hosts": v["entry"].hosts,
|
||||
"subject": v["entry"].subject,
|
||||
"by": v["entry"].actor,
|
||||
"url": v["url"],
|
||||
"details_kept": not v["gone"],
|
||||
"ref": {"type": v["entry"].ref_type, "id": v["entry"].ref_id} if v["entry"].ref_type else None,
|
||||
}
|
||||
for v in items
|
||||
]
|
||||
entries = [_entry(v) for v in items]
|
||||
out = {"range": key, **bounded(entries, limit, key="entries"),
|
||||
"areas": dict(cfg.labels), "types": sorted(catalog.TYPES)}
|
||||
if out["truncated"]:
|
||||
@@ -76,5 +84,67 @@ async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str
|
||||
return out
|
||||
|
||||
|
||||
def _area(cfg, area: str) -> str:
|
||||
"""An area by its key or its label, as Settings → Timeline names it."""
|
||||
want = (area or "").strip().lower()
|
||||
for key, label in cfg.areas:
|
||||
if want in (key, label.lower()):
|
||||
return key
|
||||
raise ValueError(f"area {area!r} is not one of {', '.join(cfg.labels)} (get_timeline lists them).")
|
||||
|
||||
|
||||
async def log_change(summary: str, area: str, hosts: list[str] | str | None = None, subject: str = "",
|
||||
detail: str = "", at: str = "") -> dict:
|
||||
"""Record on the timeline a change Steward can't see happen: a config
|
||||
edited by hand, a package installed, a disk swapped, a service moved, a
|
||||
setting changed in another system. Record one whenever you, or the person
|
||||
you work with, change the infrastructure outside Steward's own tools, so
|
||||
"what changed on this host lately?" has the answer later. Playbook runs,
|
||||
maintenance, deploys and Steward's own settings are recorded already.
|
||||
|
||||
summary: what was done, one line (up to 300 characters), e.g. "Moved
|
||||
Postgres data to the new SSD".
|
||||
area: what part of the infrastructure it is about, by key or label:
|
||||
service, host_os, hardware, storage, network, security, monitoring, as
|
||||
Settings → Timeline names them (get_timeline's `areas`).
|
||||
hosts: the host(s) it was done on, by exact name (list_hosts); none for a
|
||||
change not on any one host.
|
||||
subject: what it was done to, when that is not a host: a container, a
|
||||
service, a file ("postgres", "/etc/fstab").
|
||||
detail: why and how, and anything worth knowing if it goes wrong (up to
|
||||
4000 characters).
|
||||
at: when it was done, ISO-8601 (a time with no zone is UTC); default now.
|
||||
|
||||
Shown as by "<you> via MCP"; the person whose token this is can edit or
|
||||
delete it from the Timeline page. Recorded in the audit log.
|
||||
"""
|
||||
if isinstance(hosts, str):
|
||||
hosts = hosts.split(",")
|
||||
when = None
|
||||
if (at or "").strip():
|
||||
try:
|
||||
when = datetime.fromisoformat(at.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise ValueError(f"at {at!r} is not an ISO-8601 time, e.g. 2026-10-06T22:00:00Z.") from None
|
||||
when = when if when.tzinfo else when.replace(tzinfo=timezone.utc)
|
||||
caller = current_caller()
|
||||
async with db_session() as db:
|
||||
async with db.begin():
|
||||
cfg = await tl_config.load(db)
|
||||
try:
|
||||
f = await changes.fields(db, cfg, summary=summary, area=_area(cfg, area), hosts=hosts or [],
|
||||
subject=subject, note=detail, at=when)
|
||||
except ValueError as exc:
|
||||
if "No host is named" in str(exc):
|
||||
raise ValueError(f"{exc} list_hosts gives the names.") from None
|
||||
raise
|
||||
entry = changes.log(db, f, author_id=caller.user_id, actor=mcp_username(caller.username))
|
||||
[v] = await tl.views(db, cfg, [entry])
|
||||
await audit_mcp("timeline.change_recorded", "timeline_entry", entry.id,
|
||||
{"name": entry.summary, "hosts": entry.hosts, "area": entry.area})
|
||||
return _entry(v)
|
||||
|
||||
|
||||
def register(register_tool) -> None:
|
||||
register_tool(get_timeline, role=UserRole.viewer, read_only=True)
|
||||
register_tool(log_change, role=UserRole.operator)
|
||||
|
||||
Reference in New Issue
Block a user