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 } }