E1: split monolithic files — cmd/nomos (main.go → server.go + mcp.go + workers.go),
internal/mcp/tools.go → entity_tools/ops_tools/knowledge_tools/analysis_tools,
internal/httpapi/impl.go → domain files (entities, events, signals, ontology,
fleet_health, client_context, client_lifecycle, entity_mutations, query_audit).
E2: migrate raw pool.Exec queries to sqlc (entities/relationships queries + generated).
E3: unify SSH — consolidate crypto/ssh dial into actuator/client.go (+client_test).
E4/E5: add tests — db/lifecycle, checkdefaults/build, ontology/preconditions, policy/risk.
354 lines
9.6 KiB
Go
354 lines
9.6 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func (s *Server) ListEntities(ctx context.Context, req gen.ListEntitiesRequestObject) (gen.ListEntitiesResponseObject, error) {
|
|
limit := clampLimit(req.Params.Limit)
|
|
|
|
// Type filter includes descendants via the parent hierarchy (R3-1).
|
|
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 {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var items []gen.Entity
|
|
for rows.Next() {
|
|
e, err := scanEntity(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, e)
|
|
}
|
|
if rows.Err() != nil {
|
|
return nil, rows.Err()
|
|
}
|
|
|
|
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) {
|
|
id, err := s.resolveEntityID(ctx, req.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
e, err := scanEntity(s.pool.QueryRow(ctx,
|
|
"SELECT "+entityCols+" FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.id = $1", id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.GetEntity200JSONResponse{
|
|
Body: e,
|
|
Headers: gen.GetEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(e.Version) + `"`},
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) GetEntityRelations(ctx context.Context, req gen.GetEntityRelationsRequestObject) (gen.GetEntityRelationsResponseObject, error) {
|
|
id, err := s.resolveEntityID(ctx, req.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dir := "both"
|
|
if req.Params.Direction != nil {
|
|
dir = string(*req.Params.Direction)
|
|
}
|
|
relType := req.Params.RelType
|
|
rows, err := sqlcgen.New(s.pool).ListEntityRelations(ctx, sqlcgen.ListEntityRelationsParams{
|
|
Direction: dir,
|
|
ID: id,
|
|
RelType: relType,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := []gen.Relationship{}
|
|
for _, r := range rows {
|
|
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
|
|
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
|
|
}
|
|
|
|
func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusRequestObject) (gen.GetBlastRadiusResponseObject, error) {
|
|
id, err := s.resolveEntityID(ctx, req.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
depth := 3
|
|
if req.Params.Depth != nil {
|
|
depth = *req.Params.Depth
|
|
}
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT `+entityCols+`, 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`, id, depth)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
resp := gen.GetBlastRadius200JSONResponse{Items: []struct {
|
|
Depth int `json:"depth"`
|
|
Entity gen.Entity `json:"entity"`
|
|
}{}}
|
|
for rows.Next() {
|
|
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 {
|
|
Depth int `json:"depth"`
|
|
Entity gen.Entity `json:"entity"`
|
|
}{Depth: d, Entity: e})
|
|
}
|
|
return resp, rows.Err()
|
|
}
|
|
|
|
func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (gen.GetGraphResponseObject, error) {
|
|
depth := 2
|
|
if req.Params.Depth != nil {
|
|
depth = *req.Params.Depth
|
|
}
|
|
|
|
var nodes []gen.Entity
|
|
var err error
|
|
truncated := false
|
|
|
|
// 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
|
|
if req.Params.RelType != nil {
|
|
relTypes = *req.Params.RelType
|
|
}
|
|
|
|
if req.Params.Root != nil && *req.Params.Root != "" {
|
|
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 {
|
|
return nil, err
|
|
}
|
|
|
|
ids := make([]uuid.UUID, len(nodes))
|
|
for i, n := range nodes {
|
|
ids[i] = uuid.UUID(n.Id)
|
|
}
|
|
edgeRows, err := sqlcgen.New(s.pool).ListGraphEdges(ctx, sqlcgen.ListGraphEdgesParams{
|
|
Ids: ids,
|
|
RelTypes: relTypes,
|
|
})
|
|
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}
|
|
if truncated {
|
|
resp.Truncated = &truncated
|
|
}
|
|
|
|
if req.Params.Include != nil {
|
|
for _, inc := range *req.Params.Include {
|
|
if inc == gen.Status {
|
|
health, herr := s.entityHealthByID(ctx, ids)
|
|
if herr != nil {
|
|
return nil, herr
|
|
}
|
|
resp.Health = &health
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// entityHealthByID returns entity_status.health keyed by entity id, for the
|
|
// given id set (used by GetGraph's include=status).
|
|
func (s *Server) entityHealthByID(ctx context.Context, ids []uuid.UUID) (map[string]gen.GraphViewHealth, error) {
|
|
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()
|
|
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) {
|
|
rows, err := s.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []gen.Entity{}
|
|
for rows.Next() {
|
|
e, err := scanEntity(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, e)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
// sqlcEntityToGen converts a sqlcgen.Entity to a gen.Entity.
|
|
func sqlcEntityToGen(e sqlcgen.Entity) gen.Entity {
|
|
out := gen.Entity{
|
|
Id: e.ID,
|
|
Slug: e.Slug,
|
|
Type: e.Type,
|
|
Name: e.Name,
|
|
State: e.State,
|
|
Version: int(e.Version),
|
|
CreatedAt: e.CreatedAt,
|
|
UpdatedAt: e.UpdatedAt,
|
|
}
|
|
if e.MaintenanceUntil != nil {
|
|
out.MaintenanceUntil = e.MaintenanceUntil
|
|
}
|
|
if len(e.Attributes) > 0 {
|
|
var attrs map[string]any
|
|
if json.Unmarshal(e.Attributes, &attrs) == nil && len(attrs) > 0 {
|
|
out.Attributes = &attrs
|
|
}
|
|
}
|
|
return out
|
|
} |