"""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())