resolveProxmoxHostSlug trusted attributes.host verbatim, so a value polluted with prose — lxc:teddycloud carried host="hubris (confirmed via pct config…)" — became a slug that never resolved, leaving its checks 'down' despite a correct `hosts` edge. Treat an attribute containing whitespace/parens as invalid and fall back to the canonical hosts edge. The audit now reports `polluted_attrs` — entities whose routing-critical attributes carry prose — so this class is visible instead of a silent resolution failure.
134 lines
5.0 KiB
Go
134 lines
5.0 KiB
Go
// 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
|
|
}
|