Files
oikos/internal/mcp/discover.go
dtoro 64f7d54011
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat: Phase 2 — ports package, secrets port move, postgres adapter move
Problem: the hexagon's Phase 2 (plans/2026-08-15-hexagonal-architecture.md)
must give the use-cases-to-be their contract surface: driven-port
interfaces, test fakes, the secrets interface moved into core, and the
postgres package inside the adapters tree — before the first vertical
slice (Phase 3) can wire a composition root.

Change:
- internal/core/ports: full driven-port catalog per plan §3.3 —
  repositories as transaction-scoped aggregates whose inputs carry
  derived checks, audit, and events (§3.6), plus CommandExecutor,
  TargetResolver, Checker, Secrets, EventPublisher, Provisioner.
  Port-local payload types (Event, AuditEntry, CheckDef, KnowledgeEntry,
  ExecResult) keep signatures off infrastructure; TypeTree aliases
  internal/ontology (pure over domain) until checkdefaults is absorbed.
  ReadModels intentionally not declared yet — it materializes with the
  Phase 3 slice and grows as report handlers rewire.
- secrets.Backend is now an alias of ports.Secrets; implementations
  (Infisical, SOPS, Manager) unchanged. mcp's local secretBackend
  subset is deleted; tool constructors take ports.Secrets.
- internal/db → internal/adapters/postgres (mechanical import rewrite;
  package identifier stays db until the Phase 3 repository split).
  sqlc.yaml, Makefile, golangci exclusions, and docs follow the move;
  make generate-check verified.
- internal/adapters/ssh: Executor implements ports.CommandExecutor over
  the actuator dial pool + RunStreaming (10-min default timeout carried
  over from the httpapi path).
- internal/adapters/remote: Resolver implements ports.TargetResolver
  delegating to internal/remote (still pool-based; drops onto
  ports.EntityRepository when repositories land in Phase 3 — documented
  transitional import).
- internal/core/ports/portstest: importable fakes — in-memory
  EntityRepo (with check-then-act SetState, side-effect recording),
  RecordingExecutor, FakeChecker, SpyPublisher; port-satisfaction
  guards; tests.

Risk: ports are declared ahead of implementations — signatures firm up
per phase as slices land (documented in the package doc); the
remote→postgres transitional import is explicit and dissolves in
Phase 3.

Verification: go vet, make test (race, 19 packages), generate-check,
golangci on core+adapters — 0 issues; full-repo baseline down
365→344.
2026-08-15 22:56:56 +02:00

120 lines
3.3 KiB
Go

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