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.
120 lines
3.3 KiB
Go
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
|
|
}
|
|
}
|