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>
258 lines
14 KiB
Python
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())
|