Files
FabledSteward/tests/integration/test_tickets.py
T
bvandeusenandClaude Opus 5.5 2a561210c3
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Successful in 1m51s
CI / publish (push) Successful in 1m16s
feat(tickets): open, comment and resolve tickets from the UI and MCP; known issues on alerts and anomalies (#6351, #6352)
A ticket records a known problem: title, area, hosts, subject, description,
links to alert rules and anomalies, and a log of comments, edits, resolving
and reopening. Opening, resolving (with the resolution as the note) and
reopening go on the timeline; every step is audited, and the audit rows stay
off the timeline.

An alert or anomaly an open ticket covers (by link, by subject, or a
host-wide ticket on the host itself) shows a Known issue badge, and offers
"Open a ticket" prefilled from its rule or anomaly when none does. Hosts,
monitors and containers get a Known issues card.

MCP: list_tickets, get_ticket (viewer); open_ticket, update_ticket,
comment_ticket, resolve_ticket, reopen_ticket (operator). list_alerts and
list_anomalies carry known_issue; get_overview, get_host, get_monitor and
get_container carry open_tickets. Also update_change and delete_change for
changes recorded by hand (author or admin).

Tickets are left out of backups, like the timeline (2026-10-15 ruling).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:06:43 -04:00

258 lines
14 KiB
Python

"""Integration: tickets (#6351, #6352), from the pages and through MCP. A
ticket opened from a firing alert links its rule and makes the alert a known
issue until it is resolved; opening, resolving and reopening go on the
timeline, the rest in the ticket's own log; every step is audited. Also the
recorded-change tools update_change and delete_change: only the author or an
admin. Requires STEWARD_DATABASE_URL.
"""
from __future__ import annotations
import asyncio
import os
import time
import uuid
from datetime import datetime, timedelta, timezone
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
async def _user(app, name: str, role: str):
from steward.models.users import User, UserRole
uid = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=name, email=f"{name}@test", password_hash="x",
role=UserRole(role), is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role=role, username=name, _csrf="test-token", login_at=time.time())
return client, uid
async def _firing(app, tag: str, created_by: str):
"""A host with a disk rule firing on its root mount, and an anomaly on
its CPU: what a ticket is opened on."""
from steward.models.alerts import AlertOperator, AlertRule, AlertState, AlertStateEnum
from steward.models.hosts import Host
from steward.models.norms import MetricAnomaly
name = f"tk-{tag}"
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as s:
async with s.begin():
host = Host(name=name, address="10.7.7.7")
rule = AlertRule(id=str(uuid.uuid4()), name=f"Disk full {tag}", source_module="host_agent",
resource_name=f"{name}:/", metric_name="disk_used_pct",
operator=AlertOperator.gt, threshold=90.0, consecutive_failures_required=1,
enabled=True, created_by=created_by)
anomaly = MetricAnomaly(source_module="host_agent", resource_name=name, metric_name="cpu_pct",
started_at=now - timedelta(hours=2), last_started_at=now - timedelta(hours=2),
ended_at=now - timedelta(hours=1), occurrences=1, peak_level=95.0,
peak_at=now - timedelta(hours=2), usual_hold=20.0, usual_peak=35.0)
s.add_all([host, rule, anomaly])
async with s.begin():
s.add(AlertState(rule_id=rule.id, state=AlertStateEnum.firing,
last_transition_at=now - timedelta(minutes=5)))
return host, rule, anomaly
async def _ticket_on(app, host: str):
from sqlalchemy import select
from steward.models.tickets import Ticket
async with app.db_sessionmaker() as s:
return (await s.execute(select(Ticket).where(Ticket.hosts.contains([host])))).scalar_one()
async def _timeline(app, host: str) -> list[tuple[str, str | None]]:
from sqlalchemy import select
from steward.models.timeline import TimelineEntry
async with app.db_sessionmaker() as s:
rows = (await s.execute(select(TimelineEntry).where(TimelineEntry.hosts.contains([host]),
TimelineEntry.type.like("ticket.%"))
.order_by(TimelineEntry.at))).scalars().all()
return [(e.type, (e.detail or {}).get("note")) for e in rows]
async def _audited(app, entity_id: str) -> list[str]:
from sqlalchemy import select
from steward.models.audit import AuditEvent
async with app.db_sessionmaker() as s:
return list((await s.execute(select(AuditEvent.action).where(AuditEvent.entity_type == "ticket",
AuditEvent.entity_id == entity_id)
.order_by(AuditEvent.timestamp))).scalars())
async def _known(app, rule_id: str) -> list[int]:
from steward.alerts import service as alert_svc
async with app.db_sessionmaker() as s:
rows = await alert_svc.active_rows(s, datetime.now(timezone.utc))
return [t.id for r in rows if r["rule"].id == rule_id for t in r["tickets"]]
def test_a_ticket_opened_from_an_alert_is_worked_and_resolved_on_its_page(app):
async def _go():
tag = uuid.uuid4().hex[:8]
op, op_id = await _user(app, f"tk-op-{tag}", "operator")
viewer, _ = await _user(app, f"tk-v-{tag}", "viewer")
host, rule, _ = await _firing(app, tag, op_id)
# The form opens from the alert, filled in from its rule and linked to it.
page = await (await op.get(f"/tickets/new?rule={rule.id}&back=/alerts/")).get_data(as_text=True)
assert f'value="Disk full {tag} on {host.name}:/"' in page
assert f'value="alert_rule:{rule.id}" checked' in page and f'value="{host.name}" checked' in page
assert (await viewer.get("/tickets/new")).status_code == 403
form = {"csrf_token": "test-token", "title": "Root disk filling with old logs", "area": "storage",
"body": "journald keeps 4 GB.", "hosts": host.name, "subject": f"{host.name}:/",
"link": f"alert_rule:{rule.id}", "back": "/alerts/"}
bad = await op.post("/tickets/", form={**form, "link": "alert_rule:no-such-rule"})
assert bad.status_code == 400 and "No alert rule has the id no-such-rule" in await bad.get_data(as_text=True)
res = await op.post("/tickets/", form=form)
t = await _ticket_on(app, host.name)
assert res.status_code == 302 and res.headers["Location"] == f"/tickets/{t.id}"
assert (t.status, t.area, t.subject, t.opened_by) == ("open", "storage", f"{host.name}:/", f"tk-op-{tag}")
assert await _known(app, rule.id) == [t.id] # the alert is a known issue
alerts = await (await op.get("/alerts/")).get_data(as_text=True)
assert f'href="/tickets/{t.id}">#{t.id} Root disk filling' in alerts
card = await (await viewer.get(f"/tickets/card?host={host.name}&back=/hosts/{host.id}"))\
.get_data(as_text=True)
assert "Known issues (1 open)" in card and "Root disk filling with old logs" in card
assert (await viewer.post(f"/tickets/{t.id}/comment",
form={"csrf_token": "test-token", "text": "x"})).status_code == 403
await op.post(f"/tickets/{t.id}/comment", form={"csrf_token": "test-token", "text": "Vacuumed to 500 MB."})
refused = await op.post(f"/tickets/{t.id}/resolve", form={"csrf_token": "test-token", "text": " "})
assert refused.status_code == 400 and "Say what resolved it" in await refused.get_data(as_text=True)
await op.post(f"/tickets/{t.id}/resolve",
form={"csrf_token": "test-token", "text": "Capped journald at 500 MB."})
t = await _ticket_on(app, host.name)
assert (t.status, t.resolution, t.resolved_by) == ("resolved", "Capped journald at 500 MB.", f"tk-op-{tag}")
assert await _known(app, rule.id) == [] # resolved: no longer known
await op.post(f"/tickets/{t.id}/reopen", form={"csrf_token": "test-token", "text": "Full again."})
# An edit with the link's box unticked unlinks it.
await op.post(f"/tickets/{t.id}", form={k: v for k, v in form.items() if k != "link"})
assert await _known(app, rule.id) == [t.id] # still: it names the mount
shown = await (await viewer.get(f"/tickets/{t.id}")).get_data(as_text=True)
assert "Vacuumed to 500 MB." in shown and "unlinked alert rule" in shown and "Full again." in shown
assert await _timeline(app, host.name) == [
("ticket.opened", None), ("ticket.resolved", "Capped journald at 500 MB."),
("ticket.reopened", "Full again.")]
assert await _audited(app, str(t.id)) == [
"ticket.opened", "ticket.commented", "ticket.resolved", "ticket.reopened", "ticket.edited"]
asyncio.run(_go())
def test_tickets_are_worked_through_mcp_and_show_up_where_an_agent_looks(app):
from steward.core import anomalies as anom
from steward.core.api_tokens import Caller
from steward.mcp import _context
from steward.mcp.tools import alerts, hosts, overview, tickets
from steward.models.users import UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
_, uid = await _user(app, f"tm-{tag}", "operator")
host, rule, anomaly = await _firing(app, tag, uid)
_context._app_ctx.set(app)
_context._caller_ctx.set(Caller(user_id=uid, username=f"tm-{tag}", role=UserRole.operator, token_id=7))
with pytest.raises(ValueError, match="list_hosts"):
await tickets.open_ticket("x", "storage", hosts=["nope"])
out = await tickets.open_ticket("Root disk filling", "Storage", description="journald",
hosts=host.name, subject=f"{host.name}:/", alert_rules=rule.id)
n = out["ticket"]
assert (out["status"], out["opened_by"], out["url"]) == ("open", f"tm-{tag} via MCP", f"/tickets/{n}")
assert [(lk["type"], lk["id"]) for lk in out["links"]] == [("alert_rule", rule.id)]
assert [e["kind"] for e in out["log"]] == ["opened"]
listed = await alerts.list_alerts()
[mine] = [a for a in listed["alerts"] if a["rule_id"] == rule.id]
assert mine["known_issue"] == [{"ticket": n, "title": "Root disk filling"}]
assert n in [t["ticket"] for t in (await tickets.list_tickets(host=host.name))["tickets"]]
assert [t["ticket"] for t in (await hosts.get_host(host.name))["open_tickets"]] == [n]
ov = await overview.get_overview()
assert ov["counts"]["tickets_open"] >= 1 and "open_tickets" in ov
# A link to the anomaly too; the host as a whole is not its subject, so
# the CPU anomaly is known only through the link.
async with app.db_sessionmaker() as s:
assert (await anom.views(s, [anomaly]))[0].tickets == []
changed = await tickets.update_ticket(f"#{n}", link_anomalies=[anomaly.id], title="Root disk filling up")
assert changed["changed"] == ["title", "linked anomaly"]
async with app.db_sessionmaker() as s:
assert [t.id for t in (await anom.views(s, [anomaly]))[0].tickets] == [n]
await tickets.comment_ticket(n, "Vacuumed the journal.")
done = await tickets.resolve_ticket(n, "Capped journald.")
assert (done["status"], done["resolution"]) == ("resolved", "Capped journald.")
with pytest.raises(ValueError, match="already resolved"):
await tickets.resolve_ticket(n, "again")
[mine] = [a for a in (await alerts.list_alerts())["alerts"] if a["rule_id"] == rule.id]
assert mine["known_issue"] == []
back = await tickets.reopen_ticket(n, "Full again.")
assert back["status"] == "open" and [e["kind"] for e in back["log"]] == [
"opened", "edited", "comment", "resolved", "reopened"]
with pytest.raises(ValueError, match="No ticket #999999"):
await tickets.get_ticket(999999)
assert await _audited(app, str(n)) == [
"ticket.opened", "ticket.edited", "ticket.commented", "ticket.resolved", "ticket.reopened"]
asyncio.run(_go())
def test_a_recorded_change_is_corrected_or_taken_back_by_its_author_or_an_admin(app):
from steward.core.api_tokens import Caller
from steward.mcp import _context
from steward.mcp.tools.timeline import delete_change, log_change, update_change
from steward.models.users import UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
_, author = await _user(app, f"uc-a-{tag}", "operator")
_, other = await _user(app, f"uc-o-{tag}", "operator")
_, admin = await _user(app, f"uc-ad-{tag}", "admin")
_context._app_ctx.set(app)
def as_(uid, name, role):
_context._caller_ctx.set(Caller(user_id=uid, username=name, role=role, token_id=7))
as_(author, f"uc-a-{tag}", UserRole.operator)
entry = await log_change(f"Swapped the PSU {tag}", "hardware", subject="psu", detail="old one whined")
as_(other, f"uc-o-{tag}", UserRole.operator)
with pytest.raises(ValueError, match="only they or an admin"):
await update_change(entry["entry_id"], summary="mine now")
with pytest.raises(ValueError, match="only they or an admin"):
await delete_change(entry["entry_id"])
as_(author, f"uc-a-{tag}", UserRole.operator)
fixed = await update_change(entry["entry_id"], summary=f"Swapped the PSU for a 650 W {tag}", subject="")
assert (fixed["summary"], fixed["subject"], fixed["note"], fixed["area"]) == (
f"Swapped the PSU for a 650 W {tag}", None, "old one whined", "hardware")
with pytest.raises(ValueError, match="No recorded change"):
await update_change("no-such-entry", summary="x")
as_(admin, f"uc-ad-{tag}", UserRole.admin)
assert (await delete_change(entry["entry_id"]))["deleted"] == entry["entry_id"]
from sqlalchemy import select
from steward.models.audit import AuditEvent
async with app.db_sessionmaker() as s:
actions = list((await s.execute(select(AuditEvent.action).where(
AuditEvent.entity_id == entry["entry_id"]).order_by(AuditEvent.timestamp))).scalars())
assert actions == ["timeline.change_recorded", "timeline.change_edited", "timeline.change_deleted"]
asyncio.run(_go())