feat: Phase 3d — ReadModels port, postgres impl, HTTP reads rewire
Problem: entity/relationship/graph/blast-radius reads were embedded
as inline SQL in the httpapi handlers, duplicating the recursive type
tree CTE, the blast_radius function call, and the topology-picking
query across the REST and MCP surfaces with no port abstraction.
Change:
- ports.ReadModels interface: ListEntities, GetEntity, GetEntityBySlug,
GetEntityRelations, GetBlastRadius, GetGraph (with health),
ListEntityTypes. Returns EntityWithHealth (domain.Entity + health
from entity_status join) and domain.Relationship — no gen types
in the port.
- adapters/postgres/readmodels.go: EntityReader implements ReadModels
with the existing SQL verbatim (recursive type filter, blast_radius,
most-connected-first topology, graph edge listing).
- httpapi/entities.go: ListEntities, GetEntity, GetEntityRelations,
GetBlastRadius, GetGraph rewired to ReadModels. SQL moved to the
adapter; handlers map domain/ports types to gen wire shapes.
entityWithHealthToGen, sqlcEntityToGen helpers added.
- Old sqlcEntityToGen (sqlcgen.Entity → gen.Entity) preserved for
client_lifecycle.go; mutation handlers use domainToGen.
Verification: go build/vet, full test suite (19 pkgs), DB integration
(postgres + mcp — both green). httpapi DB tests have the pre-existing
set of failures (TestAPIEndToEnd entity_types=60/501, TestPhase3*)
verified at ec11956.
This commit is contained in:
349
internal/adapters/postgres/readmodels.go
Normal file
349
internal/adapters/postgres/readmodels.go
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/core/domain"
|
||||||
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EntityReader implements ports.ReadModels over the postgres pool.
|
||||||
|
type EntityReader struct {
|
||||||
|
pool *Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ports.ReadModels = (*EntityReader)(nil)
|
||||||
|
|
||||||
|
// NewEntityReader builds the read-models service.
|
||||||
|
func NewEntityReader(pool *Pool) *EntityReader { return &EntityReader{pool: pool} }
|
||||||
|
|
||||||
|
const entityHealthCols = `e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at, COALESCE(st.health, 'unknown'), st.last_check_at`
|
||||||
|
|
||||||
|
type rowScanner2 interface{ Scan(dest ...any) error }
|
||||||
|
|
||||||
|
func scanWithHealth(row rowScanner2) (ports.EntityWithHealth, error) {
|
||||||
|
var id uuid.UUID
|
||||||
|
var slug, typ, name string
|
||||||
|
var state *string
|
||||||
|
var attrs []byte
|
||||||
|
var maint *time.Time
|
||||||
|
var version int32
|
||||||
|
var createdAt, updatedAt time.Time
|
||||||
|
var health string
|
||||||
|
var lastCheck *time.Time
|
||||||
|
if err := row.Scan(&id, &slug, &typ, &name, &state, &attrs, &maint, &version, &createdAt, &updatedAt, &health, &lastCheck); err != nil {
|
||||||
|
return ports.EntityWithHealth{}, err
|
||||||
|
}
|
||||||
|
e := domain.Entity{
|
||||||
|
ID: domain.UUID(id.String()),
|
||||||
|
Slug: slug,
|
||||||
|
Type: typ,
|
||||||
|
Name: name,
|
||||||
|
Version: int(version),
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
UpdatedAt: updatedAt,
|
||||||
|
MaintenanceUntil: maint,
|
||||||
|
}
|
||||||
|
if state != nil {
|
||||||
|
e.State = *state
|
||||||
|
}
|
||||||
|
if len(attrs) > 0 {
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal(attrs, &m) == nil {
|
||||||
|
e.Attributes = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ports.EntityWithHealth{Entity: e, Health: health, LastCheckAt: lastCheck}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EntityReader) ListEntities(ctx context.Context, f ports.EntityFilters) ([]ports.EntityWithHealth, string, error) {
|
||||||
|
limit := f.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
nullable := func(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
WITH RECURSIVE tt AS (
|
||||||
|
SELECT name FROM entity_types WHERE $1::text IS NULL OR name = $1
|
||||||
|
UNION
|
||||||
|
SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name
|
||||||
|
WHERE $1::text IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT `+entityHealthCols+`
|
||||||
|
FROM entities e
|
||||||
|
JOIN entity_types et ON et.name = e.type
|
||||||
|
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||||
|
WHERE e.type IN (SELECT name FROM tt)
|
||||||
|
AND ($2::text IS NULL OR e.state = $2)
|
||||||
|
AND ($3::text IS NULL OR et.domain = $3)
|
||||||
|
AND ($4::text IS NULL OR et.layer = $4)
|
||||||
|
AND ($5::text IS NULL OR e.slug ILIKE '%'||$5||'%' OR e.name ILIKE '%'||$5||'%')
|
||||||
|
AND ($6::text IS NULL OR e.slug > $6)
|
||||||
|
ORDER BY e.slug
|
||||||
|
LIMIT $7`,
|
||||||
|
nullable(f.Type), nullable(f.State), nullable(f.Domain), nullable(f.Layer),
|
||||||
|
nullable(f.Q), nullable(f.Cursor), limit+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := []ports.EntityWithHealth{}
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanWithHealth(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
items = append(items, e)
|
||||||
|
}
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, "", rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
next := ""
|
||||||
|
if len(items) > limit {
|
||||||
|
items = items[:limit]
|
||||||
|
next = items[len(items)-1].Entity.Slug
|
||||||
|
}
|
||||||
|
return items, next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EntityReader) GetEntity(ctx context.Context, id domain.UUID) (ports.EntityWithHealth, error) {
|
||||||
|
e, err := scanWithHealth(r.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+entityHealthCols+` FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.id = $1`, mustUUID(id)))
|
||||||
|
if err != nil {
|
||||||
|
return ports.EntityWithHealth{}, mapRowErr(err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EntityReader) GetEntityBySlug(ctx context.Context, slug string) (ports.EntityWithHealth, error) {
|
||||||
|
e, err := scanWithHealth(r.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+entityHealthCols+` FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.slug = $1`, slug))
|
||||||
|
if err != nil {
|
||||||
|
return ports.EntityWithHealth{}, mapRowErr(err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EntityReader) GetEntityRelations(ctx context.Context, entityID domain.UUID, direction, relType string) ([]domain.Relationship, error) {
|
||||||
|
eid := mustUUID(entityID)
|
||||||
|
switch direction {
|
||||||
|
case "outbound":
|
||||||
|
return r.queryRels(ctx, `SELECT source_id, target_id, type, attributes, valid_from, valid_to
|
||||||
|
FROM relationships WHERE valid_to IS NULL AND source_id = $1`, eid, relType)
|
||||||
|
case "inbound":
|
||||||
|
return r.queryRels(ctx, `SELECT source_id, target_id, type, attributes, valid_from, valid_to
|
||||||
|
FROM relationships WHERE valid_to IS NULL AND target_id = $1`, eid, relType)
|
||||||
|
default:
|
||||||
|
return r.queryRels(ctx, `SELECT source_id, target_id, type, attributes, valid_from, valid_to
|
||||||
|
FROM relationships WHERE valid_to IS NULL AND (source_id = $1 OR target_id = $1)`, eid, relType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EntityReader) queryRels(ctx context.Context, query string, eid uuid.UUID, relType string) ([]domain.Relationship, error) {
|
||||||
|
var args []any
|
||||||
|
args = append(args, eid)
|
||||||
|
if relType != "" {
|
||||||
|
query += ` AND type = $2`
|
||||||
|
args = append(args, relType)
|
||||||
|
} else {
|
||||||
|
query += ` ORDER BY type`
|
||||||
|
}
|
||||||
|
rows, err := r.pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanEdges(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EntityReader) GetBlastRadius(ctx context.Context, entityID domain.UUID, depth int) ([]ports.EntityWithHealth, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT `+entityHealthCols+`, b.depth
|
||||||
|
FROM blast_radius($1, $2) b
|
||||||
|
JOIN entities e ON e.id = b.entity_id
|
||||||
|
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||||
|
ORDER BY b.depth, e.slug`, mustUUID(entityID), depth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []ports.EntityWithHealth
|
||||||
|
for rows.Next() {
|
||||||
|
var d int
|
||||||
|
e, err := scanWithHealth(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = d
|
||||||
|
items = append(items, e)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EntityReader) GetGraph(ctx context.Context, depth int, root *domain.UUID, relTypes []string, cap int) ([]ports.EntityWithHealth, []domain.Relationship, bool, error) {
|
||||||
|
truncated := false
|
||||||
|
var nodes []ports.EntityWithHealth
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if root != nil {
|
||||||
|
nodes, err = r.blastRadiusNodes(ctx, mustUUID(*root), depth, relTypes)
|
||||||
|
} else {
|
||||||
|
nodes, err = r.topologyNodes(ctx, cap)
|
||||||
|
if err == nil && len(nodes) > cap {
|
||||||
|
nodes = nodes[:cap]
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]uuid.UUID, len(nodes))
|
||||||
|
for i, n := range nodes {
|
||||||
|
uid, _ := uuid.Parse(string(n.Entity.ID))
|
||||||
|
ids[i] = uid
|
||||||
|
}
|
||||||
|
edges, err := r.listGraphEdges(ctx, ids, relTypes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, false, err
|
||||||
|
}
|
||||||
|
return nodes, edges, truncated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EntityReader) blastRadiusNodes(ctx context.Context, rootID uuid.UUID, depth int, relTypes []string) ([]ports.EntityWithHealth, error) {
|
||||||
|
query := `
|
||||||
|
SELECT ` + entityHealthCols + `
|
||||||
|
FROM blast_radius($1, $2, $3) b JOIN entities e ON e.id = b.entity_id
|
||||||
|
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||||
|
ORDER BY e.slug`
|
||||||
|
var relTypesArg any
|
||||||
|
if len(relTypes) > 0 {
|
||||||
|
relTypesArg = relTypes
|
||||||
|
}
|
||||||
|
rows, err := r.pool.Query(ctx, query, rootID, depth, relTypesArg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ports.EntityWithHealth
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanWithHealth(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, e)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EntityReader) topologyNodes(ctx context.Context, cap int) ([]ports.EntityWithHealth, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT `+entityHealthCols+`
|
||||||
|
FROM entities e
|
||||||
|
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||||
|
WHERE e.type NOT IN ('execution','task')
|
||||||
|
AND e.id IN (
|
||||||
|
SELECT e2.id FROM entities e2
|
||||||
|
LEFT JOIN relationships r ON r.valid_to IS NULL
|
||||||
|
AND (r.source_id = e2.id OR r.target_id = e2.id)
|
||||||
|
WHERE e2.type NOT IN ('execution','task')
|
||||||
|
GROUP BY e2.id
|
||||||
|
ORDER BY count(r.type) DESC, e2.slug
|
||||||
|
LIMIT $1
|
||||||
|
)
|
||||||
|
ORDER BY e.slug`, cap+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ports.EntityWithHealth
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanWithHealth(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, e)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EntityReader) listGraphEdges(ctx context.Context, ids []uuid.UUID, relTypes []string) ([]domain.Relationship, error) {
|
||||||
|
query := `SELECT r.source_id, r.target_id, r.type, r.attributes, r.valid_from, r.valid_to
|
||||||
|
FROM relationships r
|
||||||
|
WHERE r.valid_to IS NULL AND (r.source_id = ANY($1) OR r.target_id = ANY($1))`
|
||||||
|
var args []any
|
||||||
|
args = append(args, ids)
|
||||||
|
if len(relTypes) > 0 {
|
||||||
|
query += ` AND r.type = ANY($2)`
|
||||||
|
args = append(args, relTypes)
|
||||||
|
} else {
|
||||||
|
query += ` ORDER BY r.type`
|
||||||
|
}
|
||||||
|
rows, err := r.pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanEdges(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EntityReader) ListEntityTypes(ctx context.Context) ([]domain.EntityType, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT name, COALESCE(parent_type,''), is_abstract, COALESCE(domain,''), COALESCE(layer,''), COALESCE(description,''),
|
||||||
|
COALESCE(lifecycle_id,''), '{}'::jsonb, COALESCE(schema_version,0), COALESCE(status,'')
|
||||||
|
FROM entity_types ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []domain.EntityType
|
||||||
|
for rows.Next() {
|
||||||
|
var t domain.EntityType
|
||||||
|
if err := rows.Scan(&t.Name, &t.ParentType, &t.IsAbstract, &t.Domain, &t.Layer, &t.Description,
|
||||||
|
&t.LifecycleID, &t.AttributeSchema, &t.SchemaVersion, &t.Status); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, t)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanEdges(rows pgx.Rows) ([]domain.Relationship, error) {
|
||||||
|
var items []domain.Relationship
|
||||||
|
for rows.Next() {
|
||||||
|
var src, tgt uuid.UUID
|
||||||
|
var rType string
|
||||||
|
var attrs []byte
|
||||||
|
var vf time.Time
|
||||||
|
var vt *time.Time
|
||||||
|
if err := rows.Scan(&src, &tgt, &rType, &attrs, &vf, &vt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rel := domain.Relationship{
|
||||||
|
SourceID: domain.UUID(src.String()), TargetID: domain.UUID(tgt.String()),
|
||||||
|
Type: rType, Attributes: map[string]any{}, ValidFrom: vf, ValidTo: vt,
|
||||||
|
}
|
||||||
|
if len(attrs) > 0 {
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal(attrs, &m) == nil {
|
||||||
|
rel.Attributes = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = append(items, rel)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
181
internal/core/app/entities_test.go
Normal file
181
internal/core/app/entities_test.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/core/domain"
|
||||||
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
|
"github.com/dtoro/oikos/internal/core/ports/portstest"
|
||||||
|
"github.com/dtoro/oikos/internal/ontology"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeOntology is a static OntologyStore over a hand-built tree.
|
||||||
|
type fakeOntology struct{ tree *ontology.TypeTree }
|
||||||
|
|
||||||
|
func (f *fakeOntology) LoadTypeTree(context.Context) (ports.TypeTree, error) {
|
||||||
|
return f.tree, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTree() *ontology.TypeTree {
|
||||||
|
return &ontology.TypeTree{
|
||||||
|
Types: map[string]ontology.TypeInfo{
|
||||||
|
"service": {LifecycleID: "infra"},
|
||||||
|
"host": {LifecycleID: "infra"},
|
||||||
|
"widget": {IsAbstract: true},
|
||||||
|
},
|
||||||
|
Lifecycles: map[string]ontology.LifecycleInfo{
|
||||||
|
"infra": {States: map[string]bool{"planned": true, "active": true, "deprecated": true}, DefaultState: "active"},
|
||||||
|
},
|
||||||
|
RelTypes: map[string]ontology.RelTypeInfo{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSvc(t *testing.T) (*EntityService, *portstest.EntityRepo) {
|
||||||
|
t.Helper()
|
||||||
|
repo := portstest.NewEntityRepo()
|
||||||
|
svc := NewEntityService(repo, &fakeOntology{tree: testTree()})
|
||||||
|
return svc, repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntityServiceCreate(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, repo := newSvc(t)
|
||||||
|
|
||||||
|
e, res, err := svc.Create(ctx, CreateEntityCmd{
|
||||||
|
Type: "service", Name: "jellyfin", Attributes: map[string]any{"url": "https://media.example"},
|
||||||
|
ActorType: "operator", Actor: "tester", Method: "POST", Path: "/api/v1/entities",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if e.Slug != "service:jellyfin" {
|
||||||
|
t.Errorf("default slug = %q, want service:jellyfin", e.Slug)
|
||||||
|
}
|
||||||
|
if e.State != "active" {
|
||||||
|
t.Errorf("default state = %q, want active (lifecycle default)", e.State)
|
||||||
|
}
|
||||||
|
if res.Created != 0 {
|
||||||
|
// testTree's service declares no monitoring → nothing derived
|
||||||
|
t.Errorf("derived = %d, want 0 (no monitoring spec in fake tree)", res.Created)
|
||||||
|
}
|
||||||
|
if len(repo.Audits) != 1 || repo.Audits[0].Action != "create" {
|
||||||
|
t.Errorf("audit = %+v, want one create entry", repo.Audits)
|
||||||
|
}
|
||||||
|
if len(repo.Events) != 1 || repo.Events[0].Type != "entity.created" {
|
||||||
|
t.Errorf("event = %+v, want entity.created", repo.Events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntityServiceCreateValidation(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, _ := newSvc(t)
|
||||||
|
|
||||||
|
if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "no-such-type", Name: "x"}); !errors.Is(err, domain.ErrNotFound) {
|
||||||
|
t.Errorf("unknown type: got %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "widget", Name: "x"}); !errors.Is(err, domain.ErrAbstractType) {
|
||||||
|
t.Errorf("abstract type: got %v, want ErrAbstractType", err)
|
||||||
|
}
|
||||||
|
// The stricter (MCP) rule now governs both surfaces: caller-supplied
|
||||||
|
// states must be declared in the lifecycle.
|
||||||
|
if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "service", Name: "x", State: "destroyed"}); !errors.Is(err, domain.ErrInvalidTransition) {
|
||||||
|
t.Errorf("undeclared state: got %v, want ErrInvalidTransition", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntityServiceCreateIdempotency(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, repo := newSvc(t)
|
||||||
|
|
||||||
|
idem := &ports.Idempotency{
|
||||||
|
Actor: "tester", Key: "k1", RequestHash: "abc",
|
||||||
|
RenderBody: func(e domain.Entity) []byte { return []byte(`{"slug":"` + e.Slug + `"}`) },
|
||||||
|
}
|
||||||
|
if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "host", Name: "h1", Idempotency: idem}); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cached, err := repo.GetIdempotent(ctx, "tester", "k1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get idempotent: %v", err)
|
||||||
|
}
|
||||||
|
if cached.RequestHash != "abc" || cached.ResponseCode != 201 {
|
||||||
|
t.Errorf("cached = %+v, want hash abc / code 201", cached)
|
||||||
|
}
|
||||||
|
if string(cached.ResponseBody) != `{"slug":"host:h1"}` {
|
||||||
|
t.Errorf("cached body = %s, want the rendered wire shape", cached.ResponseBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.GetIdempotent(ctx, "tester", "other"); !errors.Is(err, domain.ErrNotFound) {
|
||||||
|
t.Errorf("missing key: got %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntityServiceUpdate(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, repo := newSvc(t)
|
||||||
|
|
||||||
|
created, _, err := svc.Create(ctx, CreateEntityCmd{Type: "service", Name: "svc", Attributes: map[string]any{"a": 1}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge semantics: existing keys survive.
|
||||||
|
updated, _, err := svc.Update(ctx, UpdateEntityCmd{
|
||||||
|
SlugOrID: "service:svc", ExpectedVer: created.Version,
|
||||||
|
Attributes: map[string]any{"b": 2}, RederiveChecks: true,
|
||||||
|
ActorType: "agent", Actor: "mcp", Method: "TOOL", Path: "update_entity_attributes",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Attributes["a"] != 1 || updated.Attributes["b"] != 2 {
|
||||||
|
t.Errorf("merged attrs = %v, want a+b", updated.Attributes)
|
||||||
|
}
|
||||||
|
if len(repo.Rederived) != 1 || repo.Rederived[0] != created.ID {
|
||||||
|
t.Errorf("redrive = %v, want one pass for the entity", repo.Rederived)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic concurrency: stale version refuses.
|
||||||
|
if _, _, err := svc.Update(ctx, UpdateEntityCmd{SlugOrID: "service:svc", ExpectedVer: created.Version}); !errors.Is(err, domain.ErrConflict) {
|
||||||
|
t.Errorf("stale version: got %v, want ErrConflict", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace semantics: wholesale swap.
|
||||||
|
replaced, _, err := svc.Update(ctx, UpdateEntityCmd{
|
||||||
|
SlugOrID: "service:svc", Attributes: map[string]any{"only": true}, AttrsReplace: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replace update: %v", err)
|
||||||
|
}
|
||||||
|
if len(replaced.Attributes) != 1 || replaced.Attributes["only"] != true {
|
||||||
|
t.Errorf("replaced attrs = %v, want only", replaced.Attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntityServiceSetState(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, repo := newSvc(t)
|
||||||
|
|
||||||
|
if _, _, err := svc.Create(ctx, CreateEntityCmd{Type: "host", Name: "old"}); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := svc.SetState(ctx, "host:old", "active", "deprecated", "operator", "t", "PATCH", "/x")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setState: %v", err)
|
||||||
|
}
|
||||||
|
if after.State != "deprecated" {
|
||||||
|
t.Errorf("state = %s, want deprecated", after.State)
|
||||||
|
}
|
||||||
|
if len(repo.Audits) != 2 || repo.Audits[1].Action != "state" {
|
||||||
|
t.Errorf("audits = %+v, want a state entry after create", repo.Audits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale From refuses (check-then-act).
|
||||||
|
if _, err := svc.SetState(ctx, "host:old", "active", "deprecated", "operator", "t", "PATCH", "/x"); !errors.Is(err, domain.ErrConflict) {
|
||||||
|
t.Errorf("stale from-state: got %v, want ErrConflict", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package ports
|
package ports
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/core/domain"
|
"github.com/dtoro/oikos/internal/core/domain"
|
||||||
@@ -119,6 +120,27 @@ type RelationshipRepository interface {
|
|||||||
ListFor(ctx context.Context, entityID domain.UUID, direction string) ([]domain.Relationship, error)
|
ListFor(ctx context.Context, entityID domain.UUID, direction string) ([]domain.Relationship, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EntityWithHealth pairs an entity with its probe health (from the
|
||||||
|
// entity_status join — the read shape graph/report endpoints need).
|
||||||
|
type EntityWithHealth struct {
|
||||||
|
Entity domain.Entity
|
||||||
|
Health string
|
||||||
|
LastCheckAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadModels surfaces query-shaped report reads consumed directly by the
|
||||||
|
// httpapi/mcpserver adapters (no service hop — invariant-free reads, plan
|
||||||
|
// §3.4). Methods accrete per phase as report handlers rewire.
|
||||||
|
type ReadModels interface {
|
||||||
|
ListEntities(ctx context.Context, filters EntityFilters) ([]EntityWithHealth, string, error)
|
||||||
|
GetEntity(ctx context.Context, id domain.UUID) (EntityWithHealth, error)
|
||||||
|
GetEntityBySlug(ctx context.Context, slug string) (EntityWithHealth, error)
|
||||||
|
GetEntityRelations(ctx context.Context, entityID domain.UUID, direction, relType string) ([]domain.Relationship, error)
|
||||||
|
GetBlastRadius(ctx context.Context, entityID domain.UUID, depth int) ([]EntityWithHealth, error)
|
||||||
|
GetGraph(ctx context.Context, depth int, root *domain.UUID, relTypes []string, cap int) (nodes []EntityWithHealth, edges []domain.Relationship, truncated bool, err error)
|
||||||
|
ListEntityTypes(ctx context.Context) ([]domain.EntityType, error)
|
||||||
|
}
|
||||||
|
|
||||||
// OntologyStore loads the type tree; implementations cache. Consumers
|
// OntologyStore loads the type tree; implementations cache. Consumers
|
||||||
// validate entity types, relationship endpoints, and lifecycle transitions
|
// validate entity types, relationship endpoints, and lifecycle transitions
|
||||||
// against it before issuing repository writes.
|
// against it before issuing repository writes.
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ func (r *EntityRepo) Create(_ context.Context, in ports.EntityCreateInput) (doma
|
|||||||
if _, dup := r.bySlug[in.Entity.Slug]; dup {
|
if _, dup := r.bySlug[in.Entity.Slug]; dup {
|
||||||
return domain.Entity{}, domain.ErrConflict
|
return domain.Entity{}, domain.ErrConflict
|
||||||
}
|
}
|
||||||
|
if in.Entity.Version == 0 {
|
||||||
|
in.Entity.Version = 1
|
||||||
|
}
|
||||||
r.store(in.Entity)
|
r.store(in.Entity)
|
||||||
r.Audits = append(r.Audits, in.Audit...)
|
r.Audits = append(r.Audits, in.Audit...)
|
||||||
if in.Event != nil {
|
if in.Event != nil {
|
||||||
@@ -151,6 +154,9 @@ func (r *EntityRepo) Update(_ context.Context, in ports.EntityUpdateInput) (doma
|
|||||||
}
|
}
|
||||||
after := in.Entity
|
after := in.Entity
|
||||||
after.Version = current.Version + 1
|
after.Version = current.Version + 1
|
||||||
|
if in.RederiveChecks {
|
||||||
|
r.Rederived = append(r.Rederived, after.ID)
|
||||||
|
}
|
||||||
delete(r.bySlug, current.Slug)
|
delete(r.bySlug, current.Slug)
|
||||||
r.store(after)
|
r.store(after)
|
||||||
r.Audits = append(r.Audits, in.Audit...)
|
r.Audits = append(r.Audits, in.Audit...)
|
||||||
|
|||||||
@@ -4,85 +4,59 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
|
"github.com/dtoro/oikos/internal/core/domain"
|
||||||
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) ListEntities(ctx context.Context, req gen.ListEntitiesRequestObject) (gen.ListEntitiesResponseObject, error) {
|
func (s *Server) ListEntities(ctx context.Context, req gen.ListEntitiesRequestObject) (gen.ListEntitiesResponseObject, error) {
|
||||||
limit := clampLimit(req.Params.Limit)
|
limit := clampLimit(req.Params.Limit)
|
||||||
|
filters := ports.EntityFilters{
|
||||||
|
Type: strOrEmpty(req.Params.Type),
|
||||||
|
State: strOrEmpty(req.Params.State),
|
||||||
|
Q: strOrEmpty(req.Params.Q),
|
||||||
|
Domain: strOrEmpty(req.Params.Domain),
|
||||||
|
Layer: strOrEmpty(req.Params.Layer),
|
||||||
|
Cursor: strOrEmpty(req.Params.Cursor),
|
||||||
|
Limit: limit,
|
||||||
|
}
|
||||||
|
|
||||||
// Type filter includes descendants via the parent hierarchy (R3-1).
|
items, next, err := s.readModels.ListEntities(ctx, filters)
|
||||||
query := `
|
|
||||||
WITH RECURSIVE tt AS (
|
|
||||||
SELECT name FROM entity_types WHERE $1::text IS NULL OR name = $1
|
|
||||||
UNION
|
|
||||||
SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name
|
|
||||||
WHERE $1::text IS NOT NULL
|
|
||||||
)
|
|
||||||
SELECT ` + entityCols + ` FROM entities e
|
|
||||||
JOIN entity_types et ON et.name = e.type
|
|
||||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
|
||||||
WHERE e.type IN (SELECT name FROM tt)
|
|
||||||
AND ($2::text IS NULL OR e.state = $2)
|
|
||||||
AND ($3::text IS NULL OR et.domain = $3)
|
|
||||||
AND ($4::text IS NULL OR et.layer = $4)
|
|
||||||
AND ($5::text IS NULL OR e.slug ILIKE '%'||$5||'%' OR e.name ILIKE '%'||$5||'%')
|
|
||||||
AND ($6::text IS NULL OR e.slug > $6)
|
|
||||||
ORDER BY e.slug
|
|
||||||
LIMIT $7`
|
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, query,
|
|
||||||
req.Params.Type, req.Params.State, req.Params.Domain, req.Params.Layer,
|
|
||||||
req.Params.Q, req.Params.Cursor, limit+1)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var items []gen.Entity
|
entities := make([]gen.Entity, len(items))
|
||||||
for rows.Next() {
|
for i, e := range items {
|
||||||
e, err := scanEntity(rows)
|
entities[i] = entityWithHealthToGen(e)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, e)
|
|
||||||
}
|
}
|
||||||
if rows.Err() != nil {
|
if entities == nil {
|
||||||
return nil, rows.Err()
|
entities = []gen.Entity{}
|
||||||
}
|
}
|
||||||
|
return gen.ListEntities200JSONResponse{Items: entities, NextCursor: &next}, nil
|
||||||
var next *string
|
|
||||||
if len(items) > limit {
|
|
||||||
items = items[:limit]
|
|
||||||
next = &items[len(items)-1].Slug
|
|
||||||
}
|
|
||||||
if items == nil {
|
|
||||||
items = []gen.Entity{}
|
|
||||||
}
|
|
||||||
return gen.ListEntities200JSONResponse{Items: items, NextCursor: next}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) GetEntity(ctx context.Context, req gen.GetEntityRequestObject) (gen.GetEntityResponseObject, error) {
|
func (s *Server) GetEntity(ctx context.Context, req gen.GetEntityRequestObject) (gen.GetEntityResponseObject, error) {
|
||||||
id, err := s.resolveEntityID(ctx, req.Id)
|
uid, err := s.resolveEntityID(ctx, req.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e, err := scanEntity(s.pool.QueryRow(ctx,
|
e, err := s.readModels.GetEntity(ctx, domain.UUID(uid.String()))
|
||||||
"SELECT "+entityCols+" FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.id = $1", id))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
entity := entityWithHealthToGen(e)
|
||||||
return gen.GetEntity200JSONResponse{
|
return gen.GetEntity200JSONResponse{
|
||||||
Body: e,
|
Body: entity,
|
||||||
Headers: gen.GetEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(e.Version) + `"`},
|
Headers: gen.GetEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(e.Entity.Version) + `"`},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) GetEntityRelations(ctx context.Context, req gen.GetEntityRelationsRequestObject) (gen.GetEntityRelationsResponseObject, error) {
|
func (s *Server) GetEntityRelations(ctx context.Context, req gen.GetEntityRelationsRequestObject) (gen.GetEntityRelationsResponseObject, error) {
|
||||||
id, err := s.resolveEntityID(ctx, req.Id)
|
uid, err := s.resolveEntityID(ctx, req.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -90,39 +64,22 @@ func (s *Server) GetEntityRelations(ctx context.Context, req gen.GetEntityRelati
|
|||||||
if req.Params.Direction != nil {
|
if req.Params.Direction != nil {
|
||||||
dir = string(*req.Params.Direction)
|
dir = string(*req.Params.Direction)
|
||||||
}
|
}
|
||||||
relType := req.Params.RelType
|
relType := strOrEmpty(req.Params.RelType)
|
||||||
rows, err := sqlcgen.New(s.pool).ListEntityRelations(ctx, sqlcgen.ListEntityRelationsParams{
|
|
||||||
Direction: dir,
|
rels, err := s.readModels.GetEntityRelations(ctx, domain.UUID(uid.String()), dir, relType)
|
||||||
ID: id,
|
|
||||||
RelType: relType,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := []gen.Relationship{}
|
|
||||||
for _, r := range rows {
|
items := make([]gen.Relationship, len(rels))
|
||||||
var attrs *map[string]any
|
for i, r := range rels {
|
||||||
if len(r.Attributes) > 0 {
|
items[i] = relToGen(r)
|
||||||
var m map[string]any
|
|
||||||
if json.Unmarshal(r.Attributes, &m) == nil && len(m) > 0 {
|
|
||||||
attrs = &m
|
|
||||||
}
|
|
||||||
}
|
|
||||||
validTo := r.ValidTo
|
|
||||||
items = append(items, gen.Relationship{
|
|
||||||
Source: r.SourceSlug,
|
|
||||||
Target: r.TargetSlug,
|
|
||||||
Type: r.Type,
|
|
||||||
Attributes: attrs,
|
|
||||||
ValidFrom: r.ValidFrom,
|
|
||||||
ValidTo: validTo,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return gen.GetEntityRelations200JSONResponse{Items: items}, nil
|
return gen.GetEntityRelations200JSONResponse{Items: items}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusRequestObject) (gen.GetBlastRadiusResponseObject, error) {
|
func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusRequestObject) (gen.GetBlastRadiusResponseObject, error) {
|
||||||
id, err := s.resolveEntityID(ctx, req.Id)
|
uid, err := s.resolveEntityID(ctx, req.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -130,50 +87,23 @@ func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusReque
|
|||||||
if req.Params.Depth != nil {
|
if req.Params.Depth != nil {
|
||||||
depth = *req.Params.Depth
|
depth = *req.Params.Depth
|
||||||
}
|
}
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT `+entityCols+`, b.depth
|
items, err := s.readModels.GetBlastRadius(ctx, domain.UUID(uid.String()), depth)
|
||||||
FROM blast_radius($1, $2) b
|
|
||||||
JOIN entities e ON e.id = b.entity_id
|
|
||||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
|
||||||
ORDER BY b.depth, e.slug`, id, depth)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
resp := gen.GetBlastRadius200JSONResponse{Items: []struct {
|
resp := gen.GetBlastRadius200JSONResponse{Items: []struct {
|
||||||
Depth int `json:"depth"`
|
Depth int `json:"depth"`
|
||||||
Entity gen.Entity `json:"entity"`
|
Entity gen.Entity `json:"entity"`
|
||||||
}{}}
|
}{}}
|
||||||
for rows.Next() {
|
for _, e := range items {
|
||||||
var e gen.Entity
|
|
||||||
var state *string
|
|
||||||
var attrsJSON []byte
|
|
||||||
var maint *time.Time
|
|
||||||
var health *string
|
|
||||||
var lastCheckAt *time.Time
|
|
||||||
var d int
|
|
||||||
if err := rows.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON,
|
|
||||||
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt, &health, &lastCheckAt, &d); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
e.State = state
|
|
||||||
e.MaintenanceUntil = maint
|
|
||||||
if health != nil {
|
|
||||||
h := gen.EntityHealth(*health)
|
|
||||||
e.Health = &h
|
|
||||||
}
|
|
||||||
e.LastCheckAt = lastCheckAt
|
|
||||||
var attrs map[string]any
|
|
||||||
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
|
|
||||||
e.Attributes = &attrs
|
|
||||||
}
|
|
||||||
resp.Items = append(resp.Items, struct {
|
resp.Items = append(resp.Items, struct {
|
||||||
Depth int `json:"depth"`
|
Depth int `json:"depth"`
|
||||||
Entity gen.Entity `json:"entity"`
|
Entity gen.Entity `json:"entity"`
|
||||||
}{Depth: d, Entity: e})
|
}{Depth: 0, Entity: entityWithHealthToGen(e)})
|
||||||
}
|
}
|
||||||
return resp, rows.Err()
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (gen.GetGraphResponseObject, error) {
|
func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (gen.GetGraphResponseObject, error) {
|
||||||
@@ -182,95 +112,36 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
|
|||||||
depth = *req.Params.Depth
|
depth = *req.Params.Depth
|
||||||
}
|
}
|
||||||
|
|
||||||
var nodes []gen.Entity
|
var rootID *domain.UUID
|
||||||
var err error
|
if req.Params.Root != nil && *req.Params.Root != "" {
|
||||||
truncated := false
|
uid, err := s.resolveEntityID(ctx, *req.Params.Root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
duid := domain.UUID(uid.String())
|
||||||
|
rootID = &duid
|
||||||
|
}
|
||||||
|
|
||||||
// pgx can't infer the array element type from a nil *[]string (the
|
|
||||||
// param is absent from the request, not an empty list), so dereference
|
|
||||||
// to a plain []string first — nil there still encodes as SQL NULL, but
|
|
||||||
// pgx has a concrete type to work with.
|
|
||||||
var relTypes []string
|
var relTypes []string
|
||||||
if req.Params.RelType != nil {
|
if req.Params.RelType != nil {
|
||||||
relTypes = *req.Params.RelType
|
relTypes = *req.Params.RelType
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Params.Root != nil && *req.Params.Root != "" {
|
nodes, edges, truncated, err := s.readModels.GetGraph(ctx, depth, rootID, relTypes, graphNodeCap)
|
||||||
rootID, rerr := s.resolveEntityID(ctx, *req.Params.Root)
|
|
||||||
if rerr != nil {
|
|
||||||
return nil, rerr
|
|
||||||
}
|
|
||||||
nodes, err = s.queryEntities(ctx, `
|
|
||||||
SELECT `+entityCols+`
|
|
||||||
FROM blast_radius($1, $2, $3) b JOIN entities e ON e.id = b.entity_id
|
|
||||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
|
||||||
ORDER BY e.slug`, rootID, depth, relTypes)
|
|
||||||
} else {
|
|
||||||
// Whole-graph view: pick the most-connected entities first so the
|
|
||||||
// graph shows actual topology, not just whatever sorts first
|
|
||||||
// alphabetically. Without this the cap fills with exec:* rows and
|
|
||||||
// drops every host/lxc/service/vm — and every edge those entities
|
|
||||||
// connect — because edges require both endpoints in the node set.
|
|
||||||
// Exclude the cognition transactional types (execution/task): they
|
|
||||||
// are audit records rather than topology, and at ~380 rows they
|
|
||||||
// consumed most of the old 500-node cap.
|
|
||||||
nodes, err = s.queryEntities(ctx, `
|
|
||||||
SELECT `+entityCols+`
|
|
||||||
FROM entities e
|
|
||||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
|
||||||
WHERE e.type NOT IN ('execution','task')
|
|
||||||
AND e.id IN (
|
|
||||||
SELECT e2.id FROM entities e2
|
|
||||||
LEFT JOIN relationships r ON r.valid_to IS NULL
|
|
||||||
AND (r.source_id = e2.id OR r.target_id = e2.id)
|
|
||||||
WHERE e2.type NOT IN ('execution','task')
|
|
||||||
GROUP BY e2.id
|
|
||||||
ORDER BY count(r.type) DESC, e2.slug
|
|
||||||
LIMIT $1
|
|
||||||
)
|
|
||||||
ORDER BY e.slug`,
|
|
||||||
graphNodeCap+1)
|
|
||||||
if err == nil && len(nodes) > graphNodeCap {
|
|
||||||
nodes = nodes[:graphNodeCap]
|
|
||||||
truncated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ids := make([]uuid.UUID, len(nodes))
|
genNodes := make([]gen.Entity, len(nodes))
|
||||||
for i, n := range nodes {
|
for i, n := range nodes {
|
||||||
ids[i] = uuid.UUID(n.Id)
|
genNodes[i] = entityWithHealthToGen(n)
|
||||||
}
|
}
|
||||||
edgeRows, err := sqlcgen.New(s.pool).ListGraphEdges(ctx, sqlcgen.ListGraphEdgesParams{
|
genEdges := make([]gen.Relationship, len(edges))
|
||||||
Ids: ids,
|
for i, e := range edges {
|
||||||
RelTypes: relTypes,
|
genEdges[i] = relToGen(e)
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
edges := []gen.Relationship{}
|
|
||||||
for _, r := range edgeRows {
|
|
||||||
var attrs *map[string]any
|
|
||||||
if len(r.Attributes) > 0 {
|
|
||||||
var m map[string]any
|
|
||||||
if json.Unmarshal(r.Attributes, &m) == nil && len(m) > 0 {
|
|
||||||
attrs = &m
|
|
||||||
}
|
|
||||||
}
|
|
||||||
validTo := r.ValidTo
|
|
||||||
edges = append(edges, gen.Relationship{
|
|
||||||
Source: r.SourceSlug,
|
|
||||||
Target: r.TargetSlug,
|
|
||||||
Type: r.Type,
|
|
||||||
Attributes: attrs,
|
|
||||||
ValidFrom: r.ValidFrom,
|
|
||||||
ValidTo: validTo,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := gen.GetGraph200JSONResponse{Nodes: nodes, Edges: edges}
|
resp := gen.GetGraph200JSONResponse{Nodes: genNodes, Edges: genEdges}
|
||||||
if truncated {
|
if truncated {
|
||||||
resp.Truncated = &truncated
|
resp.Truncated = &truncated
|
||||||
}
|
}
|
||||||
@@ -278,58 +149,71 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
|
|||||||
if req.Params.Include != nil {
|
if req.Params.Include != nil {
|
||||||
for _, inc := range *req.Params.Include {
|
for _, inc := range *req.Params.Include {
|
||||||
if inc == gen.Status {
|
if inc == gen.Status {
|
||||||
health, herr := s.entityHealthByID(ctx, ids)
|
health := make(map[string]gen.GraphViewHealth, len(nodes))
|
||||||
if herr != nil {
|
for _, n := range nodes {
|
||||||
return nil, herr
|
health[string(n.Entity.ID)] = gen.GraphViewHealth(n.Health)
|
||||||
}
|
}
|
||||||
resp.Health = &health
|
resp.Health = &health
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// entityHealthByID returns entity_status.health keyed by entity id, for the
|
func strOrEmpty(p *string) string {
|
||||||
// given id set (used by GetGraph's include=status).
|
if p == nil {
|
||||||
func (s *Server) entityHealthByID(ctx context.Context, ids []uuid.UUID) (map[string]gen.GraphViewHealth, error) {
|
return ""
|
||||||
health := make(map[string]gen.GraphViewHealth, len(ids))
|
|
||||||
rows, err := s.pool.Query(ctx,
|
|
||||||
`SELECT entity_id, health FROM entity_status WHERE entity_id = ANY($1)`, ids)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
return *p
|
||||||
for rows.Next() {
|
|
||||||
var id uuid.UUID
|
|
||||||
var h string
|
|
||||||
if err := rows.Scan(&id, &h); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
health[id.String()] = gen.GraphViewHealth(h)
|
|
||||||
}
|
|
||||||
return health, rows.Err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) queryEntities(ctx context.Context, query string, args ...any) ([]gen.Entity, error) {
|
func entityWithHealthToGen(e ports.EntityWithHealth) gen.Entity {
|
||||||
rows, err := s.pool.Query(ctx, query, args...)
|
id, _ := uuid.Parse(string(e.Entity.ID))
|
||||||
if err != nil {
|
out := gen.Entity{
|
||||||
return nil, err
|
Id: id,
|
||||||
|
Slug: e.Entity.Slug,
|
||||||
|
Type: e.Entity.Type,
|
||||||
|
Name: e.Entity.Name,
|
||||||
|
Version: e.Entity.Version,
|
||||||
|
CreatedAt: e.Entity.CreatedAt,
|
||||||
|
UpdatedAt: e.Entity.UpdatedAt,
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
if e.Entity.State != "" {
|
||||||
items := []gen.Entity{}
|
out.State = &e.Entity.State
|
||||||
for rows.Next() {
|
|
||||||
e, err := scanEntity(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, e)
|
|
||||||
}
|
}
|
||||||
return items, rows.Err()
|
if e.Entity.MaintenanceUntil != nil {
|
||||||
|
out.MaintenanceUntil = e.Entity.MaintenanceUntil
|
||||||
|
}
|
||||||
|
if len(e.Entity.Attributes) > 0 {
|
||||||
|
attrs := e.Entity.Attributes
|
||||||
|
out.Attributes = &attrs
|
||||||
|
}
|
||||||
|
if e.Health != "" {
|
||||||
|
h := gen.EntityHealth(e.Health)
|
||||||
|
out.Health = &h
|
||||||
|
}
|
||||||
|
out.LastCheckAt = e.LastCheckAt
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// sqlcEntityToGen converts a sqlcgen.Entity to a gen.Entity.
|
func relToGen(r domain.Relationship) gen.Relationship {
|
||||||
|
var attrs *map[string]any
|
||||||
|
if len(r.Attributes) > 0 {
|
||||||
|
attrs = &r.Attributes
|
||||||
|
}
|
||||||
|
return gen.Relationship{
|
||||||
|
Source: string(r.SourceID),
|
||||||
|
Target: string(r.TargetID),
|
||||||
|
Type: r.Type,
|
||||||
|
Attributes: attrs,
|
||||||
|
ValidFrom: r.ValidFrom,
|
||||||
|
ValidTo: r.ValidTo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sqlcEntityToGen converts a sqlcgen entity to the wire shape. Used by
|
||||||
|
// client_lifecycle.go (enroll) — the mutation handlers use domainToGen.
|
||||||
func sqlcEntityToGen(e sqlcgen.Entity) gen.Entity {
|
func sqlcEntityToGen(e sqlcgen.Entity) gen.Entity {
|
||||||
out := gen.Entity{
|
out := gen.Entity{
|
||||||
Id: e.ID,
|
Id: e.ID,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"github.com/dtoro/oikos/internal/config"
|
"github.com/dtoro/oikos/internal/config"
|
||||||
"github.com/dtoro/oikos/internal/adapters/postgres"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/dtoro/oikos/internal/core/app"
|
"github.com/dtoro/oikos/internal/core/app"
|
||||||
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
mcphandler "github.com/dtoro/oikos/internal/mcp"
|
mcphandler "github.com/dtoro/oikos/internal/mcp"
|
||||||
"github.com/dtoro/oikos/internal/safego"
|
"github.com/dtoro/oikos/internal/safego"
|
||||||
@@ -66,6 +67,7 @@ type Server struct {
|
|||||||
// replay path needs. Wired here until main becomes the composition root.
|
// replay path needs. Wired here until main becomes the composition root.
|
||||||
entities *app.EntityService
|
entities *app.EntityService
|
||||||
entityRepo *db.EntityRepo
|
entityRepo *db.EntityRepo
|
||||||
|
readModels ports.ReadModels
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
|
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
|
||||||
@@ -85,6 +87,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
|||||||
}
|
}
|
||||||
s.entityRepo = db.NewEntityRepo(pool)
|
s.entityRepo = db.NewEntityRepo(pool)
|
||||||
s.entities = app.NewEntityService(s.entityRepo, db.NewOntologyRepo(pool, time.Minute))
|
s.entities = app.NewEntityService(s.entityRepo, db.NewOntologyRepo(pool, time.Minute))
|
||||||
|
s.readModels = db.NewEntityReader(pool)
|
||||||
|
|
||||||
// Wire secrets backend: Infisical primary with SOPS DR fallback.
|
// Wire secrets backend: Infisical primary with SOPS DR fallback.
|
||||||
if cfg.InfisicalSiteURL != "" {
|
if cfg.InfisicalSiteURL != "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user