Files
oikos/internal/mcp/discover.go
dtoro b98d7c24bf
Some checks are pending
ci / build-test (push) Waiting to run
ci / docker-build (push) Waiting to run
feat: Phase 9 gaps closed — ApprovalService.Decide convergence, execlog fold, execworker poller
- ApprovalService (core/app/approval.go) + ApprovalRepo (postgres adapter) with
  full decide transaction: HMAC token verify, approval flip, execution un-gate,
  session-scoped window keys (+session suffix matching GovernanceStore gate),
  nomos session flip, audit+event on failure abort. httpapi DecideApproval now
  a thin presenter delegating to the service. ListPending payload format fixed
  (json.Unmarshal not raw-wrap).
- execlog folded into postgres adapter: internal/execlog deleted, NewExecutionLog
  / ReadExecutionLog live in the db package, callers updated (mcp, httpapi).
- execworker poller over ExecutionService.DispatchQueued: advisory lock leak
  fixed (defer/recover per execution), correlation_id preserved via Finalize
  event emission (ExecRunRepo.Finalize now emits execution.{status} with
  correlation_id from the row).
- Phase 8 session export-rename completed: Store, New, and all 53 methods
  exported; cmd/nomos/ agent.go fixed to use session.PendingContinuation etc.
- Coverage gates: ExecutionService.Submit 93.1%, PolicyService.Decide 100%.
- Plans index updated, VERSION bumped to 0.36.0.
2026-08-16 12:29:59 +02:00

119 lines
3.3 KiB
Go

package mcp
import (
"bufio"
"context"
"strings"
"github.com/dtoro/oikos/internal/adapters/postgres"
"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),
db.ExecLogSink(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
}
}