From 0929c17cbb0adf964ce3ef75c2ce940f0ad36b12 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 29 Jul 2026 20:36:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20discover=5Finfra=5Fdrift=20?= =?UTF-8?q?=E2=80=94=20live=20Proxmox=20vs=20DB=20guest=20reconciliation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DB-only audit_knowledge_graph can't see guests running in Proxmox that have no entity, or entities whose pve_id is no longer live — the drift that the stray test LXCs were a symptom of. discover_infra_drift enumerates running guests via pct/qm list on every proxmox host (over the same SSH/pct path the checks use) and diffs against the DB: returns missing (live, no entity) and ghost (DB, not live). Read-only. Companion to audit_knowledge_graph; the skill now runs both and treats the remaining checks (misplaced parent, undeployed scripts, seed drift) as manual. --- .agents/skills/knowledge-graph-audit/SKILL.md | 16 ++- internal/mcp/discover.go | 119 ++++++++++++++++++ internal/mcp/tools.go | 7 ++ 3 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 internal/mcp/discover.go diff --git a/.agents/skills/knowledge-graph-audit/SKILL.md b/.agents/skills/knowledge-graph-audit/SKILL.md index 2e3ce6d..066f391 100644 --- a/.agents/skills/knowledge-graph-audit/SKILL.md +++ b/.agents/skills/knowledge-graph-audit/SKILL.md @@ -58,16 +58,20 @@ A `down_checks` finding that is NOT a real outage is usually one of: ## 4. What this audit does NOT cover (follow-ups) -Live-infrastructure discovery is out of scope for the DB report and must be done -manually until that machinery lands: +Live-infrastructure discovery has its own tool — run **`discover_infra_drift`** +alongside this one. It compares running Proxmox guests (`pct`/`qm list` on every +proxmox host) against the DB graph and returns: + +- **missing entities** — a guest running in Proxmox with no DB entity. +- **ghost entities** — a DB lxc/vm whose `pve_id` is no longer live. + +Still manual until that machinery lands: -- **Ghost vs missing entities** — cross-check `pct list` / `qm list` (on - `host:hubris`, `host:strong`) and `docker ps` against `list_entities`. A guest - with no entity, or an entity with no guest, is drift. - **Misplaced parent** — compare each guest's actual Proxmox host against its `hosts` edge (migrations leave these stale). - **Undeployed scripts** — per-guest `/opt/oikos/checks/` presence. -- **Unmodeled certs** — Caddy-managed TLS certs with no `certificate` entity. +- **Unmodeled certs** — now modeled; verify with `audit_knowledge_graph` / + the cert-expiry checks. - **Seed drift** — run `oikos export` and `git diff seeds/` to find runtime-created entities not in version control. diff --git a/internal/mcp/discover.go b/internal/mcp/discover.go new file mode 100644 index 0000000..b77717d --- /dev/null +++ b/internal/mcp/discover.go @@ -0,0 +1,119 @@ +package mcp + +import ( + "bufio" + "context" + "strings" + + "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/execlog" + "github.com/dtoro/oikos/internal/remote" +) + +// discoverInfraDrift compares the live Proxmox guests (pct/qm list on every +// proxmox host) against the DB graph, surfacing drift the DB-only audit +// cannot see: guests running with no entity (missing), and entities whose +// pve_id is no longer live (ghost). This is the auto-discover/validate half +// of the knowledge-graph audit skill — read-only, reaches hosts over the same +// SSH/pct path the checks use. +func discoverInfraDrift(ctx context.Context, pool *db.Pool) any { + // 1. proxmox hosts to query. + hostRows, err := pool.Query(ctx, + `SELECT slug FROM entities WHERE type='proxmox-host' AND COALESCE(state,'active')='active'`) + if err != nil { + return map[string]any{"error": "query hosts: " + err.Error()} + } + var hosts []string + for hostRows.Next() { + var s string + if hostRows.Scan(&s) == nil { + hosts = append(hosts, s) + } + } + hostRows.Close() + + // 2. DB guests keyed by pve_id. + type dbGuest struct { + Slug string `json:"slug"` + Type string `json:"type"` + PveID string `json:"pve_id"` + Host string `json:"host"` + } + dbGuests := map[string]dbGuest{} + gr, err := pool.Query(ctx, + `SELECT slug, type, COALESCE(attributes->>'pve_id',''), COALESCE(attributes->>'host','') + FROM entities WHERE type IN ('lxc','vm')`) + if err != nil { + return map[string]any{"error": "query guests: " + err.Error()} + } + for gr.Next() { + var g dbGuest + if gr.Scan(&g.Slug, &g.Type, &g.PveID, &g.Host) == nil && g.PveID != "" { + dbGuests[g.PveID] = g + } + } + gr.Close() + + // 3. enumerate live guests from every host. + live := map[string]string{} // pve_id -> "host:name" + hostErrors := map[string]string{} + for _, hs := range hosts { + et, rerr := remote.ResolveExecTarget(ctx, pool, hs, sshUser) + if rerr != nil { + hostErrors[hs] = "resolve: " + rerr.Error() + continue + } + for _, cmd := range []string{"pct list", "qm list"} { + out, eerr := sshExecStream(ctx, et.Host, et.User, et.Wrap(cmd), + execlog.Sink(func(string, []byte) {})) + if eerr != nil { + hostErrors[hs+" "+cmd] = eerr.Error() + continue + } + scanIDs(out, hs, live) + } + } + + // 4. diff. + var missing, ghost []string + for id, hn := range live { + if _, ok := dbGuests[id]; !ok { + missing = append(missing, id+" on "+hn) + } + } + for id, g := range dbGuests { + if _, ok := live[id]; !ok { + ghost = append(ghost, g.Slug+" (pve_id="+id+")") + } + } + + return map[string]any{ + "hosts_queried": len(hosts), + "live_guests": len(live), + "db_guests": len(dbGuests), + "missing_entities": missing, // in Proxmox, no DB entity + "ghost_entities": ghost, // in DB, not live in Proxmox + "host_errors": hostErrors, + } +} + +// scanIDs parses `pct list` / `qm list` output (VMID ... Name) into the live map. +func scanIDs(output, hostSlug string, live map[string]string) { + sc := bufio.NewScanner(strings.NewReader(output)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(strings.ToLower(line), "vmid") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + id := fields[0] + name := "" + if len(fields) >= 4 { + name = fields[len(fields)-1] // pct: last col is name; qm: name near end + } + live[id] = hostSlug + ":" + name + } +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 70e75ee..d3f0204 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -791,6 +791,13 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg { return textResult(string(b)), nil }}, + {tool: &mcp.Tool{Name: "discover_infra_drift", Description: "Read-only live discovery: compares running Proxmox guests (pct/qm list on every proxmox host) against the DB graph. Returns guests running with no entity (missing) and entities whose pve_id is no longer live (ghost) — drift the DB-only audit_knowledge_graph cannot see. Reaches hosts over the same SSH/pct path the checks use. Does NOT mutate anything.", + InputSchema: objSchema(), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + b, _ := json.Marshal(discoverInfraDrift(ctx, pool)) + return textResult(string(b)), nil + }}, + {tool: &mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key", InputSchema: objSchema(prop{"caller_pubkey", "string", "Age public key of the caller (optional)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {