// Package audit produces read-only drift reports over the knowledge graph and // monitoring state. It is the shared engine behind the // /api/v1/audit/drift endpoint and the audit_knowledge_graph MCP tool. // // It surfaces the structural gaps an operator otherwise discovers only by // accident: orphan check entities, checks targeting retired entities, probes // stuck down/unknown, unmonitored declared types, and live edges pointing at // destroyed/deprecated targets. Live-infra discovery (pct/docker/certs) is a // follow-up that needs host-hop execution; these categories are pure DB // queries, so the report is cheap, safe to run unattended, and testable. package audit import ( "context" "github.com/dtoro/oikos/internal/db" ) // Finding is one drift item the operator should look at. type Finding struct { Category string `json:"category"` Severity string `json:"severity"` // info | warning | critical Count int `json:"count"` Entities []string `json:"entities"` Evidence string `json:"evidence"` SuggestedRunbook string `json:"suggested_runbook"` } // Summary tallies findings by category. type Summary struct { TotalFindings int `json:"total_findings"` ByCategory map[string]int `json:"by_category"` } // Report runs every drift check and returns the findings plus a summary. func Report(ctx context.Context, pool *db.Pool) ([]Finding, Summary) { specs := []struct { finding Finding query string }{ { Finding{Category: "orphan_checks", Severity: "warning", Evidence: "check entities with truncated/random slugs (legacy shortSlug bug), no live target", SuggestedRunbook: "scripts/cleanup-orphan-checks.sh"}, `SELECT e.slug FROM entities e WHERE e.type = 'check' AND e.slug ~ '^check:(ping|ssh-script|disk):[0-9a-f]{8}$'`, }, { Finding{Category: "dead_checks", Severity: "warning", Evidence: "enabled check_defs whose target entity is deprecated/destroyed", SuggestedRunbook: "lifecycle-deprecate-node / lifecycle-destroy-node"}, `SELECT e.slug FROM check_defs cd JOIN entities e ON e.id = cd.entity_id JOIN entities tgt ON tgt.id = cd.target_id WHERE cd.enabled AND tgt.state IN ('deprecated','destroyed')`, }, { Finding{Category: "down_checks", Severity: "critical", Evidence: "enabled checks reporting health=down", SuggestedRunbook: "service-health-check"}, `SELECT e.slug FROM check_defs cd JOIN entities e ON e.id = cd.entity_id WHERE cd.enabled AND cd.last_health = 'down'`, }, { Finding{Category: "unknown_checks", Severity: "warning", Evidence: "enabled checks that ran but reported health=unknown (likely misconfigured probe)", SuggestedRunbook: "knowledge-graph-audit"}, `SELECT e.slug FROM check_defs cd JOIN entities e ON e.id = cd.entity_id WHERE cd.enabled AND cd.last_health = 'unknown'`, }, { Finding{Category: "unmonitored", Severity: "warning", Evidence: "active entities whose type declares monitoring but have no enabled check_def", SuggestedRunbook: "knowledge-graph-audit"}, `SELECT DISTINCT e.slug FROM signals sg JOIN entities e ON e.id = sg.target_entity_id WHERE sg.kind = 'unmonitored' AND sg.state IN ('raised','acknowledged','acting')`, }, { Finding{Category: "dangling_edges", Severity: "warning", Evidence: "live relationships (hosts/provides/mounts) pointing at destroyed/deprecated targets", SuggestedRunbook: "lifecycle-destroy-node"}, `SELECT src.slug || ' -' || r.type || '-> ' || tgt.slug FROM relationships r JOIN entities src ON src.id = r.source_id JOIN entities tgt ON tgt.id = r.target_id WHERE r.valid_to IS NULL AND src.state NOT IN ('destroyed','deprecated') AND tgt.state IN ('destroyed','deprecated')`, }, { Finding{Category: "polluted_attrs", Severity: "warning", Evidence: "routing-critical attributes carrying prose (breaks resolution) — e.g. host='hubris (confirmed via pct…')", SuggestedRunbook: "knowledge-graph-audit"}, `SELECT slug || ': host=' || (attributes->>'host') FROM entities WHERE attributes->>'host' IS NOT NULL AND (attributes->>'host') ~ '[ (]'`, }, } findings := make([]Finding, 0, len(specs)) summary := Summary{ByCategory: map[string]int{}} for _, sp := range specs { f := runFinding(ctx, pool, sp.finding, sp.query) findings = append(findings, f) summary.TotalFindings += f.Count summary.ByCategory[f.Category] = f.Count } return findings, summary } const entityCap = 50 // runFinding runs a single-column slug query and folds the rows into a Finding. func runFinding(ctx context.Context, pool *db.Pool, f Finding, query string) Finding { rows, err := pool.Query(ctx, query) if err != nil { f.Evidence = f.Evidence + " (query error: " + err.Error() + ")" return f } defer rows.Close() for rows.Next() { var slug string if err := rows.Scan(&slug); err != nil { continue } f.Count++ if len(f.Entities) < entityCap { f.Entities = append(f.Entities, slug) } } return f }