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.
This commit is contained in:
34
internal/adapters/postgres/checks.go
Normal file
34
internal/adapters/postgres/checks.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dtoro/oikos/internal/checkdefaults"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// EnsureEntityChecks derives an entity's default check_defs from the
|
||||
// monitoring spec of its type (resolving per-entity `monitoring` overrides).
|
||||
//
|
||||
// This is the single shared hook that keeps the check graph in sync with
|
||||
// entity mutations. Both the HTTP create/patch handlers and the MCP
|
||||
// entity-mutation tools (create_entity, update_entity_attributes) call it so
|
||||
// that flipping an entity's `monitoring` attribute regenerates checks
|
||||
// regardless of which surface made the change — previously only the HTTP
|
||||
// path ran check derivation, so entities mutated via MCP silently produced no
|
||||
// checks (see plans/2026-08-03-session-review-haos-monitoring-capability-gaps.md, A2).
|
||||
func EnsureEntityChecks(ctx context.Context, tx pgx.Tx, id uuid.UUID, slug, entityType, name string, attrs []byte) (checkdefaults.Result, error) {
|
||||
tree, err := LoadTypeTree(ctx, tx)
|
||||
if err != nil {
|
||||
return checkdefaults.Result{}, err
|
||||
}
|
||||
res, err := checkdefaults.Ensure(ctx, tx, tree, checkdefaults.Target{
|
||||
ID: id, Slug: slug, Type: entityType, Name: name, Attrs: attrs,
|
||||
})
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
checkdefaults.LogResult(slug, entityType, res)
|
||||
return res, nil
|
||||
}
|
||||
6
internal/adapters/postgres/doc.go
Normal file
6
internal/adapters/postgres/doc.go
Normal file
@@ -0,0 +1,6 @@
|
||||
// Package db is the postgres adapter: connection pool, migrations, seed
|
||||
// ingest, and sqlc-generated queries. It moved from internal/db in Phase 2
|
||||
// of the hexagonal refactor (ADR 0016); the package identifier stays `db`
|
||||
// until the repository split (Phase 3) renames it alongside the first
|
||||
// ports implementations landing here.
|
||||
package db
|
||||
68
internal/adapters/postgres/entity_cache.go
Normal file
68
internal/adapters/postgres/entity_cache.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type entityCacheEntry struct {
|
||||
slug string
|
||||
id string
|
||||
attrs string
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
// EntityCache is a TTL cache mapping entity IDs to slugs and back,
|
||||
// keyed for the hot resolution paths.
|
||||
type EntityCache struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]entityCacheEntry
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewEntityCache builds a cache with the given TTL.
|
||||
func NewEntityCache(ttl time.Duration) *EntityCache {
|
||||
return &EntityCache{
|
||||
m: make(map[string]entityCacheEntry),
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSlug resolves an entity ID to its slug.
|
||||
func (c *EntityCache) GetSlug(id string) (string, bool) {
|
||||
c.mu.RLock()
|
||||
e, ok := c.m[id]
|
||||
c.mu.RUnlock()
|
||||
if !ok || time.Now().After(e.exp) {
|
||||
return "", false
|
||||
}
|
||||
return e.slug, true
|
||||
}
|
||||
|
||||
// GetID resolves a slug to its entity ID.
|
||||
func (c *EntityCache) GetID(slug string) (string, bool) {
|
||||
c.mu.RLock()
|
||||
e, ok := c.m[slug]
|
||||
c.mu.RUnlock()
|
||||
if !ok || time.Now().After(e.exp) {
|
||||
return "", false
|
||||
}
|
||||
return e.id, true
|
||||
}
|
||||
|
||||
// Set records the slug/id pair and serialized attributes.
|
||||
func (c *EntityCache) Set(slug, id, attrs string) {
|
||||
exp := time.Now().Add(c.ttl)
|
||||
c.mu.Lock()
|
||||
c.m[slug] = entityCacheEntry{slug: slug, id: id, attrs: attrs, exp: exp}
|
||||
c.m[id] = entityCacheEntry{slug: slug, id: id, attrs: attrs, exp: exp}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Invalidate drops the cached entries for one slug/id pair.
|
||||
func (c *EntityCache) Invalidate(slug, id string) {
|
||||
c.mu.Lock()
|
||||
delete(c.m, slug)
|
||||
delete(c.m, id)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
300
internal/adapters/postgres/export.go
Normal file
300
internal/adapters/postgres/export.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ExportToYAML regenerates the three seed YAMLs from the DB (DR / version
|
||||
// control, plan D6). Output is deterministic: maps marshal with sorted keys
|
||||
// (yaml.v3 default for map[string]any) and lists are ordered by slug/name,
|
||||
// so export → ingest → export is byte-stable.
|
||||
//
|
||||
// Cognition-layer entities (signals, executions, patterns, …) are runtime
|
||||
// state, not inventory — they are excluded from the export.
|
||||
func ExportToYAML(ctx context.Context, pool *Pool) (map[string][]byte, error) {
|
||||
result := make(map[string][]byte)
|
||||
|
||||
for name, fn := range map[string]func(context.Context, *Pool) (map[string]any, error){
|
||||
"ontology.yaml": exportOntology,
|
||||
"inventory.yaml": exportInventory,
|
||||
"policy.yaml": exportPolicy,
|
||||
} {
|
||||
doc, err := fn(ctx, pool)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export %s: %w", name, err)
|
||||
}
|
||||
out, err := yaml.Marshal(doc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal %s: %w", name, err)
|
||||
}
|
||||
result[name] = out
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func exportOntology(ctx context.Context, pool *Pool) (map[string]any, error) {
|
||||
lifecycles := map[string]any{}
|
||||
rows, err := pool.Query(ctx,
|
||||
`SELECT id, states, default_state, terminal_states, transitions
|
||||
FROM lifecycle_defs ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id, def string
|
||||
var states, terminal []string
|
||||
var transitionsJSON []byte
|
||||
if err := rows.Scan(&id, &states, &def, &terminal, &transitionsJSON); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
var transitions map[string]any
|
||||
if err := json.Unmarshal(transitionsJSON, &transitions); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("lifecycle %s transitions: %w", id, err)
|
||||
}
|
||||
lifecycles[id] = map[string]any{
|
||||
"states": states,
|
||||
"default_state": def,
|
||||
"terminal_states": terminal,
|
||||
"transitions": transitions,
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
entityTypes := map[string]any{}
|
||||
rows, err = pool.Query(ctx,
|
||||
`SELECT name, COALESCE(parent_type,''), is_abstract, domain, layer,
|
||||
COALESCE(description,''), COALESCE(lifecycle_id,''), attribute_schema
|
||||
FROM entity_types ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var name, parent, dom, layer, desc, lc string
|
||||
var isAbstract bool
|
||||
var schemaJSON []byte
|
||||
if err := rows.Scan(&name, &parent, &isAbstract, &dom, &layer, &desc, &lc, &schemaJSON); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
et := map[string]any{"domain": dom, "layer": layer}
|
||||
if parent != "" {
|
||||
et["parent"] = parent
|
||||
}
|
||||
if isAbstract {
|
||||
et["abstract"] = true
|
||||
}
|
||||
if desc != "" {
|
||||
et["description"] = desc
|
||||
}
|
||||
if lc != "" {
|
||||
et["lifecycle"] = lc
|
||||
}
|
||||
if len(schemaJSON) > 0 && string(schemaJSON) != "null" {
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(schemaJSON, &schema); err == nil && schema != nil {
|
||||
et["attributes"] = schema
|
||||
}
|
||||
}
|
||||
entityTypes[name] = et
|
||||
}
|
||||
rows.Close()
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
relTypes := map[string]any{}
|
||||
rows, err = pool.Query(ctx,
|
||||
`SELECT name, COALESCE(inverse,''), source_type, target_type, cardinality,
|
||||
COALESCE(description,'')
|
||||
FROM relationship_types ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var name, inverse, src, tgt, card, desc string
|
||||
if err := rows.Scan(&name, &inverse, &src, &tgt, &card, &desc); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rt := map[string]any{"source": src, "target": tgt, "cardinality": card}
|
||||
if inverse != "" {
|
||||
rt["inverse"] = inverse
|
||||
}
|
||||
if desc != "" {
|
||||
rt["description"] = desc
|
||||
}
|
||||
relTypes[name] = rt
|
||||
}
|
||||
rows.Close()
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"version": 1,
|
||||
"lifecycles": lifecycles,
|
||||
"entity_types": entityTypes,
|
||||
"relationship_types": relTypes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func exportInventory(ctx context.Context, pool *Pool) (map[string]any, error) {
|
||||
var entities []any
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT e.slug, e.type, e.name, COALESCE(e.state,''), e.attributes
|
||||
FROM entities e
|
||||
JOIN entity_types et ON et.name = e.type
|
||||
WHERE et.layer <> 'cognition'
|
||||
ORDER BY e.slug`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var slug, typ, name, state string
|
||||
var attrsJSON []byte
|
||||
if err := rows.Scan(&slug, &typ, &name, &state, &attrsJSON); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
e := map[string]any{"slug": slug, "type": typ, "name": name}
|
||||
if state != "" {
|
||||
e["state"] = state
|
||||
}
|
||||
var attrs map[string]any
|
||||
if err := json.Unmarshal(attrsJSON, &attrs); err == nil && len(attrs) > 0 {
|
||||
e["attributes"] = attrs
|
||||
}
|
||||
entities = append(entities, e)
|
||||
}
|
||||
rows.Close()
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
var rels []any
|
||||
rows, err = pool.Query(ctx, `
|
||||
SELECT se.slug, te.slug, r.type, r.attributes
|
||||
FROM relationships r
|
||||
JOIN entities se ON se.id = r.source_id
|
||||
JOIN entities te ON te.id = r.target_id
|
||||
WHERE r.valid_to IS NULL
|
||||
ORDER BY r.type, se.slug, te.slug`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var src, tgt, typ string
|
||||
var attrsJSON []byte
|
||||
if err := rows.Scan(&src, &tgt, &typ, &attrsJSON); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rel := map[string]any{"source": src, "target": tgt, "type": typ}
|
||||
var attrs map[string]any
|
||||
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
|
||||
rel["attributes"] = attrs
|
||||
}
|
||||
rels = append(rels, rel)
|
||||
}
|
||||
rows.Close()
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"version": 1,
|
||||
"entities": entities,
|
||||
"relationships": rels,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func exportPolicy(ctx context.Context, pool *Pool) (map[string]any, error) {
|
||||
riskClasses := map[string]any{}
|
||||
rows, err := pool.Query(ctx,
|
||||
`SELECT name, COALESCE(description,''), approval_required, autonomy_allowed
|
||||
FROM risk_classes ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var name, desc, approval string
|
||||
var autonomy bool
|
||||
if err := rows.Scan(&name, &desc, &approval, &autonomy); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rc := map[string]any{"approval_required": approval, "autonomy_allowed": autonomy}
|
||||
if desc != "" {
|
||||
rc["description"] = desc
|
||||
}
|
||||
riskClasses[name] = rc
|
||||
}
|
||||
rows.Close()
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
var rules []any
|
||||
rows, err = pool.Query(ctx, `
|
||||
SELECT COALESCE(ar.entity_type,''), ar.action, ar.risk_class,
|
||||
ar.autonomy_level, COALESCE(se.slug,'')
|
||||
FROM approval_rules ar
|
||||
LEFT JOIN entities se ON se.id = ar.scope_entity
|
||||
ORDER BY COALESCE(ar.entity_type,''), ar.action, COALESCE(se.slug,'')`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var et, action, rc, al, scope string
|
||||
if err := rows.Scan(&et, &action, &rc, &al, &scope); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rule := map[string]any{"action": action, "risk_class": rc, "autonomy_level": al}
|
||||
if et != "" {
|
||||
rule["entity_type"] = et
|
||||
}
|
||||
if scope != "" {
|
||||
rule["scope_entity"] = scope
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
rows.Close()
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
settings := map[string]any{}
|
||||
rows, err = pool.Query(ctx, `SELECT key, value FROM autonomy_settings ORDER BY key`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
settings[k] = v
|
||||
}
|
||||
rows.Close()
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"version": 1,
|
||||
"risk_classes": riskClasses,
|
||||
"approval_rules": rules,
|
||||
"autonomy_settings": settings,
|
||||
}, nil
|
||||
}
|
||||
414
internal/adapters/postgres/integration_test.go
Normal file
414
internal/adapters/postgres/integration_test.go
Normal file
@@ -0,0 +1,414 @@
|
||||
package db
|
||||
|
||||
// Integration tests against a real TimescaleDB. Guarded by
|
||||
// OIKOS_TEST_DATABASE_URL — skipped when unset. Run with:
|
||||
//
|
||||
// docker compose up -d postgres
|
||||
// OIKOS_TEST_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" go test ./internal/db/
|
||||
//
|
||||
// or `make test-db`. Each run creates a throwaway database and drops it.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dtoro/oikos/internal/core/domain"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func seedsDir() string { return "../../seeds" }
|
||||
|
||||
// newTestPool creates a throwaway database (dropped on cleanup), runs all
|
||||
// migrations, and returns a pool connected to it.
|
||||
func newTestPool(t *testing.T) *Pool {
|
||||
t.Helper()
|
||||
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
|
||||
if baseURL == "" {
|
||||
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
admin, err := pgx.Connect(ctx, baseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect admin: %v", err)
|
||||
}
|
||||
dbName := fmt.Sprintf("oikos_test_%08x", rand.Int63())
|
||||
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
|
||||
admin.Close(ctx)
|
||||
t.Fatalf("create test db: %v", err)
|
||||
}
|
||||
admin.Close(ctx)
|
||||
|
||||
testURL := swapDatabase(baseURL, dbName)
|
||||
pool, err := New(ctx, testURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect test db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
pool.Close()
|
||||
admin, err := pgx.Connect(ctx, baseURL)
|
||||
if err == nil {
|
||||
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
|
||||
admin.Close(ctx)
|
||||
}
|
||||
})
|
||||
|
||||
if err := pool.Migrate(ctx); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// swapDatabase replaces the database name in a postgres URL.
|
||||
func swapDatabase(url, db string) string {
|
||||
// postgres://user:pass@host:port/dbname?params
|
||||
qi := strings.Index(url, "?")
|
||||
params := ""
|
||||
base := url
|
||||
if qi >= 0 {
|
||||
base, params = url[:qi], url[qi:]
|
||||
}
|
||||
si := strings.LastIndex(base, "/")
|
||||
return base[:si+1] + db + params
|
||||
}
|
||||
|
||||
func seedAll(t *testing.T, pool *Pool, dir string) {
|
||||
t.Helper()
|
||||
for _, f := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
|
||||
content, err := os.ReadFile(dir + "/" + f)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", f, err)
|
||||
}
|
||||
ingestSeedContent(t, pool, f, content)
|
||||
}
|
||||
}
|
||||
|
||||
func ingestSeedContent(t *testing.T, pool *Pool, name string, content []byte) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
err := pool.SeedIngest(ctx, name, content,
|
||||
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||
var err error
|
||||
switch name {
|
||||
case "ontology.yaml":
|
||||
_, err = IngestOntologySeed(ctx, tx, data)
|
||||
case "inventory.yaml":
|
||||
_, err = IngestInventorySeed(ctx, tx, data)
|
||||
case "policy.yaml":
|
||||
_, err = IngestPolicySeed(ctx, tx, data)
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ingest %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func count(t *testing.T, pool *Pool, query string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := pool.QueryRow(context.Background(), query).Scan(&n); err != nil {
|
||||
t.Fatalf("count %q: %v", query, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestMigrateIdempotent(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
// second run must be a clean no-op
|
||||
if err := pool.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("second migrate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedIngestIdempotentAndNoDuplicateEdges(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
seedAll(t, pool, seedsDir())
|
||||
|
||||
entities := count(t, pool, "SELECT count(*) FROM entities")
|
||||
edges := count(t, pool, "SELECT count(*) FROM relationships WHERE valid_to IS NULL")
|
||||
if entities == 0 || edges == 0 {
|
||||
t.Fatalf("seed produced empty graph: %d entities, %d edges", entities, edges)
|
||||
}
|
||||
|
||||
// Same content → hash no-op
|
||||
seedAll(t, pool, seedsDir())
|
||||
if got := count(t, pool, "SELECT count(*) FROM relationships WHERE valid_to IS NULL"); got != edges {
|
||||
t.Errorf("unchanged re-seed altered edges: %d → %d", edges, got)
|
||||
}
|
||||
|
||||
// Changed content (hash differs) → full re-ingest must NOT duplicate edges
|
||||
// (regression: the old upsert conflicted on valid_from and duplicated all
|
||||
// 144 edges on every re-ingest)
|
||||
content, err := os.ReadFile(seedsDir() + "/inventory.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
touched := append(content, []byte("\n# touched for hash change\n")...)
|
||||
ingestSeedContent(t, pool, "inventory.yaml", touched)
|
||||
|
||||
if got := count(t, pool, "SELECT count(*) FROM relationships WHERE valid_to IS NULL"); got != edges {
|
||||
t.Errorf("touched re-seed duplicated edges: %d → %d", edges, got)
|
||||
}
|
||||
if dup := count(t, pool, `SELECT count(*) FROM (
|
||||
SELECT source_id, target_id, type FROM relationships
|
||||
WHERE valid_to IS NULL GROUP BY 1,2,3 HAVING count(*) > 1) d`); dup != 0 {
|
||||
t.Errorf("%d duplicated current edges", dup)
|
||||
}
|
||||
if got := count(t, pool, "SELECT count(*) FROM entities"); got != entities {
|
||||
t.Errorf("touched re-seed altered entity count: %d → %d", entities, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: insertOneEntityType read tMap["attribute_schema"], but
|
||||
// seeds/ontology.yaml spells the key `attributes:`. The mismatch marshalled a
|
||||
// nil into the JSON literal `null` for every one of the 60 types, so no
|
||||
// attribute schema was ever ingested — the API and `oikos export` returned
|
||||
// null across the board, silently, for the life of the project.
|
||||
func TestSeedIngestsAttributeSchemas(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
seedAll(t, pool, seedsDir())
|
||||
|
||||
if n := count(t, pool,
|
||||
`SELECT count(*) FROM entity_types WHERE attribute_schema = 'null'::jsonb`); n != 0 {
|
||||
t.Errorf("%d entity types stored the JSON literal null instead of a schema or SQL NULL", n)
|
||||
}
|
||||
|
||||
if n := count(t, pool,
|
||||
`SELECT count(*) FROM entity_types WHERE jsonb_typeof(attribute_schema) = 'object'`); n == 0 {
|
||||
t.Fatal("no entity type ingested an attribute schema")
|
||||
}
|
||||
|
||||
// A type declaring `attributes:` must round-trip its properties.
|
||||
if n := count(t, pool, `SELECT count(*) FROM entity_types
|
||||
WHERE name = 'lxc' AND attribute_schema #>> '{properties,pve_id,type}' = 'integer'`); n != 1 {
|
||||
t.Error("lxc.attribute_schema lost its declared pve_id property")
|
||||
}
|
||||
|
||||
// A type declaring none stores SQL NULL, not a JSON null.
|
||||
if n := count(t, pool, `SELECT count(*) FROM entity_types
|
||||
WHERE name = 'sensor' AND attribute_schema IS NULL`); n != 1 {
|
||||
t.Error("a type declaring no attributes should store SQL NULL")
|
||||
}
|
||||
}
|
||||
|
||||
// monitoring_spec drives which entities coverageSweep may flag as unmonitored,
|
||||
// so the three states have to survive ingest distinctly: SQL NULL (undeclared,
|
||||
// resolved from an ancestor or the layer default), '[]' (explicitly
|
||||
// unmonitorable), and a non-empty array (the kinds the type warrants).
|
||||
func TestSeedIngestsMonitoringSpec(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
seedAll(t, pool, seedsDir())
|
||||
|
||||
cases := []struct {
|
||||
typ, where, desc string
|
||||
}{
|
||||
{"service", `monitoring_spec = '["http","process"]'::jsonb`, "declared kinds"},
|
||||
{"machine", `monitoring_spec = '["ping","resource","updates"]'::jsonb`, "declared on an abstract type"},
|
||||
{"site", `monitoring_spec = '[]'::jsonb`, "explicitly unmonitorable"},
|
||||
{"lxc", `monitoring_spec IS NULL`, "inherits from container, so its own column is NULL"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if n := count(t, pool, fmt.Sprintf(
|
||||
`SELECT count(*) FROM entity_types WHERE name = '%s' AND %s`, c.typ, c.where)); n != 1 {
|
||||
t.Errorf("%s (%s): monitoring_spec did not match %s", c.typ, c.desc, c.where)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbstractTypeRejected(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
seedAll(t, pool, seedsDir())
|
||||
|
||||
bad := []byte(`
|
||||
version: 1
|
||||
entities:
|
||||
- {slug: "machine:ghost", type: machine, name: ghost}
|
||||
`)
|
||||
ctx := context.Background()
|
||||
err := pool.SeedIngest(ctx, "inventory.yaml", bad,
|
||||
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||
_, err := IngestInventorySeed(ctx, tx, data)
|
||||
return err
|
||||
})
|
||||
if !errors.Is(err, domain.ErrAbstractType) {
|
||||
t.Errorf("abstract instantiation = %v, want ErrAbstractType", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEdgeEndpointValidation(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
seedAll(t, pool, seedsDir())
|
||||
|
||||
// routes-to requires source ingress-route; a service source must fail
|
||||
bad := []byte(`
|
||||
version: 1
|
||||
relationships:
|
||||
- {source: "service:gitea", target: "service:caddy", type: routes-to}
|
||||
`)
|
||||
ctx := context.Background()
|
||||
err := pool.SeedIngest(ctx, "inventory.yaml", bad,
|
||||
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||
_, err := IngestInventorySeed(ctx, tx, data)
|
||||
return err
|
||||
})
|
||||
if !errors.Is(err, domain.ErrInvalidEdge) {
|
||||
t.Errorf("bad edge = %v, want ErrInvalidEdge", err)
|
||||
}
|
||||
|
||||
// hosts from a proxmox-host (is-a machine) to an lxc (is-a compute-entity)
|
||||
// must PASS via hierarchy walk — already covered by the seed itself, but
|
||||
// assert an explicit one for clarity
|
||||
good := []byte(`
|
||||
version: 1
|
||||
relationships:
|
||||
- {source: "host:strong", target: "lxc:jellyfin", type: hosts}
|
||||
`)
|
||||
err = pool.SeedIngest(ctx, "inventory.yaml", good,
|
||||
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||
_, err := IngestInventorySeed(ctx, tx, data)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("valid inherited edge rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardinalityEnforced(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
seedAll(t, pool, seedsDir())
|
||||
|
||||
// routes-to is many-to-one: one ingress route cannot point at two services
|
||||
bad := []byte(`
|
||||
version: 1
|
||||
relationships:
|
||||
- {source: "ingress:git.hubris.network", target: "service:jellyfin", type: routes-to}
|
||||
`)
|
||||
ctx := context.Background()
|
||||
err := pool.SeedIngest(ctx, "inventory.yaml", bad,
|
||||
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||
_, err := IngestInventorySeed(ctx, tx, data)
|
||||
return err
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "cardinality") {
|
||||
t.Errorf("cardinality violation = %v, want cardinality error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlastRadiusTerminatesOnCycles(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
seedAll(t, pool, seedsDir())
|
||||
|
||||
// Build a dependency cycle: gitea → caddy → authentik → gitea.
|
||||
//
|
||||
// `depends-on` is declared blast_direction: backward — "A depends-on B"
|
||||
// means B failing breaks A — so the blast radius of gitea walks the edges
|
||||
// BACKWARDS: whoever depends on gitea is affected first. That is authentik
|
||||
// (1 hop), then caddy which depends on authentik (2 hops).
|
||||
//
|
||||
// This test previously asserted caddy=1, authentik=2, which is the same
|
||||
// cycle walked the wrong way round: blast_radius used to follow every edge
|
||||
// source→target regardless of what the edge means, so it answered "what
|
||||
// does gitea depend on" while being named for the opposite question.
|
||||
cycle := []byte(`
|
||||
version: 1
|
||||
relationships:
|
||||
- {source: "service:gitea", target: "service:caddy", type: depends-on}
|
||||
- {source: "service:caddy", target: "service:authentik", type: depends-on}
|
||||
- {source: "service:authentik", target: "service:gitea", type: depends-on}
|
||||
`)
|
||||
ctx := context.Background()
|
||||
err := pool.SeedIngest(ctx, "inventory.yaml", cycle,
|
||||
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||
_, err := IngestInventorySeed(ctx, tx, data)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("cycle ingest: %v", err)
|
||||
}
|
||||
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT e.slug, b.depth
|
||||
FROM blast_radius((SELECT id FROM entities WHERE slug='service:gitea'), 5,
|
||||
ARRAY['depends-on']) b
|
||||
JOIN entities e ON e.id = b.entity_id ORDER BY b.depth`)
|
||||
if err != nil {
|
||||
t.Fatalf("blast_radius: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
got := map[string]int{}
|
||||
for rows.Next() {
|
||||
var slug string
|
||||
var depth int
|
||||
if err := rows.Scan(&slug, &depth); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got[slug] = depth
|
||||
}
|
||||
want := map[string]int{"service:gitea": 0, "service:authentik": 1, "service:caddy": 2}
|
||||
for slug, depth := range want {
|
||||
if got[slug] != depth {
|
||||
t.Errorf("blast_radius[%s] = %d, want %d (full: %v)", slug, got[slug], depth, got)
|
||||
}
|
||||
}
|
||||
// Deliberately not an exact node count. Walking the right way round also
|
||||
// surfaces the real seed's own dependents of gitea (homelab-mcp and what
|
||||
// depends on it), which are correct answers — the old exact-count
|
||||
// assertion only held because the forward walk found nothing real.
|
||||
// What matters here is that the cycle terminates rather than recursing.
|
||||
if len(got) > 20 {
|
||||
t.Errorf("blast_radius did not terminate sensibly: %d nodes: %v", len(got), got)
|
||||
}
|
||||
for slug, depth := range got {
|
||||
if depth > 5 {
|
||||
t.Errorf("blast_radius[%s] = %d, beyond the max_depth bound", slug, depth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportRoundTripStable: export → ingest into a fresh DB → export again
|
||||
// must yield byte-identical YAML (the canonical-form fixpoint, plan D6).
|
||||
func TestExportRoundTripStable(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
seedAll(t, pool, seedsDir())
|
||||
ctx := context.Background()
|
||||
|
||||
export1, err := ExportToYAML(ctx, pool)
|
||||
if err != nil {
|
||||
t.Fatalf("export 1: %v", err)
|
||||
}
|
||||
for name, content := range export1 {
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal(content, &doc); err != nil {
|
||||
t.Fatalf("export %s is not valid YAML: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
pool2 := newTestPool(t)
|
||||
for _, name := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
|
||||
ingestSeedContent(t, pool2, name, export1[name])
|
||||
}
|
||||
export2, err := ExportToYAML(ctx, pool2)
|
||||
if err != nil {
|
||||
t.Fatalf("export 2: %v", err)
|
||||
}
|
||||
for _, name := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
|
||||
if !bytes.Equal(export1[name], export2[name]) {
|
||||
t.Errorf("%s round-trip not byte-stable (len %d vs %d)",
|
||||
name, len(export1[name]), len(export2[name]))
|
||||
}
|
||||
}
|
||||
|
||||
// sanity: exported inventory carries the real graph, not a stub
|
||||
// (regression: export used to write 11-byte "version: 1" stubs)
|
||||
if len(export1["inventory.yaml"]) < 1000 {
|
||||
t.Errorf("inventory export suspiciously small: %d bytes", len(export1["inventory.yaml"]))
|
||||
}
|
||||
}
|
||||
182
internal/adapters/postgres/lifecycle.go
Normal file
182
internal/adapters/postgres/lifecycle.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrTransitionInvalid is a sentinel returned by ValidateTransition when the
|
||||
// from→to pair is not a declared lifecycle transition or a precondition fails.
|
||||
// Callers test with errors.Is to distinguish semantic validation failures
|
||||
// (→ HTTP 409) from infrastructure errors (→ HTTP 500).
|
||||
var ErrTransitionInvalid = errors.New("invalid lifecycle transition")
|
||||
|
||||
// ValidateTransition enforces an entity type's lifecycle: fromState → toState
|
||||
// must be a declared transition, and every precondition it lists must hold. A
|
||||
// type with no lifecycle defined allows any state. A no-op (fromState ==
|
||||
// toState) passes immediately.
|
||||
//
|
||||
// Shared by the HTTP PATCH path and the MCP set_entity_state tool so both
|
||||
// surfaces apply identical lifecycle rules — previously only the HTTP path
|
||||
// validated transitions, so an agent changing state via MCP could skip the
|
||||
// graph's retire/deprecate guardrails entirely.
|
||||
func ValidateTransition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, fromState, toState string) error {
|
||||
if toState == fromState {
|
||||
return nil
|
||||
}
|
||||
lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, entityType)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil // no lifecycle defined → any state allowed
|
||||
}
|
||||
return err
|
||||
}
|
||||
var transitions map[string]map[string]json.RawMessage
|
||||
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
|
||||
return fmt.Errorf("parse lifecycle transitions: %w", err)
|
||||
}
|
||||
tos, ok := transitions[fromState]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: no transitions defined from %q", ErrTransitionInvalid, fromState)
|
||||
}
|
||||
trans, ok := tos[toState]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s → %s is not a declared lifecycle transition", ErrTransitionInvalid, fromState, toState)
|
||||
}
|
||||
var gate struct {
|
||||
Requires []string `json:"requires"`
|
||||
}
|
||||
if err := json.Unmarshal(trans, &gate); err == nil {
|
||||
for _, check := range gate.Requires {
|
||||
if err := checkPrecondition(ctx, tx, entityID, entityType, check); err != nil {
|
||||
return fmt.Errorf("%w: precondition %q not met: %w", ErrTransitionInvalid, check, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkPrecondition evaluates one mechanical precondition named by a lifecycle
|
||||
// transition's `requires` list. Soft/operator-confirmed checks pass; unknown
|
||||
// checks are skipped (operator intent overrides). Moved here from httpapi so
|
||||
// both surfaces share one implementation.
|
||||
func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, check string) error {
|
||||
switch check {
|
||||
case "no-inbound-edges":
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx,
|
||||
"SELECT count(*) FROM relationships WHERE target_id = $1 AND valid_to IS NULL", entityID).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("%d inbound relationship edges remaining", count)
|
||||
}
|
||||
case "backups-verified", "secrets-revoked", "ingress-dns-removed":
|
||||
attrs, err := fetchAttrs(ctx, tx, entityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
want := map[string]string{
|
||||
"backups-verified": "backups_verified",
|
||||
"secrets-revoked": "secrets_revoked",
|
||||
"ingress-dns-removed": "ingress_dns_removed",
|
||||
}[check]
|
||||
if !attrTruthy(attrs, want) {
|
||||
return fmt.Errorf("%s not recorded in entity attributes", want)
|
||||
}
|
||||
case "age-key-enrolled-if-needed":
|
||||
if entityType == "workstation" {
|
||||
attrs, err := fetchAttrs(ctx, tx, entityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !attrTruthy(attrs, "age_pubkey") {
|
||||
return fmt.Errorf("age key not enrolled (no age_pubkey in attributes)")
|
||||
}
|
||||
}
|
||||
case "mesh-joined-if-needed":
|
||||
if entityType == "workstation" {
|
||||
attrs, err := fetchAttrs(ctx, tx, entityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !attrTruthy(attrs, "mesh_ip") {
|
||||
return fmt.Errorf("mesh not joined (no mesh_ip in attributes)")
|
||||
}
|
||||
}
|
||||
case "health-check-answering":
|
||||
st, err := sqlcgen.New(tx).GetEntityStatus(ctx, entityID)
|
||||
if err != nil || st.Health == "unknown" || st.Health == "down" {
|
||||
h := "unknown"
|
||||
if err == nil {
|
||||
h = st.Health
|
||||
}
|
||||
return fmt.Errorf("health check not answering (status: %s)", h)
|
||||
}
|
||||
case "doc-page-complete":
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT count(*) FROM relationships r
|
||||
JOIN entities ke ON ke.id = r.source_id
|
||||
WHERE r.target_id = $1 AND r.valid_to IS NULL
|
||||
AND r.type = 'documents' AND ke.type IN ('document','runbook','investigation')`,
|
||||
entityID).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fmt.Errorf("no documentation linked to entity")
|
||||
}
|
||||
case "inventory-entry", "ip-reserved", "storage-pool-chosen", "cancelled-note",
|
||||
"preflight-passed", "error-summary", "replacement-live-or-role-retired",
|
||||
"replacement-failed", "post-verify-passed", "recovery-verified", "written-off",
|
||||
"ingress-live-if-public", "doc-page-stub", "un-deprecate-note", "write-off-note":
|
||||
// Soft checks — always pass. Operator-confirmed via the transition
|
||||
// request itself, or not mechanically enforceable.
|
||||
default:
|
||||
// Unknown preconditions are skipped (operator intent overrides).
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchAttrs loads an entity's JSONB attributes column as a decoded map.
|
||||
// Missing attributes decode to an empty map (every key absent).
|
||||
func fetchAttrs(ctx context.Context, tx pgx.Tx, entityID uuid.UUID) (map[string]any, error) {
|
||||
var raw string
|
||||
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var attrs map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &attrs); err != nil {
|
||||
return nil, fmt.Errorf("decode entity attributes: %w", err)
|
||||
}
|
||||
if attrs == nil {
|
||||
attrs = map[string]any{}
|
||||
}
|
||||
return attrs, nil
|
||||
}
|
||||
|
||||
// attrTruthy reports whether key is present in attrs with a meaningful value.
|
||||
// It replaces substring matching on raw JSONB text: a previous strings.Contains
|
||||
// check treated {"backups_verified": false} as satisfied (the key text was
|
||||
// present) and bypassed the attributes GIN index. Booleans must be true;
|
||||
// strings must be non-empty; nil/absent fail.
|
||||
func attrTruthy(attrs map[string]any, key string) bool {
|
||||
v, ok := attrs[key]
|
||||
if !ok || v == nil {
|
||||
return false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
return t != ""
|
||||
default:
|
||||
return true // numbers, objects, arrays count as present
|
||||
}
|
||||
}
|
||||
55
internal/adapters/postgres/lifecycle_test.go
Normal file
55
internal/adapters/postgres/lifecycle_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// attrTruthy replaces a previous strings.Contains check over raw JSONB text.
|
||||
// The key regression it guards: a literal attribute like
|
||||
// {"backups_verified": false} must NOT satisfy the "backups-verified"
|
||||
// precondition, even though the key text is present in the column.
|
||||
func TestAttrTruthy(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
attrs map[string]any
|
||||
key string
|
||||
want bool
|
||||
}{
|
||||
{"absent", map[string]any{}, "backups_verified", false},
|
||||
{"nil map", nil, "backups_verified", false},
|
||||
{"explicit nil value", map[string]any{"backups_verified": nil}, "backups_verified", false},
|
||||
{"bool true", map[string]any{"backups_verified": true}, "backups_verified", true},
|
||||
{"bool false is the regression case", map[string]any{"backups_verified": false}, "backups_verified", false},
|
||||
{"nonempty string age pubkey", map[string]any{"age_pubkey": "age1abc"}, "age_pubkey", true},
|
||||
{"empty string is falsy", map[string]any{"mesh_ip": ""}, "mesh_ip", false},
|
||||
{"number counts as present", map[string]any{"port": float64(22)}, "port", true},
|
||||
{"other keys present", map[string]any{"backups_verified": true, "unrelated": "x"}, "backups_verified", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := attrTruthy(tc.attrs, tc.key); got != tc.want {
|
||||
t.Fatalf("attrTruthy(%v, %q) = %v, want %v", tc.attrs, tc.key, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAttrs decodes the JSONB column text; verify the decode shape that
|
||||
// attrTruthy then evaluates (the DB round-trip itself is covered by make test-db).
|
||||
func TestAttrTruthyAfterDecode(t *testing.T) {
|
||||
raw := `{"backups_verified": true, "mesh_ip": "10.0.0.5", "secrets_revoked": false}`
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if !attrTruthy(got, "backups_verified") {
|
||||
t.Error("backups_verified should be truthy after decode")
|
||||
}
|
||||
if !attrTruthy(got, "mesh_ip") {
|
||||
t.Error("mesh_ip should be truthy after decode")
|
||||
}
|
||||
if attrTruthy(got, "secrets_revoked") {
|
||||
t.Error("secrets_revoked:false is the regression — must be falsy")
|
||||
}
|
||||
}
|
||||
288
internal/adapters/postgres/pool.go
Normal file
288
internal/adapters/postgres/pool.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/dtoro/oikos/migrations"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Pool wraps a pgx connection pool.
|
||||
type Pool struct {
|
||||
*pgxpool.Pool
|
||||
}
|
||||
|
||||
// New creates a new connection pool.
|
||||
func New(ctx context.Context, databaseURL string) (*Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse database url: %w", err)
|
||||
}
|
||||
cfg.MaxConns = 15
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pool: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
return nil, fmt.Errorf("ping db: %w", err)
|
||||
}
|
||||
return &Pool{pool}, nil
|
||||
}
|
||||
|
||||
// migrationLockKey is the advisory-lock key serializing migration runs —
|
||||
// two concurrent `oikos migrate` invocations must not interleave DDL.
|
||||
const migrationLockKey = 0x01c05e5
|
||||
|
||||
// Migrate runs all embedded forward migrations in order.
|
||||
// Uses a schema_migrations table to track applied versions. The whole run
|
||||
// happens on one connection holding a session advisory lock.
|
||||
func (p *Pool) Migrate(ctx context.Context) error {
|
||||
conn, err := p.Acquire(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire migration conn: %w", err)
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
|
||||
return fmt.Errorf("acquire migration lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if _, err := conn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", migrationLockKey); err != nil {
|
||||
slog.Warn("postgres: release migration lock failed", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Create tracking table if not exists
|
||||
_, err = conn.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
// List migration files
|
||||
entries, err := fs.ReadDir(migrations.FS, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration fs: %w", err)
|
||||
}
|
||||
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && hasSuffix(e.Name(), ".up.sql") {
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
for _, fname := range files {
|
||||
// Extract version number (001, 002, etc.)
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(fname, "%03d", &version); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if already applied
|
||||
var applied int
|
||||
err := conn.QueryRow(ctx,
|
||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = $1", version).Scan(&applied)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check migration %d: %w", version, err)
|
||||
}
|
||||
if applied > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Read and execute migration — split into individual statements
|
||||
// because TimescaleDB CAGGs and some DDL can't run inside a transaction,
|
||||
// and pgx's multi-statement Exec wraps them implicitly.
|
||||
content, err := migrations.FS.ReadFile(fname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", fname, err)
|
||||
}
|
||||
|
||||
stmts := splitSQL(string(content))
|
||||
for i, stmt := range stmts {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
_, err := conn.Exec(ctx, stmt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("exec migration %s stmt %d: %w", fname, i+1, err)
|
||||
}
|
||||
}
|
||||
_, err = conn.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record migration %d: %w", version, err)
|
||||
}
|
||||
slog.Info("migration applied", "file", fname, "version", version, "statements", len(stmts))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedIngest ingests a YAML seed file into the database.
|
||||
// Idempotent: if the file's content hash matches seed_versions, it's a no-op (A4).
|
||||
func (p *Pool) SeedIngest(ctx context.Context, filename string, content []byte,
|
||||
ingestFn func(ctx context.Context, tx pgx.Tx, data map[string]any) error) error {
|
||||
|
||||
hash := contentHash(content)
|
||||
|
||||
// Check if already applied with same hash
|
||||
var existing string
|
||||
err := p.QueryRow(ctx,
|
||||
"SELECT content_hash FROM seed_versions WHERE file = $1", filename).Scan(&existing)
|
||||
if err == nil && existing == hash {
|
||||
return nil // no-op, same content
|
||||
}
|
||||
|
||||
// Parse YAML
|
||||
var data map[string]any
|
||||
if err := yaml.Unmarshal(content, &data); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", filename, err)
|
||||
}
|
||||
|
||||
// Apply in a single transaction
|
||||
tx, err := p.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(ctx); err != nil {
|
||||
slog.Debug("postgres: rollback after failed ingest", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := ingestFn(ctx, tx, data); err != nil {
|
||||
return fmt.Errorf("ingest %s: %w", filename, err)
|
||||
}
|
||||
|
||||
// Record the seed version
|
||||
_, err = tx.Exec(ctx,
|
||||
`INSERT INTO seed_versions (file, content_hash) VALUES ($1, $2)
|
||||
ON CONFLICT (file) DO UPDATE SET content_hash = $2, applied_at = now()`,
|
||||
filename, hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record seed version: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit seed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// contentHash returns a SHA-256 hex digest of the content.
|
||||
func contentHash(content []byte) string {
|
||||
h := sha256.Sum256(content)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// hasSuffix reports whether the string ends with the given suffix.
|
||||
func hasSuffix(s, suffix string) bool {
|
||||
return strings.HasSuffix(s, suffix)
|
||||
}
|
||||
|
||||
// splitSQL splits a SQL string into individual statements.
|
||||
// Handles $$ ... $$ dollar-quoted blocks, $tag$ ... $tag$ tagged quotes,
|
||||
// -- line comments, /* ... */ block comments, and '...' string literals
|
||||
// so that semicolons inside any of these constructs are not treated as
|
||||
// statement boundaries.
|
||||
func splitSQL(sql string) []string {
|
||||
var statements []string
|
||||
var current strings.Builder
|
||||
inDollarQuote := false
|
||||
dollarTag := ""
|
||||
|
||||
i := 0
|
||||
for i < len(sql) {
|
||||
// Handle line comments (-- to end of line)
|
||||
if !inDollarQuote && i+1 < len(sql) && sql[i] == '-' && sql[i+1] == '-' {
|
||||
for i < len(sql) && sql[i] != '\n' {
|
||||
current.WriteByte(sql[i])
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle block comments (/* ... */)
|
||||
if !inDollarQuote && i+1 < len(sql) && sql[i] == '/' && sql[i+1] == '*' {
|
||||
end := strings.Index(sql[i+2:], "*/")
|
||||
if end >= 0 {
|
||||
current.WriteString(sql[i : i+end+4])
|
||||
i += end + 4
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Handle single-quoted string literals ('...')
|
||||
if !inDollarQuote && sql[i] == '\'' {
|
||||
j := i + 1
|
||||
for j < len(sql) {
|
||||
if sql[j] == '\'' {
|
||||
if j+1 < len(sql) && sql[j+1] == '\'' {
|
||||
j += 2 // skip doubled quote ''
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
current.WriteString(sql[i : j+1])
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for dollar-quote start/end
|
||||
if !inDollarQuote && sql[i] == '$' {
|
||||
j := i + 1
|
||||
for j < len(sql) && (sql[j] == '_' || (sql[j] >= 'a' && sql[j] <= 'z') || (sql[j] >= 'A' && sql[j] <= 'Z') || (sql[j] >= '0' && sql[j] <= '9')) {
|
||||
j++
|
||||
}
|
||||
if j < len(sql) && sql[j] == '$' {
|
||||
dollarTag = sql[i : j+1]
|
||||
current.WriteString(dollarTag)
|
||||
inDollarQuote = true
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
if inDollarQuote && strings.HasPrefix(sql[i:], dollarTag) {
|
||||
current.WriteString(dollarTag)
|
||||
i += len(dollarTag)
|
||||
inDollarQuote = false
|
||||
dollarTag = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if !inDollarQuote && sql[i] == ';' {
|
||||
statements = append(statements, current.String())
|
||||
current.Reset()
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
current.WriteByte(sql[i])
|
||||
i++
|
||||
}
|
||||
|
||||
if strings.TrimSpace(current.String()) != "" {
|
||||
statements = append(statements, current.String())
|
||||
}
|
||||
|
||||
return statements
|
||||
}
|
||||
79
internal/adapters/postgres/queries/entities.sql
Normal file
79
internal/adapters/postgres/queries/entities.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
-- Entity read + mutation queries (API paths). Aliased `e` throughout to
|
||||
-- avoid ambiguity with joined tables.
|
||||
|
||||
-- name: GetEntityByID :one
|
||||
SELECT e.* FROM entities e WHERE e.id = $1;
|
||||
|
||||
-- name: GetEntityBySlug :one
|
||||
SELECT e.* FROM entities e WHERE e.slug = $1;
|
||||
|
||||
-- name: ListEntities :many
|
||||
WITH RECURSIVE tt AS (
|
||||
SELECT name FROM entity_types WHERE sqlc.narg('type')::text IS NULL OR name = sqlc.narg('type')
|
||||
UNION
|
||||
SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name
|
||||
WHERE sqlc.narg('type')::text IS NOT NULL
|
||||
)
|
||||
SELECT e.* FROM entities e
|
||||
JOIN entity_types et ON et.name = e.type
|
||||
WHERE e.type IN (SELECT name FROM tt)
|
||||
AND (sqlc.narg('state')::text IS NULL OR e.state = sqlc.narg('state'))
|
||||
AND (sqlc.narg('domain')::text IS NULL OR et.domain = sqlc.narg('domain'))
|
||||
AND (sqlc.narg('layer')::text IS NULL OR et.layer = sqlc.narg('layer'))
|
||||
AND (sqlc.narg('q')::text IS NULL
|
||||
OR e.slug ILIKE '%'||sqlc.narg('q')||'%'
|
||||
OR e.name ILIKE '%'||sqlc.narg('q')||'%')
|
||||
AND (sqlc.narg('cursor')::text IS NULL OR e.slug > sqlc.narg('cursor'))
|
||||
ORDER BY e.slug
|
||||
LIMIT sqlc.arg('lim');
|
||||
|
||||
-- name: InsertEntity :one
|
||||
INSERT INTO entities (id, slug, type, name, state, attributes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateEntity :one
|
||||
UPDATE entities SET
|
||||
name = COALESCE(sqlc.narg('name'), name),
|
||||
state = COALESCE(sqlc.narg('state'), state),
|
||||
attributes = COALESCE(sqlc.narg('attributes'), attributes),
|
||||
maintenance_until = CASE WHEN sqlc.arg('set_maintenance')::bool
|
||||
THEN sqlc.narg('maintenance_until') ELSE maintenance_until END,
|
||||
version = version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg('id') AND version = sqlc.arg('version')
|
||||
RETURNING *;
|
||||
|
||||
-- name: MergeEntityAttributes :execrows
|
||||
-- Shallow-merge a JSON patch into an entity's attributes (the
|
||||
-- update_entity_attributes MCP/HTTP surface). Replaces the raw
|
||||
-- `attributes = attributes || $2::jsonb` used in entity_tools.go.
|
||||
UPDATE entities SET
|
||||
attributes = attributes || sqlc.arg('patch')::jsonb,
|
||||
updated_at = now()
|
||||
WHERE slug = sqlc.arg('slug');
|
||||
|
||||
-- name: SetEntityState :execrows
|
||||
-- Set an entity's lifecycle state by id (the set_entity_state surface, run
|
||||
-- after db.ValidateTransition). Replaces the raw
|
||||
-- `UPDATE entities SET state = $2 ... WHERE id = $1`.
|
||||
UPDATE entities SET
|
||||
state = sqlc.arg('state'),
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg('id');
|
||||
|
||||
-- blast_radius(): the recursive-CTE traversal function's TABLE return type
|
||||
-- is opaque to sqlc's analyzer — that one query stays hand-written pgx in
|
||||
-- internal/httpapi (see entities.go GetBlastRadius).
|
||||
--
|
||||
-- Deliberate raw-SQL exceptions (plan E2): the httpapi entity *read* handlers
|
||||
-- (ListEntities/GetEntity/GetGraph/queryEntities) project a fixed
|
||||
-- `entityCols` column set (entities.* + a LEFT JOIN to entity_status for
|
||||
-- health/last_check_at) and scan it positionally into the oapi-generated
|
||||
-- gen.Entity shape. sqlc generates its own row struct per query and cannot
|
||||
-- emit gen.Entity, so migrating those reads would add a per-call field-by-
|
||||
-- field mapping with no compile-time gain and real column-order risk. They
|
||||
-- stay hand-written pgx, like blast_radius and the seed/export bulk paths
|
||||
-- noted in sqlc.yaml. The mutation/relationship surface (MergeEntityAttributes,
|
||||
-- SetEntityState, InsertRelationshipIfAbsent, EndCurrentRelationship) IS
|
||||
-- migrated and is what the entity CRUD tools now call.
|
||||
13
internal/adapters/postgres/queries/ontology.sql
Normal file
13
internal/adapters/postgres/queries/ontology.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- name: ListEntityTypes :many
|
||||
SELECT * FROM entity_types ORDER BY name;
|
||||
|
||||
-- name: ListRelationshipTypes :many
|
||||
SELECT * FROM relationship_types ORDER BY name;
|
||||
|
||||
-- name: ListLifecycleDefs :many
|
||||
SELECT * FROM lifecycle_defs ORDER BY id;
|
||||
|
||||
-- name: GetLifecycleForType :one
|
||||
SELECT ld.* FROM lifecycle_defs ld
|
||||
JOIN entity_types et ON et.lifecycle_id = ld.id
|
||||
WHERE et.name = $1;
|
||||
269
internal/adapters/postgres/queries/operations.sql
Normal file
269
internal/adapters/postgres/queries/operations.sql
Normal file
@@ -0,0 +1,269 @@
|
||||
-- name: ListSignals :many
|
||||
SELECT sig.entity_id, se.slug, sig.kind, sig.severity, sig.state,
|
||||
te.slug AS target_slug, sig.check_id, sig.evidence, sig.likely_cause,
|
||||
sig.occurrence_count, sig.flap_count, sig.hold_down_until,
|
||||
sig.mute_until, sig.first_seen_at, sig.last_seen_at
|
||||
FROM signals sig
|
||||
JOIN entities se ON se.id = sig.entity_id
|
||||
LEFT JOIN entities te ON te.id = sig.target_entity_id
|
||||
WHERE (sqlc.narg('state')::text IS NULL OR sig.state = sqlc.narg('state'))
|
||||
AND (sqlc.narg('severity')::text IS NULL OR sig.severity = sqlc.narg('severity'))
|
||||
AND (sqlc.narg('target')::text IS NULL OR te.slug = sqlc.narg('target'))
|
||||
AND (sqlc.narg('kind')::text IS NULL OR sig.kind = sqlc.narg('kind'))
|
||||
AND (sqlc.narg('cursor')::text IS NULL OR se.slug > sqlc.narg('cursor'))
|
||||
ORDER BY se.slug
|
||||
LIMIT sqlc.arg('lim');
|
||||
|
||||
-- name: GetIdempotentResponse :one
|
||||
SELECT response_code, response_body, request_hash FROM idempotency_keys
|
||||
WHERE actor = $1 AND key = $2;
|
||||
|
||||
-- name: PutIdempotentResponse :exec
|
||||
INSERT INTO idempotency_keys (actor, key, request_hash, response_code, response_body)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (actor, key) DO NOTHING;
|
||||
|
||||
-- name: InsertAuditEntry :exec
|
||||
INSERT INTO audit_log (actor_type, actor_id, action, entity_id, method, path,
|
||||
status_code, detail, source_ip, correlation_id, session_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
|
||||
|
||||
-- name: InsertEvent :one
|
||||
INSERT INTO events (type, entity_id, severity, source, data, correlation_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, ts;
|
||||
|
||||
-- name: ListEvents :many
|
||||
SELECT id, ts, type, entity_id, severity, source, data, correlation_id
|
||||
FROM events
|
||||
WHERE (sqlc.narg('type')::text IS NULL OR type = sqlc.narg('type'))
|
||||
AND (sqlc.narg('entity_id')::uuid IS NULL OR entity_id = sqlc.narg('entity_id'))
|
||||
AND (sqlc.narg('severity')::text IS NULL OR severity = sqlc.narg('severity'))
|
||||
AND (sqlc.narg('correlation_id')::text IS NULL OR correlation_id = sqlc.narg('correlation_id'))
|
||||
AND (sqlc.narg('from_ts')::timestamptz IS NULL OR ts >= sqlc.narg('from_ts'))
|
||||
AND (sqlc.narg('to_ts')::timestamptz IS NULL OR ts <= sqlc.narg('to_ts'))
|
||||
AND (sqlc.narg('before_id')::bigint IS NULL OR id < sqlc.narg('before_id'))
|
||||
ORDER BY id DESC
|
||||
LIMIT sqlc.arg('lim');
|
||||
|
||||
-- name: ListEventsAfter :many
|
||||
SELECT id, ts, type, entity_id, severity, source, data, correlation_id
|
||||
FROM events WHERE id > $1 ORDER BY id ASC LIMIT $2;
|
||||
|
||||
-- =====================================================================
|
||||
-- Phase 3 queries
|
||||
-- =====================================================================
|
||||
|
||||
-- name: ListEnabledCheckDefs :many
|
||||
-- Enabled AND due. interval_s used to be selected but never filtered on, so
|
||||
-- every check ran on every 30s pass and the declared intervals meant nothing.
|
||||
-- NULL last_run_at = never run = due now.
|
||||
SELECT cd.entity_id, cd.target_id, cd.target_type, cd.kind, cd.config,
|
||||
cd.interval_s, cd.timeout_s, cd.zone, cd.enabled, cd.updated_at,
|
||||
e.slug AS entity_slug
|
||||
FROM check_defs cd
|
||||
JOIN entities e ON e.id = cd.entity_id
|
||||
LEFT JOIN entities tgt ON tgt.id = cd.target_id
|
||||
WHERE cd.enabled = true
|
||||
AND (tgt.id IS NULL OR tgt.state IS NULL OR tgt.state NOT IN ('deprecated', 'destroyed'))
|
||||
AND (cd.last_run_at IS NULL
|
||||
OR cd.last_run_at <= now() - make_interval(secs => cd.interval_s));
|
||||
|
||||
-- name: MarkCheckRun :exec
|
||||
UPDATE check_defs SET last_run_at = now(), last_health = $2 WHERE entity_id = $1;
|
||||
|
||||
-- name: WorstHealthForTarget :one
|
||||
-- An entity is as healthy as its unhealthiest check. Checks that have not run
|
||||
-- yet (last_health IS NULL) are ignored rather than counted as unknown, so a
|
||||
-- newly added check does not drag a known-good entity down before it has
|
||||
-- produced a verdict.
|
||||
SELECT COALESCE(
|
||||
(SELECT last_health FROM check_defs
|
||||
WHERE enabled AND target_id = $1 AND last_health IS NOT NULL
|
||||
ORDER BY CASE last_health
|
||||
WHEN 'down' THEN 0 WHEN 'degraded' THEN 1 WHEN 'stale' THEN 2
|
||||
WHEN 'unknown' THEN 3 ELSE 4 END
|
||||
LIMIT 1),
|
||||
'unknown')::text AS health;
|
||||
|
||||
-- name: GetCheckDef :one
|
||||
SELECT * FROM check_defs WHERE entity_id = $1;
|
||||
|
||||
-- name: InsertCheckDef :exec
|
||||
INSERT INTO check_defs (entity_id, target_id, target_type, kind, config, interval_s, timeout_s, zone, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);
|
||||
|
||||
-- name: UpdateCheckDef :exec
|
||||
UPDATE check_defs SET kind = $2, config = $3, interval_s = $4, timeout_s = $5,
|
||||
target_id = $6, target_type = $7, zone = $8, enabled = $9, updated_at = now()
|
||||
WHERE entity_id = $1;
|
||||
|
||||
-- name: UpsertSignal :one
|
||||
INSERT INTO signals (entity_id, kind, severity, target_entity_id, check_id, evidence, likely_cause, state)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'raised')
|
||||
ON CONFLICT (target_entity_id, kind) WHERE state NOT IN ('resolved','failed')
|
||||
DO UPDATE SET occurrence_count = signals.occurrence_count + 1,
|
||||
last_seen_at = now(),
|
||||
evidence = EXCLUDED.evidence,
|
||||
updated_at = now()
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetOpenSignalsForAutoAct :many
|
||||
-- Signals with auto-act classifications that haven't been executed yet
|
||||
SELECT s.*, c.entity_id AS classification_id, c.action, c.risk_class, c.route,
|
||||
c.blast_radius, c.correlation_id, c.reasoning
|
||||
FROM classifications c
|
||||
JOIN signals s ON s.entity_id = c.signal_entity_id
|
||||
LEFT JOIN executions e ON e.classification_id = c.entity_id
|
||||
WHERE c.route = 'auto-act'
|
||||
AND e.entity_id IS NULL
|
||||
AND (s.hold_down_until IS NULL OR s.hold_down_until < now())
|
||||
AND (s.mute_until IS NULL OR s.mute_until < now())
|
||||
ORDER BY s.last_seen_at ASC
|
||||
LIMIT $1;
|
||||
|
||||
-- name: ListClassifications :many
|
||||
SELECT c.entity_id, c.signal_entity_id, c.target_entity_id, c.action,
|
||||
c.recommended_action, c.risk_class, c.route, c.blast_radius,
|
||||
c.pattern_confidence, c.skill_id, c.autonomy_check, c.reasoning,
|
||||
c.correlation_id, c.created_at,
|
||||
e.slug AS target_slug
|
||||
FROM classifications c
|
||||
JOIN entities e ON e.id = c.target_entity_id
|
||||
WHERE (sqlc.narg('route')::text IS NULL OR c.route = sqlc.narg('route'))
|
||||
AND (sqlc.narg('cursor')::text IS NULL OR e.slug > sqlc.narg('cursor'))
|
||||
ORDER BY e.slug
|
||||
LIMIT sqlc.arg('lim');
|
||||
|
||||
-- name: InsertExecution :exec
|
||||
INSERT INTO executions (entity_id, classification_id, signal_entity_id,
|
||||
target_entity_id, action, risk_class, approval_id, agent_id,
|
||||
skill_id, skill_version, status, correlation_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'proposed', $11);
|
||||
|
||||
-- name: UpdateExecutionStatus :exec
|
||||
UPDATE executions SET status = $2, result = $3, duration_ms = $4,
|
||||
verified = $5, started_at = COALESCE(started_at, now()),
|
||||
completed_at = CASE WHEN $2 IN ('completed','failed','cancelled') THEN now() ELSE completed_at END
|
||||
WHERE entity_id = $1;
|
||||
|
||||
-- name: GetExecution :one
|
||||
SELECT * FROM executions WHERE entity_id = $1;
|
||||
|
||||
-- name: ListExecutions :many
|
||||
SELECT e.entity_id, e.classification_id, e.signal_entity_id, e.target_entity_id,
|
||||
e.action, e.risk_class, e.approval_id, e.agent_id,
|
||||
e.skill_id, e.skill_version, e.status, e.result, e.duration_ms,
|
||||
e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
|
||||
te.slug AS target_slug
|
||||
FROM executions e
|
||||
JOIN entities te ON te.id = e.target_entity_id
|
||||
WHERE (sqlc.narg('status')::text IS NULL OR e.status = sqlc.narg('status'))
|
||||
AND (sqlc.narg('cursor')::text IS NULL OR te.slug > sqlc.narg('cursor'))
|
||||
ORDER BY te.slug
|
||||
LIMIT sqlc.arg('lim');
|
||||
|
||||
-- name: GetFeedbackAfterWatermark :many
|
||||
SELECT f.entity_id, f.execution_id, f.outcome, f.observation, f.lesson,
|
||||
f.unexpected_side_effects, f.tags, f.created_at,
|
||||
e.action, e.risk_class, e.target_entity_id,
|
||||
et.name AS applies_type
|
||||
FROM feedback f
|
||||
JOIN executions e ON e.entity_id = f.execution_id
|
||||
JOIN entities ent ON ent.id = e.target_entity_id
|
||||
JOIN entity_types et ON et.name = ent.type
|
||||
WHERE f.created_at > $1
|
||||
ORDER BY f.created_at ASC;
|
||||
|
||||
-- name: UpsertPattern :exec
|
||||
INSERT INTO patterns (entity_id, applies_type, action, pattern, confidence,
|
||||
evidence_count, success_count, failure_count, status, version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'hypothesized', 1)
|
||||
ON CONFLICT (applies_type, action)
|
||||
DO UPDATE SET evidence_count = patterns.evidence_count + EXCLUDED.evidence_count,
|
||||
success_count = patterns.success_count + EXCLUDED.success_count,
|
||||
failure_count = patterns.failure_count + EXCLUDED.failure_count,
|
||||
updated_at = now();
|
||||
|
||||
-- name: GetPattern :one
|
||||
SELECT * FROM patterns WHERE applies_type = $1 AND action = $2;
|
||||
|
||||
-- name: ListPatterns :many
|
||||
SELECT p.* FROM patterns p
|
||||
WHERE (sqlc.narg('status')::text IS NULL OR p.status = sqlc.narg('status'))
|
||||
ORDER BY p.applies_type, p.action;
|
||||
|
||||
-- name: UpdatePatternStatus :exec
|
||||
UPDATE patterns SET status = $2, version = version + 1,
|
||||
last_validated_at = CASE WHEN $2 = 'validated' THEN now() ELSE last_validated_at END
|
||||
WHERE entity_id = $1;
|
||||
|
||||
-- name: UpdatePatternQuarantine :exec
|
||||
UPDATE patterns SET quarantined = $2 WHERE entity_id = $1;
|
||||
|
||||
-- name: ListSkills :many
|
||||
SELECT * FROM skills
|
||||
WHERE (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
|
||||
ORDER BY name, version DESC;
|
||||
|
||||
-- name: UpdateSkillStatus :exec
|
||||
UPDATE skills SET status = $2, last_used_at = now() WHERE entity_id = $1 AND version = $2;
|
||||
|
||||
-- name: InsertApproval :exec
|
||||
INSERT INTO approvals (entity_id, subject_entity_id, action, risk_class, kind,
|
||||
payload, status, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7, $8);
|
||||
|
||||
-- name: ListApprovals :many
|
||||
SELECT a.*, e.slug AS subject_slug
|
||||
FROM approvals a
|
||||
JOIN entities e ON e.id = a.subject_entity_id
|
||||
WHERE (sqlc.narg('status')::text IS NULL OR a.status = sqlc.narg('status'))
|
||||
AND (sqlc.narg('cursor')::text IS NULL OR e.slug > sqlc.narg('cursor'))
|
||||
ORDER BY e.slug
|
||||
LIMIT sqlc.arg('lim');
|
||||
|
||||
-- name: GetApprovalByID :one
|
||||
SELECT * FROM approvals WHERE entity_id = $1;
|
||||
|
||||
-- name: UpdateApprovalStatus :exec
|
||||
UPDATE approvals SET status = $2, decided_at = now(), decided_by = $3
|
||||
WHERE entity_id = $1 AND status = 'pending';
|
||||
|
||||
-- name: GetAutonomySetting :one
|
||||
SELECT value FROM autonomy_settings WHERE key = $1;
|
||||
|
||||
-- name: ListRiskClasses :many
|
||||
SELECT * FROM risk_classes ORDER BY name;
|
||||
|
||||
-- name: ListApprovalRules :many
|
||||
SELECT * FROM approval_rules ORDER BY entity_type, action;
|
||||
|
||||
-- name: InsertMetricSample :exec
|
||||
INSERT INTO metric_samples (entity_id, metric, value, tags, ts)
|
||||
VALUES ($1, $2, $3, $4, now());
|
||||
|
||||
-- name: QueryMetrics :many
|
||||
SELECT time_bucket(sqlc.arg('bucket_interval')::interval, ts) AS bucket,
|
||||
entity_id, metric,
|
||||
ROUND(avg(value)::numeric, 2) AS avg_val,
|
||||
ROUND(min(value)::numeric, 2) AS min_val,
|
||||
ROUND(max(value)::numeric, 2) AS max_val
|
||||
FROM metric_samples
|
||||
WHERE entity_id = $1
|
||||
AND metric = $2
|
||||
AND ts > $3
|
||||
GROUP BY bucket, entity_id, metric
|
||||
ORDER BY bucket DESC;
|
||||
|
||||
-- name: UpsertEntityStatus :exec
|
||||
INSERT INTO entity_status (entity_id, health, last_check_at, details)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (entity_id)
|
||||
DO UPDATE SET health = EXCLUDED.health,
|
||||
last_check_at = EXCLUDED.last_check_at,
|
||||
details = EXCLUDED.details,
|
||||
updated_at = now();
|
||||
|
||||
-- name: GetEntityStatus :one
|
||||
SELECT * FROM entity_status WHERE entity_id = $1;
|
||||
42
internal/adapters/postgres/queries/relationships.sql
Normal file
42
internal/adapters/postgres/queries/relationships.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- name: ListEntityRelations :many
|
||||
SELECT se.slug AS source_slug, te.slug AS target_slug, r.type, r.attributes,
|
||||
r.valid_from, r.valid_to
|
||||
FROM relationships r
|
||||
JOIN entities se ON se.id = r.source_id
|
||||
JOIN entities te ON te.id = r.target_id
|
||||
WHERE r.valid_to IS NULL
|
||||
AND ((sqlc.arg('direction')::text IN ('out','both') AND r.source_id = sqlc.arg('id'))
|
||||
OR (sqlc.arg('direction')::text IN ('in','both') AND r.target_id = sqlc.arg('id')))
|
||||
AND (sqlc.narg('rel_type')::text IS NULL OR r.type = sqlc.narg('rel_type'))
|
||||
ORDER BY r.type, se.slug, te.slug;
|
||||
|
||||
-- name: ListGraphEdges :many
|
||||
SELECT se.slug AS source_slug, te.slug AS target_slug, r.type, r.attributes,
|
||||
r.valid_from, r.valid_to
|
||||
FROM relationships r
|
||||
JOIN entities se ON se.id = r.source_id
|
||||
JOIN entities te ON te.id = r.target_id
|
||||
WHERE r.valid_to IS NULL
|
||||
AND r.source_id = ANY(sqlc.arg('ids')::uuid[])
|
||||
AND r.target_id = ANY(sqlc.arg('ids')::uuid[])
|
||||
AND (sqlc.narg('rel_types')::text[] IS NULL OR r.type = ANY(sqlc.narg('rel_types')::text[]))
|
||||
ORDER BY r.type, se.slug, te.slug;
|
||||
|
||||
-- name: EndCurrentRelationship :execrows
|
||||
UPDATE relationships SET valid_to = now()
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL;
|
||||
|
||||
-- name: InsertRelationshipIfAbsent :execrows
|
||||
-- Idempotent relationship insert (the create_relationship surface): no-op if
|
||||
-- an active edge of the same source/target/type already exists. Replaces the
|
||||
-- raw INSERT...WHERE NOT EXISTS used in entity_tools.go.
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT sqlc.arg('source_id'), sqlc.arg('target_id'), sqlc.arg('type'),
|
||||
sqlc.arg('attributes')::jsonb, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = sqlc.arg('source_id')
|
||||
AND target_id = sqlc.arg('target_id')
|
||||
AND type = sqlc.arg('type')
|
||||
AND valid_to IS NULL
|
||||
);
|
||||
486
internal/adapters/postgres/seed.go
Normal file
486
internal/adapters/postgres/seed.go
Normal file
@@ -0,0 +1,486 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/dtoro/oikos/internal/checkdefaults"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// SeedResult holds counts from a seed ingest operation.
|
||||
type SeedResult struct {
|
||||
Lifecycles int
|
||||
EntityTypes int
|
||||
RelationshipTypes int
|
||||
Entities int
|
||||
Relationships int
|
||||
RiskClasses int
|
||||
ApprovalRules int
|
||||
AutonomySettings int
|
||||
Checks int
|
||||
}
|
||||
|
||||
// IngestOntologySeed ingests seeds/ontology.yaml into the DB.
|
||||
func IngestOntologySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*SeedResult, error) {
|
||||
r := &SeedResult{}
|
||||
|
||||
// Lifecycles
|
||||
lifecycles, _ := data["lifecycles"].(map[string]any)
|
||||
for id, raw := range lifecycles {
|
||||
lcMap, _ := raw.(map[string]any)
|
||||
states := toStringSlice(lcMap["states"])
|
||||
defaultState, _ := lcMap["default_state"].(string)
|
||||
terminalStates := toStringSlice(lcMap["terminal_states"])
|
||||
if len(terminalStates) == 0 {
|
||||
terminalStates = []string{}
|
||||
}
|
||||
transitionsBytes, _ := json.Marshal(lcMap["transitions"])
|
||||
_, err := tx.Exec(ctx,
|
||||
`INSERT INTO lifecycle_defs (id, states, default_state, terminal_states, transitions)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (id) DO UPDATE SET states = $2, default_state = $3,
|
||||
terminal_states = $4, transitions = $5`,
|
||||
id, states, defaultState, terminalStates, string(transitionsBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lifecycle %s: %w", id, err)
|
||||
}
|
||||
r.Lifecycles++
|
||||
}
|
||||
|
||||
// Entity types — need to handle parent_type FK, so insert in dependency order
|
||||
// (types with no parent first, then their children)
|
||||
types, _ := data["entity_types"].(map[string]any)
|
||||
if err := insertEntityTypes(ctx, tx, types, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Relationship types
|
||||
relTypes, _ := data["relationship_types"].(map[string]any)
|
||||
for name, raw := range relTypes {
|
||||
rtMap, _ := raw.(map[string]any)
|
||||
inverse, _ := rtMap["inverse"].(string)
|
||||
sourceType, _ := rtMap["source"].(string)
|
||||
targetType, _ := rtMap["target"].(string)
|
||||
cardinality, _ := rtMap["cardinality"].(string)
|
||||
desc, _ := rtMap["description"].(string)
|
||||
// Which end of the edge depends on the other; drives blast_radius().
|
||||
// Absent means 'none' — an undeclared edge contributes nothing rather
|
||||
// than silently producing a wrong dependency answer.
|
||||
blastDirection, _ := rtMap["blast_direction"].(string)
|
||||
if blastDirection == "" {
|
||||
blastDirection = "none"
|
||||
}
|
||||
_, err := tx.Exec(ctx,
|
||||
`INSERT INTO relationship_types (name, inverse, source_type, target_type, cardinality, description, blast_direction)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (name) DO UPDATE SET inverse = $2, source_type = $3,
|
||||
target_type = $4, cardinality = $5, description = $6,
|
||||
blast_direction = $7`,
|
||||
name, nullableStr(inverse), sourceType, targetType, cardinality, desc, blastDirection)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("relationship_type %s: %w", name, err)
|
||||
}
|
||||
r.RelationshipTypes++
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// IngestInventorySeed ingests seeds/inventory.yaml into the DB.
|
||||
// Every entity and edge is validated against the ontology (abstract types
|
||||
// rejected, lifecycle states checked, relationship endpoints hierarchy-
|
||||
// validated, cardinality enforced) — a violating seed rolls back atomically.
|
||||
func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*SeedResult, error) {
|
||||
r := &SeedResult{}
|
||||
|
||||
tree, err := LoadTypeTree(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load type tree: %w", err)
|
||||
}
|
||||
|
||||
// Entities
|
||||
entities, _ := data["entities"].([]any)
|
||||
entityTypes := make(map[string]string) // slug -> type, for edge validation
|
||||
var pendingChecks []checkdefaults.Target
|
||||
for _, raw := range entities {
|
||||
eMap, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
slug, _ := eMap["slug"].(string)
|
||||
typeName, _ := eMap["type"].(string)
|
||||
name, _ := eMap["name"].(string)
|
||||
state, _ := eMap["state"].(string)
|
||||
attrs := eMap["attributes"]
|
||||
|
||||
if err := tree.ValidateEntity(typeName, state); err != nil {
|
||||
return nil, fmt.Errorf("entity %s: %w", slug, err)
|
||||
}
|
||||
if state == "" {
|
||||
state = tree.DefaultState(typeName)
|
||||
}
|
||||
entityTypes[slug] = typeName
|
||||
|
||||
entityID, err := getOrCreateEntityID(ctx, tx, slug)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("entity %s: %w", slug, err)
|
||||
}
|
||||
|
||||
attrsBytes, _ := json.Marshal(attrs)
|
||||
_, err = tx.Exec(ctx,
|
||||
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 1, now(), now())
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
type = EXCLUDED.type, name = EXCLUDED.name,
|
||||
attributes = entities.attributes || EXCLUDED.attributes,
|
||||
updated_at = now()`,
|
||||
entityID, slug, typeName, name, nullableStr(state), string(attrsBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("entity %s: %w", slug, err)
|
||||
}
|
||||
|
||||
// Seed initial entity_status row so health queries return
|
||||
// results even before the scheduler populates check results.
|
||||
_, err = tx.Exec(ctx,
|
||||
`INSERT INTO entity_status (entity_id, health, updated_at)
|
||||
VALUES ($1, 'unknown', now())
|
||||
ON CONFLICT (entity_id) DO NOTHING`,
|
||||
entityID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("entity_status %s: %w", slug, err)
|
||||
}
|
||||
|
||||
// Default checks are deferred until after relationships are ingested:
|
||||
// a service has no address of its own and inherits its container's,
|
||||
// which means the hosting edge has to exist first.
|
||||
pendingChecks = append(pendingChecks, checkdefaults.Target{
|
||||
ID: entityID, Slug: slug, Type: typeName, Name: name, Attrs: attrsBytes,
|
||||
})
|
||||
|
||||
r.Entities++
|
||||
}
|
||||
|
||||
// Relationships — upsert against the current-edge partial unique index
|
||||
// (migration 007) so re-ingest never duplicates edges.
|
||||
rels, _ := data["relationships"].([]any)
|
||||
for _, raw := range rels {
|
||||
relMap, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
source, _ := relMap["source"].(string)
|
||||
target, _ := relMap["target"].(string)
|
||||
relType, _ := relMap["type"].(string)
|
||||
attrs := relMap["attributes"]
|
||||
|
||||
sourceID, err := getEntityIDBySlug(ctx, tx, source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rel from %s: %w", source, err)
|
||||
}
|
||||
targetID, err := getEntityIDBySlug(ctx, tx, target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rel to %s: %w", target, err)
|
||||
}
|
||||
|
||||
srcType := entityTypes[source]
|
||||
tgtType := entityTypes[target]
|
||||
if srcType == "" || tgtType == "" { // entity pre-existing in DB, not in this seed
|
||||
if srcType == "" {
|
||||
srcType, err = getEntityTypeBySlug(ctx, tx, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if tgtType == "" {
|
||||
tgtType, err = getEntityTypeBySlug(ctx, tx, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tree.ValidateEdge(relType, srcType, tgtType); err != nil {
|
||||
return nil, fmt.Errorf("rel %s→%s: %w", source, target, err)
|
||||
}
|
||||
|
||||
attrsBytes, _ := json.Marshal(attrs)
|
||||
_, err = tx.Exec(ctx,
|
||||
`INSERT INTO relationships (source_id, target_id, type, attributes, valid_from, valid_to)
|
||||
VALUES ($1, $2, $3, $4, now(), NULL)
|
||||
ON CONFLICT (source_id, target_id, type) WHERE valid_to IS NULL
|
||||
DO UPDATE SET attributes = EXCLUDED.attributes`,
|
||||
sourceID, targetID, relType, string(attrsBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rel %s→%s %s: %w", source, target, relType, err)
|
||||
}
|
||||
r.Relationships++
|
||||
}
|
||||
|
||||
if err := ValidateCardinality(ctx, tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Default checks, now that hosting edges exist. Errors here are fatal:
|
||||
// swallowing them is what let a foreign-key violation abort the ingest
|
||||
// transaction while surfacing as an unrelated failure several entities
|
||||
// later.
|
||||
for _, target := range pendingChecks {
|
||||
res, err := checkdefaults.Ensure(ctx, tx, tree, target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("default checks for %s: %w", target.Slug, err)
|
||||
}
|
||||
checkdefaults.LogResult(target.Slug, target.Type, res)
|
||||
r.Checks += res.Created
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// IngestPolicySeed ingests seeds/policy.yaml into the DB.
|
||||
func IngestPolicySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*SeedResult, error) {
|
||||
r := &SeedResult{}
|
||||
|
||||
// Risk classes
|
||||
riskClasses, _ := data["risk_classes"].(map[string]any)
|
||||
for name, raw := range riskClasses {
|
||||
rcMap, _ := raw.(map[string]any)
|
||||
desc, _ := rcMap["description"].(string)
|
||||
approval, _ := rcMap["approval_required"].(string)
|
||||
autonomy, _ := rcMap["autonomy_allowed"].(bool)
|
||||
_, err := tx.Exec(ctx,
|
||||
`INSERT INTO risk_classes (name, description, approval_required, autonomy_allowed)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (name) DO UPDATE SET description = $2, approval_required = $3, autonomy_allowed = $4`,
|
||||
name, desc, approval, autonomy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("risk_class %s: %w", name, err)
|
||||
}
|
||||
r.RiskClasses++
|
||||
}
|
||||
|
||||
// Approval rules
|
||||
rules, _ := data["approval_rules"].([]any)
|
||||
for _, raw := range rules {
|
||||
ruleMap, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entityType, _ := ruleMap["entity_type"].(string)
|
||||
action, _ := ruleMap["action"].(string)
|
||||
riskClass, _ := ruleMap["risk_class"].(string)
|
||||
autonomy, _ := ruleMap["autonomy_level"].(string)
|
||||
scopeEntity, _ := ruleMap["scope_entity"].(string)
|
||||
|
||||
var scopeID any
|
||||
if scopeEntity != "" {
|
||||
id, err := getEntityIDBySlug(ctx, tx, scopeEntity)
|
||||
if err == nil {
|
||||
scopeID = id
|
||||
}
|
||||
}
|
||||
|
||||
ruleID := uuid.New()
|
||||
_, err := tx.Exec(ctx,
|
||||
`INSERT INTO approval_rules (id, entity_type, action, risk_class, autonomy_level, scope_entity, version, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 1, now())
|
||||
ON CONFLICT (entity_type, action, scope_entity)
|
||||
DO UPDATE SET risk_class = $4, autonomy_level = $5, scope_entity = $6, updated_at = now()`,
|
||||
ruleID, nullableStr(entityType), action, riskClass, autonomy, scopeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("approval_rule %s/%s: %w", entityType, action, err)
|
||||
}
|
||||
r.ApprovalRules++
|
||||
}
|
||||
|
||||
// Autonomy settings
|
||||
settings, _ := data["autonomy_settings"].(map[string]any)
|
||||
for key, raw := range settings {
|
||||
val, _ := raw.(string)
|
||||
_, err := tx.Exec(ctx,
|
||||
`INSERT INTO autonomy_settings (key, value, version, updated_at)
|
||||
VALUES ($1, $2, 1, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now()`,
|
||||
key, val)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("autonomy_setting %s: %w", key, err)
|
||||
}
|
||||
r.AutonomySettings++
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// getOrCreateEntityID returns the UUID for a slug, generating a new
|
||||
// time-ordered UUIDv7 if the slug doesn't exist yet (ADR-0005).
|
||||
func getOrCreateEntityID(ctx context.Context, tx pgx.Tx, slug string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&id)
|
||||
switch {
|
||||
case err == nil:
|
||||
return id, nil
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
return uuid.NewV7()
|
||||
default:
|
||||
return uuid.Nil, fmt.Errorf("lookup slug %s: %w", slug, err)
|
||||
}
|
||||
}
|
||||
|
||||
// getEntityTypeBySlug resolves a slug to its entity type name.
|
||||
func getEntityTypeBySlug(ctx context.Context, tx pgx.Tx, slug string) (string, error) {
|
||||
var t string
|
||||
err := tx.QueryRow(ctx, "SELECT type FROM entities WHERE slug = $1", slug).Scan(&t)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve type of %s: %w", slug, err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// getEntityIDBySlug resolves a slug to its UUID.
|
||||
func getEntityIDBySlug(ctx context.Context, tx pgx.Tx, slug string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&id)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("resolve slug %s: %w", slug, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// insertEntityTypes inserts entity types in dependency order (parents before children).
|
||||
func insertEntityTypes(ctx context.Context, tx pgx.Tx, types map[string]any, r *SeedResult) error {
|
||||
// Build a dependency graph and insert in topological order
|
||||
// Simple approach: insert types with no parent first, then iterate
|
||||
inserted := make(map[string]bool)
|
||||
remaining := make(map[string]map[string]any)
|
||||
for name, raw := range types {
|
||||
tMap, _ := raw.(map[string]any)
|
||||
remaining[name] = tMap
|
||||
}
|
||||
|
||||
maxPasses := 10
|
||||
for pass := 0; pass < maxPasses && len(remaining) > 0; pass++ {
|
||||
for name, tMap := range remaining {
|
||||
parent, _ := tMap["parent"].(string)
|
||||
if parent == "" || inserted[parent] {
|
||||
if err := insertOneEntityType(ctx, tx, name, tMap); err != nil {
|
||||
return err
|
||||
}
|
||||
inserted[name] = true
|
||||
delete(remaining, name)
|
||||
r.EntityTypes++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(remaining) > 0 {
|
||||
return fmt.Errorf("circular or missing parent in entity types: %v", keysOf(remaining))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertOneEntityType(ctx context.Context, tx pgx.Tx, name string, tMap map[string]any) error {
|
||||
parent, _ := tMap["parent"].(string)
|
||||
isAbstract, _ := tMap["abstract"].(bool)
|
||||
domain, _ := tMap["domain"].(string)
|
||||
layer, _ := tMap["layer"].(string)
|
||||
desc, _ := tMap["description"].(string)
|
||||
lifecycleID, _ := tMap["lifecycle"].(string)
|
||||
|
||||
// seeds/ontology.yaml spells this `attributes:`. Reading it as
|
||||
// "attribute_schema" silently marshalled nil to the JSON literal `null`
|
||||
// for every type, so no attribute schema was ever ingested — the API and
|
||||
// `oikos export` returned null for all 60 types.
|
||||
attrSchema := tMap["attributes"]
|
||||
_, err := tx.Exec(ctx,
|
||||
`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, description,
|
||||
lifecycle_id, attribute_schema, monitoring_spec, schema_version, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 1, 'active', now(), now())
|
||||
ON CONFLICT (name) DO UPDATE SET parent_type = $2, is_abstract = $3, domain = $4,
|
||||
layer = $5, description = $6, lifecycle_id = $7, attribute_schema = $8,
|
||||
monitoring_spec = $9, updated_at = now()`,
|
||||
name, nullableStr(parent), isAbstract, domain, layer, desc, nullableStr(lifecycleID),
|
||||
attributeSchemaJSON(attrSchema), monitoringSpecJSON(tMap["monitoring"]))
|
||||
return err
|
||||
}
|
||||
|
||||
// attributeSchemaJSON marshals a type's `attributes:` block for storage,
|
||||
// mapping "the type declares no schema" to SQL NULL rather than to the JSON
|
||||
// literal `null`. Both readers already treat a JSON `null` as absent, but a
|
||||
// real NULL is what `attribute_schema IS NULL` expects and is what the column
|
||||
// meant all along.
|
||||
func attributeSchemaJSON(v any) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// monitoringSpecJSON normalises an entity type's `monitoring:` declaration into
|
||||
// the JSONB stored in entity_types.monitoring_spec. Three outcomes, and the
|
||||
// difference between the last two is load-bearing for coverage signalling:
|
||||
//
|
||||
// absent → nil (SQL NULL) — undeclared, an ontology gap
|
||||
// none | [] → "[]" — explicitly unmonitorable, by design
|
||||
// [http, resource]→ '["http","resource"]'
|
||||
//
|
||||
// `monitoring: none` is accepted as a more legible spelling of `[]`; YAML
|
||||
// parses the bare word as the string "none", not as null.
|
||||
func monitoringSpecJSON(v any) any {
|
||||
switch spec := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case string:
|
||||
if spec == "none" {
|
||||
return "[]"
|
||||
}
|
||||
// A single kind written unquoted, e.g. `monitoring: http`.
|
||||
b, _ := json.Marshal([]string{spec})
|
||||
return string(b)
|
||||
case []any:
|
||||
b, _ := json.Marshal(toStringSlice(spec))
|
||||
return string(b)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toStringSlice(v any) []string {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
switch s := v.(type) {
|
||||
case []string:
|
||||
return s
|
||||
case []any:
|
||||
out := make([]string, 0, len(s))
|
||||
for _, item := range s {
|
||||
if str, ok := item.(string); ok {
|
||||
out = append(out, str)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullableStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func keysOf(m map[string]map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
107
internal/adapters/postgres/splitsql_test.go
Normal file
107
internal/adapters/postgres/splitsql_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func nonEmpty(stmts []string) []string {
|
||||
var out []string
|
||||
for _, s := range stmts {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSplitSQLBasic(t *testing.T) {
|
||||
stmts := nonEmpty(splitSQL("CREATE TABLE a (id int); CREATE TABLE b (id int);"))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLDollarQuotedFunction(t *testing.T) {
|
||||
sql := `CREATE FUNCTION f() RETURNS int AS $$
|
||||
SELECT 1; SELECT 2;
|
||||
$$ LANGUAGE sql;
|
||||
CREATE TABLE t (id int);`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
if !strings.Contains(stmts[0], "SELECT 1; SELECT 2;") {
|
||||
t.Errorf("dollar-quoted body was split: %q", stmts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLTaggedDollarQuote(t *testing.T) {
|
||||
sql := `DO $body$ BEGIN PERFORM 1; END $body$;SELECT 1;`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLSemicolonInComment(t *testing.T) {
|
||||
sql := "-- comment with ; semicolon\nCREATE TABLE t (id int); -- trailing; note\nSELECT 1;"
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLSemicolonInStringLiteral(t *testing.T) {
|
||||
sql := `SELECT 'hello; world'; INSERT INTO t VALUES (1);`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLDollarSignInStringLiteral(t *testing.T) {
|
||||
sql := `SELECT '$100'; SELECT 2;`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLBlockComment(t *testing.T) {
|
||||
sql := `SELECT 1; /* block; with; semicolons */ SELECT 2;`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLBlockCommentWithDollarQuote(t *testing.T) {
|
||||
sql := `/* $$ not a dollar quote */ SELECT 1;`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 1 {
|
||||
t.Fatalf("got %d statements, want 1: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLDoubledQuoteInString(t *testing.T) {
|
||||
sql := `SELECT 'O''Brien'; SELECT 2;`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLEmptyInput(t *testing.T) {
|
||||
stmts := nonEmpty(splitSQL(""))
|
||||
if len(stmts) != 0 {
|
||||
t.Fatalf("got %d statements, want 0", len(stmts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLNoSemicolon(t *testing.T) {
|
||||
stmts := nonEmpty(splitSQL("SELECT 1"))
|
||||
if len(stmts) != 1 {
|
||||
t.Fatalf("got %d statements, want 1", len(stmts))
|
||||
}
|
||||
}
|
||||
32
internal/adapters/postgres/sqlcgen/db.go
Normal file
32
internal/adapters/postgres/sqlcgen/db.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
|
||||
package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
275
internal/adapters/postgres/sqlcgen/entities.sql.go
Normal file
275
internal/adapters/postgres/sqlcgen/entities.sql.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: entities.sql
|
||||
|
||||
package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const getEntityByID = `-- name: GetEntityByID :one
|
||||
|
||||
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at, e.enrolled_at, e.enrolled_by FROM entities e WHERE e.id = $1
|
||||
`
|
||||
|
||||
// Entity read + mutation queries (API paths). Aliased `e` throughout to
|
||||
// avoid ambiguity with joined tables.
|
||||
func (q *Queries) GetEntityByID(ctx context.Context, id uuid.UUID) (Entity, error) {
|
||||
row := q.db.QueryRow(ctx, getEntityByID, id)
|
||||
var i Entity
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Slug,
|
||||
&i.Type,
|
||||
&i.Name,
|
||||
&i.State,
|
||||
&i.Attributes,
|
||||
&i.MaintenanceUntil,
|
||||
&i.Version,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrolledAt,
|
||||
&i.EnrolledBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getEntityBySlug = `-- name: GetEntityBySlug :one
|
||||
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at, e.enrolled_at, e.enrolled_by FROM entities e WHERE e.slug = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetEntityBySlug(ctx context.Context, slug string) (Entity, error) {
|
||||
row := q.db.QueryRow(ctx, getEntityBySlug, slug)
|
||||
var i Entity
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Slug,
|
||||
&i.Type,
|
||||
&i.Name,
|
||||
&i.State,
|
||||
&i.Attributes,
|
||||
&i.MaintenanceUntil,
|
||||
&i.Version,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrolledAt,
|
||||
&i.EnrolledBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertEntity = `-- name: InsertEntity :one
|
||||
INSERT INTO entities (id, slug, type, name, state, attributes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, slug, type, name, state, attributes, maintenance_until, version, created_at, updated_at, enrolled_at, enrolled_by
|
||||
`
|
||||
|
||||
type InsertEntityParams struct {
|
||||
ID uuid.UUID
|
||||
Slug string
|
||||
Type string
|
||||
Name string
|
||||
State *string
|
||||
Attributes []byte
|
||||
}
|
||||
|
||||
func (q *Queries) InsertEntity(ctx context.Context, arg InsertEntityParams) (Entity, error) {
|
||||
row := q.db.QueryRow(ctx, insertEntity,
|
||||
arg.ID,
|
||||
arg.Slug,
|
||||
arg.Type,
|
||||
arg.Name,
|
||||
arg.State,
|
||||
arg.Attributes,
|
||||
)
|
||||
var i Entity
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Slug,
|
||||
&i.Type,
|
||||
&i.Name,
|
||||
&i.State,
|
||||
&i.Attributes,
|
||||
&i.MaintenanceUntil,
|
||||
&i.Version,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrolledAt,
|
||||
&i.EnrolledBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listEntities = `-- name: ListEntities :many
|
||||
WITH RECURSIVE tt AS (
|
||||
SELECT name FROM entity_types WHERE $7::text IS NULL OR name = $7
|
||||
UNION
|
||||
SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name
|
||||
WHERE $7::text IS NOT NULL
|
||||
)
|
||||
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at, e.enrolled_at, e.enrolled_by FROM entities e
|
||||
JOIN entity_types et ON et.name = e.type
|
||||
WHERE e.type IN (SELECT name FROM tt)
|
||||
AND ($1::text IS NULL OR e.state = $1)
|
||||
AND ($2::text IS NULL OR et.domain = $2)
|
||||
AND ($3::text IS NULL OR et.layer = $3)
|
||||
AND ($4::text IS NULL
|
||||
OR e.slug ILIKE '%'||$4||'%'
|
||||
OR e.name ILIKE '%'||$4||'%')
|
||||
AND ($5::text IS NULL OR e.slug > $5)
|
||||
ORDER BY e.slug
|
||||
LIMIT $6
|
||||
`
|
||||
|
||||
type ListEntitiesParams struct {
|
||||
State *string
|
||||
Domain *string
|
||||
Layer *string
|
||||
Q *string
|
||||
Cursor *string
|
||||
Lim int32
|
||||
Type *string
|
||||
}
|
||||
|
||||
func (q *Queries) ListEntities(ctx context.Context, arg ListEntitiesParams) ([]Entity, error) {
|
||||
rows, err := q.db.Query(ctx, listEntities,
|
||||
arg.State,
|
||||
arg.Domain,
|
||||
arg.Layer,
|
||||
arg.Q,
|
||||
arg.Cursor,
|
||||
arg.Lim,
|
||||
arg.Type,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Entity
|
||||
for rows.Next() {
|
||||
var i Entity
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Slug,
|
||||
&i.Type,
|
||||
&i.Name,
|
||||
&i.State,
|
||||
&i.Attributes,
|
||||
&i.MaintenanceUntil,
|
||||
&i.Version,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrolledAt,
|
||||
&i.EnrolledBy,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const mergeEntityAttributes = `-- name: MergeEntityAttributes :execrows
|
||||
UPDATE entities SET
|
||||
attributes = attributes || $1::jsonb,
|
||||
updated_at = now()
|
||||
WHERE slug = $2
|
||||
`
|
||||
|
||||
type MergeEntityAttributesParams struct {
|
||||
Patch []byte
|
||||
Slug string
|
||||
}
|
||||
|
||||
// Shallow-merge a JSON patch into an entity's attributes (the
|
||||
// update_entity_attributes MCP/HTTP surface). Replaces the raw
|
||||
// `attributes = attributes || $2::jsonb` used in entity_tools.go.
|
||||
func (q *Queries) MergeEntityAttributes(ctx context.Context, arg MergeEntityAttributesParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, mergeEntityAttributes, arg.Patch, arg.Slug)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const setEntityState = `-- name: SetEntityState :execrows
|
||||
UPDATE entities SET
|
||||
state = $1,
|
||||
updated_at = now()
|
||||
WHERE id = $2
|
||||
`
|
||||
|
||||
type SetEntityStateParams struct {
|
||||
State *string
|
||||
ID uuid.UUID
|
||||
}
|
||||
|
||||
// Set an entity's lifecycle state by id (the set_entity_state surface, run
|
||||
// after db.ValidateTransition). Replaces the raw
|
||||
// `UPDATE entities SET state = $2 ... WHERE id = $1`.
|
||||
func (q *Queries) SetEntityState(ctx context.Context, arg SetEntityStateParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, setEntityState, arg.State, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const updateEntity = `-- name: UpdateEntity :one
|
||||
UPDATE entities SET
|
||||
name = COALESCE($1, name),
|
||||
state = COALESCE($2, state),
|
||||
attributes = COALESCE($3, attributes),
|
||||
maintenance_until = CASE WHEN $4::bool
|
||||
THEN $5 ELSE maintenance_until END,
|
||||
version = version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $6 AND version = $7
|
||||
RETURNING id, slug, type, name, state, attributes, maintenance_until, version, created_at, updated_at, enrolled_at, enrolled_by
|
||||
`
|
||||
|
||||
type UpdateEntityParams struct {
|
||||
Name *string
|
||||
State *string
|
||||
Attributes []byte
|
||||
SetMaintenance bool
|
||||
MaintenanceUntil *time.Time
|
||||
ID uuid.UUID
|
||||
Version int32
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateEntity(ctx context.Context, arg UpdateEntityParams) (Entity, error) {
|
||||
row := q.db.QueryRow(ctx, updateEntity,
|
||||
arg.Name,
|
||||
arg.State,
|
||||
arg.Attributes,
|
||||
arg.SetMaintenance,
|
||||
arg.MaintenanceUntil,
|
||||
arg.ID,
|
||||
arg.Version,
|
||||
)
|
||||
var i Entity
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Slug,
|
||||
&i.Type,
|
||||
&i.Name,
|
||||
&i.State,
|
||||
&i.Attributes,
|
||||
&i.MaintenanceUntil,
|
||||
&i.Version,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrolledAt,
|
||||
&i.EnrolledBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
460
internal/adapters/postgres/sqlcgen/models.go
Normal file
460
internal/adapters/postgres/sqlcgen/models.go
Normal file
@@ -0,0 +1,460 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
|
||||
package sqlcgen
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AgentActivity struct {
|
||||
ID int64
|
||||
Ts time.Time
|
||||
AgentID uuid.UUID
|
||||
SessionID *string
|
||||
ActivityType string
|
||||
ToolName *string
|
||||
EntityID *uuid.UUID
|
||||
InputSummary *string
|
||||
OutputSummary *string
|
||||
DurationMs *int32
|
||||
TokenCount *int32
|
||||
Success *bool
|
||||
CorrelationID *string
|
||||
}
|
||||
|
||||
type AgentMessage struct {
|
||||
ID uuid.UUID
|
||||
SessionID uuid.UUID
|
||||
Role string
|
||||
Content []byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type AgentSession struct {
|
||||
ID uuid.UUID
|
||||
Title string
|
||||
Actor string
|
||||
CreatedAt time.Time
|
||||
LastActiveAt time.Time
|
||||
Goal string
|
||||
Status string
|
||||
Outcome *string
|
||||
Summary string
|
||||
EntityID *uuid.UUID
|
||||
CompletionNudges int32
|
||||
Blocker string
|
||||
ClosedAt *time.Time
|
||||
}
|
||||
|
||||
type Approval struct {
|
||||
EntityID uuid.UUID
|
||||
SubjectEntityID *uuid.UUID
|
||||
Action string
|
||||
RiskClass string
|
||||
Kind string
|
||||
Payload []byte
|
||||
Status string
|
||||
TokenHash *string
|
||||
ExpiresAt time.Time
|
||||
DecidedAt *time.Time
|
||||
DecidedBy *uuid.UUID
|
||||
CreatedAt time.Time
|
||||
MatrixEventID *string
|
||||
AlertSentAt *time.Time
|
||||
}
|
||||
|
||||
type ApprovalRule struct {
|
||||
ID uuid.UUID
|
||||
EntityType *string
|
||||
Action string
|
||||
RiskClass string
|
||||
AutonomyLevel string
|
||||
ScopeEntity *uuid.UUID
|
||||
Version int32
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
ID int64
|
||||
Ts time.Time
|
||||
ActorType string
|
||||
ActorID *uuid.UUID
|
||||
Action string
|
||||
EntityID *uuid.UUID
|
||||
Method *string
|
||||
Path *string
|
||||
StatusCode *int32
|
||||
Detail []byte
|
||||
SourceIp *string
|
||||
CorrelationID *string
|
||||
SessionID *uuid.UUID
|
||||
}
|
||||
|
||||
type AutonomySetting struct {
|
||||
Key string
|
||||
Value string
|
||||
Version int32
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type CheckDef struct {
|
||||
EntityID uuid.UUID
|
||||
TargetID *uuid.UUID
|
||||
TargetType *string
|
||||
Kind string
|
||||
Config []byte
|
||||
IntervalS int32
|
||||
TimeoutS int32
|
||||
Zone *string
|
||||
Enabled bool
|
||||
UpdatedAt time.Time
|
||||
// When this check last executed. NULL = never, due immediately. Compared against interval_s to decide due-ness.
|
||||
LastRunAt *time.Time
|
||||
// This check's own most recent verdict (healthy/degraded/down/unknown). entity_status.health is the worst of these across the target's enabled checks.
|
||||
LastHealth *string
|
||||
}
|
||||
|
||||
type Classification struct {
|
||||
EntityID uuid.UUID
|
||||
SignalEntityID *uuid.UUID
|
||||
TargetEntityID *uuid.UUID
|
||||
Action string
|
||||
RecommendedAction []byte
|
||||
RiskClass string
|
||||
Route string
|
||||
BlastRadius []uuid.UUID
|
||||
PatternConfidence *float32
|
||||
SkillID *uuid.UUID
|
||||
AutonomyCheck *string
|
||||
Reasoning []byte
|
||||
CorrelationID string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ContextFile struct {
|
||||
Path string
|
||||
Hash string
|
||||
LastChanged time.Time
|
||||
}
|
||||
|
||||
type ContextVersion struct {
|
||||
Singleton bool
|
||||
Version int64
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Entity struct {
|
||||
ID uuid.UUID
|
||||
Slug string
|
||||
Type string
|
||||
Name string
|
||||
State *string
|
||||
Attributes []byte
|
||||
MaintenanceUntil *time.Time
|
||||
Version int32
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
EnrolledAt *time.Time
|
||||
EnrolledBy *uuid.UUID
|
||||
}
|
||||
|
||||
type EntityStatus struct {
|
||||
EntityID uuid.UUID
|
||||
Health string
|
||||
LastCheckAt *time.Time
|
||||
Details []byte
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type EntityType struct {
|
||||
Name string
|
||||
ParentType *string
|
||||
IsAbstract bool
|
||||
Domain string
|
||||
Layer string
|
||||
Description *string
|
||||
LifecycleID *string
|
||||
AttributeSchema []byte
|
||||
SchemaVersion int32
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// Check kinds this type warrants, resolved through parent_type. NULL means undeclared (an ontology gap), [] means explicitly unmonitorable, ["http","resource"] means declared kinds. Populated from seeds/ontology.yaml.
|
||||
MonitoringSpec []byte
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID int64
|
||||
Ts time.Time
|
||||
Type string
|
||||
EntityID *uuid.UUID
|
||||
Severity string
|
||||
Source string
|
||||
Data []byte
|
||||
CorrelationID *string
|
||||
}
|
||||
|
||||
type Execution struct {
|
||||
EntityID uuid.UUID
|
||||
ClassificationID *uuid.UUID
|
||||
SignalEntityID *uuid.UUID
|
||||
TargetEntityID *uuid.UUID
|
||||
Action string
|
||||
RiskClass string
|
||||
ApprovalID *uuid.UUID
|
||||
AgentID *uuid.UUID
|
||||
SkillID *uuid.UUID
|
||||
SkillVersion *int32
|
||||
Status string
|
||||
Result []byte
|
||||
DurationMs *int32
|
||||
Verified bool
|
||||
CorrelationID string
|
||||
StartedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ExecutionLog struct {
|
||||
ExecutionID uuid.UUID
|
||||
Ts time.Time
|
||||
Seq int32
|
||||
Stream string
|
||||
Chunk string
|
||||
}
|
||||
|
||||
type Feedback struct {
|
||||
EntityID uuid.UUID
|
||||
ExecutionID uuid.UUID
|
||||
Outcome string
|
||||
Observation *string
|
||||
Lesson *string
|
||||
UnexpectedSideEffects []string
|
||||
Tags []string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type IdempotencyKey struct {
|
||||
Key string
|
||||
Actor string
|
||||
RequestHash string
|
||||
ResponseCode *int32
|
||||
ResponseBody []byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type KnowledgeEntity struct {
|
||||
EntityID uuid.UUID
|
||||
Title string
|
||||
Content string
|
||||
Source *string
|
||||
Tags []string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ContentHash *string
|
||||
Search interface{}
|
||||
EditedBy string
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
type KnowledgeRevision struct {
|
||||
ID int64
|
||||
EntityID uuid.UUID
|
||||
Title string
|
||||
Content string
|
||||
Source *string
|
||||
Tags []string
|
||||
EditedBy string
|
||||
VersionAt time.Time
|
||||
RevisedAt time.Time
|
||||
}
|
||||
|
||||
type Ledger struct {
|
||||
Ts time.Time
|
||||
ExecutionID uuid.UUID
|
||||
TargetEntityID *uuid.UUID
|
||||
Action string
|
||||
RiskClass string
|
||||
Status string
|
||||
Verified bool
|
||||
Route *string
|
||||
Reasoning []byte
|
||||
ApprovalStatus *string
|
||||
DecidedBy *uuid.UUID
|
||||
AgentID *uuid.UUID
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
type LifecycleDef struct {
|
||||
ID string
|
||||
States []string
|
||||
DefaultState string
|
||||
TerminalStates []string
|
||||
Transitions []byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type MetricRollups1d struct {
|
||||
Bucket interface{}
|
||||
EntityID uuid.UUID
|
||||
Metric string
|
||||
AvgValue float64
|
||||
MinValue interface{}
|
||||
MaxValue interface{}
|
||||
SampleCount int64
|
||||
}
|
||||
|
||||
type MetricRollups1h struct {
|
||||
Bucket interface{}
|
||||
EntityID uuid.UUID
|
||||
Metric string
|
||||
AvgValue float64
|
||||
MinValue interface{}
|
||||
MaxValue interface{}
|
||||
SampleCount int64
|
||||
}
|
||||
|
||||
type MetricSample struct {
|
||||
Ts time.Time
|
||||
EntityID uuid.UUID
|
||||
Metric string
|
||||
Value float64
|
||||
Tags []byte
|
||||
}
|
||||
|
||||
type NomosPlanExecution struct {
|
||||
ExecutionID uuid.UUID
|
||||
SessionID uuid.UUID
|
||||
ContinuedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Pattern struct {
|
||||
EntityID uuid.UUID
|
||||
AppliesType string
|
||||
Action string
|
||||
Pattern string
|
||||
Confidence float32
|
||||
EvidenceCount int32
|
||||
SuccessCount int32
|
||||
FailureCount int32
|
||||
Status string
|
||||
Quarantined bool
|
||||
Version int32
|
||||
LastValidatedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ProvisioningStep struct {
|
||||
ID uuid.UUID
|
||||
EntityID uuid.UUID
|
||||
ExecutionID uuid.UUID
|
||||
StepOrder int32
|
||||
StepName string
|
||||
Status string
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
ErrorMessage *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Relationship struct {
|
||||
SourceID uuid.UUID
|
||||
TargetID uuid.UUID
|
||||
Type string
|
||||
Attributes []byte
|
||||
ValidFrom time.Time
|
||||
ValidTo *time.Time
|
||||
}
|
||||
|
||||
type RelationshipType struct {
|
||||
Name string
|
||||
Inverse *string
|
||||
SourceType string
|
||||
TargetType string
|
||||
Cardinality string
|
||||
Description *string
|
||||
CreatedAt time.Time
|
||||
// Which end of this edge depends on the other. forward = target depends on source. backward = source depends on target. none = no runtime dependency. Drives blast_radius().
|
||||
BlastDirection string
|
||||
}
|
||||
|
||||
type RiskClass struct {
|
||||
Name string
|
||||
Description *string
|
||||
ApprovalRequired string
|
||||
AutonomyAllowed bool
|
||||
}
|
||||
|
||||
type SeedVersion struct {
|
||||
File string
|
||||
ContentHash string
|
||||
AppliedAt time.Time
|
||||
}
|
||||
|
||||
type SessionPlanStep struct {
|
||||
ID uuid.UUID
|
||||
SessionID uuid.UUID
|
||||
Seq int32
|
||||
Title string
|
||||
Detail string
|
||||
Status string
|
||||
ExecutionID *uuid.UUID
|
||||
TargetSlug *string
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
Generation int32
|
||||
ReplacedReason *string
|
||||
}
|
||||
|
||||
type SessionQuestion struct {
|
||||
ID uuid.UUID
|
||||
SessionID uuid.UUID
|
||||
Prompt string
|
||||
Context []byte
|
||||
Status string
|
||||
Answer *string
|
||||
CreatedAt time.Time
|
||||
AnsweredAt *time.Time
|
||||
}
|
||||
|
||||
type Signal struct {
|
||||
EntityID uuid.UUID
|
||||
Kind string
|
||||
Severity string
|
||||
TargetEntityID *uuid.UUID
|
||||
CheckID *uuid.UUID
|
||||
Evidence *string
|
||||
LikelyCause *string
|
||||
State string
|
||||
OccurrenceCount int32
|
||||
FirstSeenAt time.Time
|
||||
LastSeenAt time.Time
|
||||
FlapCount int32
|
||||
HoldDownUntil *time.Time
|
||||
MuteUntil *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Skill struct {
|
||||
EntityID uuid.UUID
|
||||
Version int32
|
||||
Name string
|
||||
Procedure []byte
|
||||
AppliesType *string
|
||||
Action string
|
||||
PatternIds []uuid.UUID
|
||||
Status string
|
||||
SuccessRate *float32
|
||||
ChangedBy *uuid.UUID
|
||||
ChangeReason *string
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
132
internal/adapters/postgres/sqlcgen/ontology.sql.go
Normal file
132
internal/adapters/postgres/sqlcgen/ontology.sql.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: ontology.sql
|
||||
|
||||
package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const getLifecycleForType = `-- name: GetLifecycleForType :one
|
||||
SELECT ld.id, ld.states, ld.default_state, ld.terminal_states, ld.transitions, ld.created_at FROM lifecycle_defs ld
|
||||
JOIN entity_types et ON et.lifecycle_id = ld.id
|
||||
WHERE et.name = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetLifecycleForType(ctx context.Context, name string) (LifecycleDef, error) {
|
||||
row := q.db.QueryRow(ctx, getLifecycleForType, name)
|
||||
var i LifecycleDef
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.States,
|
||||
&i.DefaultState,
|
||||
&i.TerminalStates,
|
||||
&i.Transitions,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listEntityTypes = `-- name: ListEntityTypes :many
|
||||
SELECT name, parent_type, is_abstract, domain, layer, description, lifecycle_id, attribute_schema, schema_version, status, created_at, updated_at, monitoring_spec FROM entity_types ORDER BY name
|
||||
`
|
||||
|
||||
func (q *Queries) ListEntityTypes(ctx context.Context) ([]EntityType, error) {
|
||||
rows, err := q.db.Query(ctx, listEntityTypes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []EntityType
|
||||
for rows.Next() {
|
||||
var i EntityType
|
||||
if err := rows.Scan(
|
||||
&i.Name,
|
||||
&i.ParentType,
|
||||
&i.IsAbstract,
|
||||
&i.Domain,
|
||||
&i.Layer,
|
||||
&i.Description,
|
||||
&i.LifecycleID,
|
||||
&i.AttributeSchema,
|
||||
&i.SchemaVersion,
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.MonitoringSpec,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLifecycleDefs = `-- name: ListLifecycleDefs :many
|
||||
SELECT id, states, default_state, terminal_states, transitions, created_at FROM lifecycle_defs ORDER BY id
|
||||
`
|
||||
|
||||
func (q *Queries) ListLifecycleDefs(ctx context.Context) ([]LifecycleDef, error) {
|
||||
rows, err := q.db.Query(ctx, listLifecycleDefs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []LifecycleDef
|
||||
for rows.Next() {
|
||||
var i LifecycleDef
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.States,
|
||||
&i.DefaultState,
|
||||
&i.TerminalStates,
|
||||
&i.Transitions,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listRelationshipTypes = `-- name: ListRelationshipTypes :many
|
||||
SELECT name, inverse, source_type, target_type, cardinality, description, created_at, blast_direction FROM relationship_types ORDER BY name
|
||||
`
|
||||
|
||||
func (q *Queries) ListRelationshipTypes(ctx context.Context) ([]RelationshipType, error) {
|
||||
rows, err := q.db.Query(ctx, listRelationshipTypes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []RelationshipType
|
||||
for rows.Next() {
|
||||
var i RelationshipType
|
||||
if err := rows.Scan(
|
||||
&i.Name,
|
||||
&i.Inverse,
|
||||
&i.SourceType,
|
||||
&i.TargetType,
|
||||
&i.Cardinality,
|
||||
&i.Description,
|
||||
&i.CreatedAt,
|
||||
&i.BlastDirection,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
1496
internal/adapters/postgres/sqlcgen/operations.sql.go
Normal file
1496
internal/adapters/postgres/sqlcgen/operations.sql.go
Normal file
File diff suppressed because it is too large
Load Diff
177
internal/adapters/postgres/sqlcgen/relationships.sql.go
Normal file
177
internal/adapters/postgres/sqlcgen/relationships.sql.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: relationships.sql
|
||||
|
||||
package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const endCurrentRelationship = `-- name: EndCurrentRelationship :execrows
|
||||
UPDATE relationships SET valid_to = now()
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL
|
||||
`
|
||||
|
||||
type EndCurrentRelationshipParams struct {
|
||||
SourceID uuid.UUID
|
||||
TargetID uuid.UUID
|
||||
Type string
|
||||
}
|
||||
|
||||
func (q *Queries) EndCurrentRelationship(ctx context.Context, arg EndCurrentRelationshipParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, endCurrentRelationship, arg.SourceID, arg.TargetID, arg.Type)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const insertRelationshipIfAbsent = `-- name: InsertRelationshipIfAbsent :execrows
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT $1, $2, $3,
|
||||
$4::jsonb, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = $1
|
||||
AND target_id = $2
|
||||
AND type = $3
|
||||
AND valid_to IS NULL
|
||||
)
|
||||
`
|
||||
|
||||
type InsertRelationshipIfAbsentParams struct {
|
||||
SourceID uuid.UUID
|
||||
TargetID uuid.UUID
|
||||
Type string
|
||||
Attributes []byte
|
||||
}
|
||||
|
||||
// Idempotent relationship insert (the create_relationship surface): no-op if
|
||||
// an active edge of the same source/target/type already exists. Replaces the
|
||||
// raw INSERT...WHERE NOT EXISTS used in entity_tools.go.
|
||||
func (q *Queries) InsertRelationshipIfAbsent(ctx context.Context, arg InsertRelationshipIfAbsentParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, insertRelationshipIfAbsent,
|
||||
arg.SourceID,
|
||||
arg.TargetID,
|
||||
arg.Type,
|
||||
arg.Attributes,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const listEntityRelations = `-- name: ListEntityRelations :many
|
||||
SELECT se.slug AS source_slug, te.slug AS target_slug, r.type, r.attributes,
|
||||
r.valid_from, r.valid_to
|
||||
FROM relationships r
|
||||
JOIN entities se ON se.id = r.source_id
|
||||
JOIN entities te ON te.id = r.target_id
|
||||
WHERE r.valid_to IS NULL
|
||||
AND (($1::text IN ('out','both') AND r.source_id = $2)
|
||||
OR ($1::text IN ('in','both') AND r.target_id = $2))
|
||||
AND ($3::text IS NULL OR r.type = $3)
|
||||
ORDER BY r.type, se.slug, te.slug
|
||||
`
|
||||
|
||||
type ListEntityRelationsParams struct {
|
||||
Direction string
|
||||
ID uuid.UUID
|
||||
RelType *string
|
||||
}
|
||||
|
||||
type ListEntityRelationsRow struct {
|
||||
SourceSlug string
|
||||
TargetSlug string
|
||||
Type string
|
||||
Attributes []byte
|
||||
ValidFrom time.Time
|
||||
ValidTo *time.Time
|
||||
}
|
||||
|
||||
func (q *Queries) ListEntityRelations(ctx context.Context, arg ListEntityRelationsParams) ([]ListEntityRelationsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listEntityRelations, arg.Direction, arg.ID, arg.RelType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListEntityRelationsRow
|
||||
for rows.Next() {
|
||||
var i ListEntityRelationsRow
|
||||
if err := rows.Scan(
|
||||
&i.SourceSlug,
|
||||
&i.TargetSlug,
|
||||
&i.Type,
|
||||
&i.Attributes,
|
||||
&i.ValidFrom,
|
||||
&i.ValidTo,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listGraphEdges = `-- name: ListGraphEdges :many
|
||||
SELECT se.slug AS source_slug, te.slug AS target_slug, r.type, r.attributes,
|
||||
r.valid_from, r.valid_to
|
||||
FROM relationships r
|
||||
JOIN entities se ON se.id = r.source_id
|
||||
JOIN entities te ON te.id = r.target_id
|
||||
WHERE r.valid_to IS NULL
|
||||
AND r.source_id = ANY($1::uuid[])
|
||||
AND r.target_id = ANY($1::uuid[])
|
||||
AND ($2::text[] IS NULL OR r.type = ANY($2::text[]))
|
||||
ORDER BY r.type, se.slug, te.slug
|
||||
`
|
||||
|
||||
type ListGraphEdgesParams struct {
|
||||
Ids []uuid.UUID
|
||||
RelTypes []string
|
||||
}
|
||||
|
||||
type ListGraphEdgesRow struct {
|
||||
SourceSlug string
|
||||
TargetSlug string
|
||||
Type string
|
||||
Attributes []byte
|
||||
ValidFrom time.Time
|
||||
ValidTo *time.Time
|
||||
}
|
||||
|
||||
func (q *Queries) ListGraphEdges(ctx context.Context, arg ListGraphEdgesParams) ([]ListGraphEdgesRow, error) {
|
||||
rows, err := q.db.Query(ctx, listGraphEdges, arg.Ids, arg.RelTypes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListGraphEdgesRow
|
||||
for rows.Next() {
|
||||
var i ListGraphEdgesRow
|
||||
if err := rows.Scan(
|
||||
&i.SourceSlug,
|
||||
&i.TargetSlug,
|
||||
&i.Type,
|
||||
&i.Attributes,
|
||||
&i.ValidFrom,
|
||||
&i.ValidTo,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
150
internal/adapters/postgres/typetree.go
Normal file
150
internal/adapters/postgres/typetree.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/dtoro/oikos/internal/ontology"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// LoadTypeTree loads the ontology meta-schema from the DB for validation.
|
||||
// Called within the same transaction as an ingest so it sees just-ingested
|
||||
// types.
|
||||
func LoadTypeTree(ctx context.Context, tx pgx.Tx) (*ontology.TypeTree, error) {
|
||||
t := &ontology.TypeTree{
|
||||
Types: map[string]ontology.TypeInfo{},
|
||||
RelTypes: map[string]ontology.RelTypeInfo{},
|
||||
Lifecycles: map[string]ontology.LifecycleInfo{},
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx,
|
||||
`SELECT name, COALESCE(parent_type,''), is_abstract, COALESCE(lifecycle_id,''),
|
||||
layer, monitoring_spec
|
||||
FROM entity_types`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load entity_types: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var info ontology.TypeInfo
|
||||
// NULL monitoring_spec means the type declared nothing; '[]' means it
|
||||
// declared "explicitly unmonitorable". Scanning through a pointer is
|
||||
// what keeps those two apart — see ontology.TypeTree.Monitoring.
|
||||
var monitoring *[]string
|
||||
if err := rows.Scan(&name, &info.Parent, &info.IsAbstract, &info.LifecycleID,
|
||||
&info.Layer, &monitoring); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
info.Monitoring = monitoring
|
||||
t.Types[name] = info
|
||||
}
|
||||
rows.Close()
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
rows, err = tx.Query(ctx,
|
||||
`SELECT name, source_type, target_type, cardinality FROM relationship_types`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load relationship_types: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var info ontology.RelTypeInfo
|
||||
if err := rows.Scan(&name, &info.SourceType, &info.TargetType, &info.Cardinality); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
t.RelTypes[name] = info
|
||||
}
|
||||
rows.Close()
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
rows, err = tx.Query(ctx,
|
||||
`SELECT id, states, default_state FROM lifecycle_defs`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load lifecycle_defs: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var id, def string
|
||||
var states []string
|
||||
if err := rows.Scan(&id, &states, &def); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
set := make(map[string]bool, len(states))
|
||||
for _, s := range states {
|
||||
set[s] = true
|
||||
}
|
||||
t.Lifecycles[id] = ontology.LifecycleInfo{States: set, DefaultState: def}
|
||||
}
|
||||
rows.Close()
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ValidateCardinality checks all CURRENT edges against their relationship
|
||||
// type's declared cardinality. Runs inside the ingest transaction so a
|
||||
// violating seed rolls back atomically.
|
||||
func ValidateCardinality(ctx context.Context, tx pgx.Tx) error {
|
||||
// one-to-one / many-to-one: a source may have at most one outgoing
|
||||
// current edge of the type.
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT r.type, se.slug, count(*)
|
||||
FROM relationships r
|
||||
JOIN relationship_types rt ON rt.name = r.type
|
||||
JOIN entities se ON se.id = r.source_id
|
||||
WHERE r.valid_to IS NULL AND rt.cardinality IN ('one-to-one','many-to-one')
|
||||
GROUP BY r.type, se.slug HAVING count(*) > 1`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
violations, err := collectViolations(rows, "source")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// one-to-one / one-to-many: a target may have at most one incoming
|
||||
// current edge of the type.
|
||||
rows, err = tx.Query(ctx, `
|
||||
SELECT r.type, te.slug, count(*)
|
||||
FROM relationships r
|
||||
JOIN relationship_types rt ON rt.name = r.type
|
||||
JOIN entities te ON te.id = r.target_id
|
||||
WHERE r.valid_to IS NULL AND rt.cardinality IN ('one-to-one','one-to-many')
|
||||
GROUP BY r.type, te.slug HAVING count(*) > 1`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v2, err := collectViolations(rows, "target")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
violations = append(violations, v2...)
|
||||
|
||||
if len(violations) > 0 {
|
||||
return fmt.Errorf("cardinality violations: %v", violations)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectViolations(rows pgx.Rows, side string) ([]string, error) {
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var relType, slug string
|
||||
var n int
|
||||
if err := rows.Scan(&relType, &slug, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s %s=%s (%d edges)", relType, side, slug, n))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user