feat(mcp): discover_infra_drift — live Proxmox vs DB guest reconciliation
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.
This commit is contained in:
@@ -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)
|
## 4. What this audit does NOT cover (follow-ups)
|
||||||
|
|
||||||
Live-infrastructure discovery is out of scope for the DB report and must be done
|
Live-infrastructure discovery has its own tool — run **`discover_infra_drift`**
|
||||||
manually until that machinery lands:
|
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
|
- **Misplaced parent** — compare each guest's actual Proxmox host against its
|
||||||
`hosts` edge (migrations leave these stale).
|
`hosts` edge (migrations leave these stale).
|
||||||
- **Undeployed scripts** — per-guest `/opt/oikos/checks/` presence.
|
- **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
|
- **Seed drift** — run `oikos export` and `git diff seeds/` to find
|
||||||
runtime-created entities not in version control.
|
runtime-created entities not in version control.
|
||||||
|
|
||||||
|
|||||||
119
internal/mcp/discover.go
Normal file
119
internal/mcp/discover.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -791,6 +791,13 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
|
|||||||
return textResult(string(b)), nil
|
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",
|
{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)"}),
|
InputSchema: objSchema(prop{"caller_pubkey", "string", "Age public key of the caller (optional)"}),
|
||||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user