- SSE stream: GET /events/stream using io.Pipe to bridge the SSE goroutine to the response body. Replay from Last-Event-ID via in-memory broker with DB fallback. LISTEN/NOTIFY fan-out to all subscribers. Heartbeat every 15s. Bounded channels. - OIDC JWT auth: validates Bearer tokens against Authentik/OIDC issuer via JWKS discovery + key caching. Extracts sub/email into context actor. Falls back to static bearer tokens. Dev mode (no OIDC + no tokens) = open. - Config: OIDCIssuer, OIDCClientID env vars - SSE + OIDC infrastructure complete, build passes, all tests pass Remaining: MCP server, conformance tests, wire audit middleware
1062 lines
32 KiB
Go
1062 lines
32 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dtoro/oikos/internal/db"
|
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
|
"github.com/dtoro/oikos/internal/domain"
|
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
|
"github.com/dtoro/oikos/internal/observability"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
const (
|
|
defaultLimit = 50
|
|
maxLimit = 200
|
|
graphNodeCap = 500
|
|
)
|
|
|
|
func clampLimit(l *int) int {
|
|
if l == nil {
|
|
return defaultLimit
|
|
}
|
|
if *l < 1 {
|
|
return 1
|
|
}
|
|
if *l > maxLimit {
|
|
return maxLimit
|
|
}
|
|
return *l
|
|
}
|
|
|
|
// resolveEntityID resolves a UUID-or-slug path/query value to the entity UUID.
|
|
func (s *Server) resolveEntityID(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
|
|
if id, err := uuid.Parse(idOrSlug); err == nil {
|
|
var found uuid.UUID
|
|
err := s.pool.QueryRow(ctx, "SELECT id FROM entities WHERE id = $1", id).Scan(&found)
|
|
if err == pgx.ErrNoRows {
|
|
return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug)
|
|
}
|
|
return found, err
|
|
}
|
|
var id uuid.UUID
|
|
err := s.pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", idOrSlug).Scan(&id)
|
|
if err == pgx.ErrNoRows {
|
|
return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug)
|
|
}
|
|
return id, err
|
|
}
|
|
|
|
// entityCols requires the entities table to be aliased as `e`.
|
|
const entityCols = `e.id, e.slug, e.type, e.name, e.state, e.attributes,
|
|
e.maintenance_until, e.version, e.created_at, e.updated_at`
|
|
|
|
func scanEntity(row pgx.Row) (gen.Entity, error) {
|
|
var e gen.Entity
|
|
var state *string
|
|
var attrsJSON []byte
|
|
var maint *time.Time
|
|
err := row.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON,
|
|
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt)
|
|
if err != nil {
|
|
return e, err
|
|
}
|
|
e.State = state
|
|
e.MaintenanceUntil = maint
|
|
var attrs map[string]any
|
|
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
|
|
e.Attributes = &attrs
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
// ─── Entities ─────────────────────────────────────────────────────────
|
|
|
|
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
|
|
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 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)
|
|
}
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT se.slug, te.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 (($3 IN ('out','both') AND r.source_id = $1)
|
|
OR ($3 IN ('in','both') AND r.target_id = $1))
|
|
AND ($2::text IS NULL OR r.type = $2)
|
|
ORDER BY r.type, se.slug, te.slug`,
|
|
id, req.Params.RelType, dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items, err := scanRelationships(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.GetEntityRelations200JSONResponse{Items: items}, nil
|
|
}
|
|
|
|
func scanRelationships(rows pgx.Rows) ([]gen.Relationship, error) {
|
|
defer rows.Close()
|
|
items := []gen.Relationship{}
|
|
for rows.Next() {
|
|
var rel gen.Relationship
|
|
var attrsJSON []byte
|
|
if err := rows.Scan(&rel.Source, &rel.Target, &rel.Type,
|
|
&attrsJSON, &rel.ValidFrom, &rel.ValidTo); err != nil {
|
|
return nil, err
|
|
}
|
|
var attrs map[string]any
|
|
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
|
|
rel.Attributes = &attrs
|
|
}
|
|
items = append(items, rel)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
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
|
|
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 d int
|
|
if err := rows.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON,
|
|
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt, &d); err != nil {
|
|
return nil, err
|
|
}
|
|
e.State = state
|
|
e.MaintenanceUntil = maint
|
|
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
|
|
|
|
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
|
|
ORDER BY e.slug`, rootID, depth, req.Params.RelType)
|
|
} else {
|
|
nodes, err = s.queryEntities(ctx, `
|
|
SELECT `+entityCols+` FROM entities e ORDER BY e.slug LIMIT $1`,
|
|
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)
|
|
}
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT se.slug, te.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) AND r.target_id = ANY($1)
|
|
AND ($2::text[] IS NULL OR r.type = ANY($2))
|
|
ORDER BY r.type, se.slug, te.slug`, ids, req.Params.RelType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
edges, err := scanRelationships(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := gen.GetGraph200JSONResponse{Nodes: nodes, Edges: edges}
|
|
if truncated {
|
|
resp.Truncated = &truncated
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
// ─── Ontology ─────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) GetOntology(ctx context.Context, req gen.GetOntologyRequestObject) (gen.GetOntologyResponseObject, error) {
|
|
resp := gen.GetOntology200JSONResponse{
|
|
EntityTypes: []gen.EntityType{},
|
|
RelationshipTypes: []gen.RelationshipType{},
|
|
Lifecycles: []gen.LifecycleDef{},
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT name, parent_type, is_abstract, domain, layer, description,
|
|
lifecycle_id, attribute_schema, schema_version, status
|
|
FROM entity_types ORDER BY name`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
var et gen.EntityType
|
|
var schemaVersion int
|
|
var schemaJSON []byte
|
|
if err := rows.Scan(&et.Name, &et.ParentType, &et.IsAbstract, &et.Domain,
|
|
&et.Layer, &et.Description, &et.LifecycleId, &schemaJSON,
|
|
&schemaVersion, &et.Status); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
et.SchemaVersion = &schemaVersion
|
|
var schema map[string]any
|
|
if len(schemaJSON) > 0 && json.Unmarshal(schemaJSON, &schema) == nil && schema != nil {
|
|
et.AttributeSchema = &schema
|
|
}
|
|
resp.EntityTypes = append(resp.EntityTypes, et)
|
|
}
|
|
rows.Close()
|
|
if rows.Err() != nil {
|
|
return nil, rows.Err()
|
|
}
|
|
|
|
rows, err = s.pool.Query(ctx, `
|
|
SELECT name, inverse, source_type, target_type, cardinality, description
|
|
FROM relationship_types ORDER BY name`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
var rt gen.RelationshipType
|
|
if err := rows.Scan(&rt.Name, &rt.Inverse, &rt.SourceType, &rt.TargetType,
|
|
&rt.Cardinality, &rt.Description); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
resp.RelationshipTypes = append(resp.RelationshipTypes, rt)
|
|
}
|
|
rows.Close()
|
|
if rows.Err() != nil {
|
|
return nil, rows.Err()
|
|
}
|
|
|
|
rows, err = s.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 lc gen.LifecycleDef
|
|
var terminal []string
|
|
var transJSON []byte
|
|
if err := rows.Scan(&lc.Id, &lc.States, &lc.DefaultState, &terminal, &transJSON); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
lc.TerminalStates = &terminal
|
|
if err := json.Unmarshal(transJSON, &lc.Transitions); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("lifecycle %s transitions: %w", lc.Id, err)
|
|
}
|
|
resp.Lifecycles = append(resp.Lifecycles, lc)
|
|
}
|
|
rows.Close()
|
|
return resp, rows.Err()
|
|
}
|
|
|
|
// ─── Signals ──────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) ListSignals(ctx context.Context, req gen.ListSignalsRequestObject) (gen.ListSignalsResponseObject, error) {
|
|
limit := clampLimit(req.Params.Limit)
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT sig.entity_id, se.slug, sig.kind, sig.severity, sig.state,
|
|
te.slug, sig.check_id::text, 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 ($1::text IS NULL OR sig.state = $1)
|
|
AND ($2::text IS NULL OR sig.severity = $2)
|
|
AND ($3::text IS NULL OR te.slug = $3)
|
|
AND ($4::text IS NULL OR sig.kind = $4)
|
|
AND ($5::text IS NULL OR se.slug > $5)
|
|
ORDER BY se.slug
|
|
LIMIT $6`,
|
|
req.Params.State, (*string)(req.Params.Severity), req.Params.EntityId,
|
|
req.Params.Kind, req.Params.Cursor, limit+1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []gen.Signal{}
|
|
for rows.Next() {
|
|
var sig gen.Signal
|
|
var flap int
|
|
if err := rows.Scan(&sig.Id, &sig.Slug, &sig.Kind, &sig.Severity, &sig.State,
|
|
&sig.Target, &sig.CheckId, &sig.Evidence, &sig.LikelyCause,
|
|
&sig.OccurrenceCount, &flap, &sig.HoldDownUntil,
|
|
&sig.MuteUntil, &sig.FirstSeenAt, &sig.LastSeenAt); err != nil {
|
|
return nil, err
|
|
}
|
|
sig.FlapCount = &flap
|
|
items = append(items, sig)
|
|
}
|
|
if rows.Err() != nil {
|
|
return nil, rows.Err()
|
|
}
|
|
|
|
var next *string
|
|
if len(items) > limit {
|
|
items = items[:limit]
|
|
next = &items[len(items)-1].Slug
|
|
}
|
|
return gen.ListSignals200JSONResponse{Items: items, NextCursor: next}, nil
|
|
}
|
|
|
|
// ─── Observability + system ───────────────────────────────────────────
|
|
|
|
func (s *Server) GetFleetHealth(ctx context.Context, req gen.GetFleetHealthRequestObject) (gen.GetFleetHealthResponseObject, error) {
|
|
resp := gen.GetFleetHealth200JSONResponse{}
|
|
resp.Entities = []struct {
|
|
Health gen.HealthSummaryEntitiesHealth `json:"health"`
|
|
LastCheckAt *time.Time `json:"last_check_at"`
|
|
Slug string `json:"slug"`
|
|
Trend *gen.HealthSummaryEntitiesTrend `json:"trend"`
|
|
Type string `json:"type"`
|
|
}{}
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT e.slug, e.type, st.health, st.last_check_at
|
|
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
|
ORDER BY e.slug`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var slug, typ, health string
|
|
var lastCheck *time.Time
|
|
if err := rows.Scan(&slug, &typ, &health, &lastCheck); err != nil {
|
|
return nil, err
|
|
}
|
|
switch health {
|
|
case "healthy":
|
|
resp.Summary.Healthy++
|
|
case "degraded":
|
|
resp.Summary.Degraded++
|
|
case "down":
|
|
resp.Summary.Down++
|
|
default:
|
|
resp.Summary.Unknown++
|
|
}
|
|
resp.Entities = append(resp.Entities, struct {
|
|
Health gen.HealthSummaryEntitiesHealth `json:"health"`
|
|
LastCheckAt *time.Time `json:"last_check_at"`
|
|
Slug string `json:"slug"`
|
|
Trend *gen.HealthSummaryEntitiesTrend `json:"trend"`
|
|
Type string `json:"type"`
|
|
}{
|
|
Health: gen.HealthSummaryEntitiesHealth(health),
|
|
LastCheckAt: lastCheck,
|
|
Slug: slug,
|
|
Type: typ,
|
|
})
|
|
}
|
|
return resp, rows.Err()
|
|
}
|
|
|
|
func (s *Server) ExportSeeds(ctx context.Context, req gen.ExportSeedsRequestObject) (gen.ExportSeedsResponseObject, error) {
|
|
exports, err := db.ExportToYAML(ctx, s.pool)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.ExportSeeds200JSONResponse{
|
|
Ontology: string(exports["ontology.yaml"]),
|
|
Inventory: string(exports["inventory.yaml"]),
|
|
Policy: string(exports["policy.yaml"]),
|
|
}, nil
|
|
}
|
|
|
|
// ─── Signal mutations ────────────────────────────────────────────────
|
|
|
|
func (s *Server) AckSignal(ctx context.Context, req gen.AckSignalRequestObject) (gen.AckSignalResponseObject, error) {
|
|
id, err := s.resolveEntityID(ctx, req.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var sig gen.Signal
|
|
err = tx.QueryRow(ctx, `
|
|
UPDATE signals SET state = 'acknowledged', updated_at = now()
|
|
WHERE entity_id = $1 AND state IN ('raised','failed')
|
|
RETURNING entity_id, (SELECT slug FROM entities WHERE id = $1),
|
|
kind, severity, 'acknowledged',
|
|
(SELECT slug FROM entities WHERE id = target_entity_id),
|
|
check_id::text, evidence, likely_cause,
|
|
occurrence_count, flap_count, hold_down_until,
|
|
mute_until, first_seen_at, last_seen_at`,
|
|
id).Scan(&sig.Id, &sig.Slug, &sig.Kind, &sig.Severity, &sig.State,
|
|
&sig.Target, &sig.CheckId, &sig.Evidence, &sig.LikelyCause,
|
|
&sig.OccurrenceCount, &sig.FlapCount, &sig.HoldDownUntil,
|
|
&sig.MuteUntil, &sig.FirstSeenAt, &sig.LastSeenAt)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, fmt.Errorf("%w: signal %s not in a state that can be acknowledged", domain.ErrInvalidTransition, req.Id)
|
|
}
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.AckSignal200JSONResponse{gen.SignalUpdatedJSONResponse(sig)}, nil
|
|
}
|
|
|
|
func (s *Server) ResolveSignal(ctx context.Context, req gen.ResolveSignalRequestObject) (gen.ResolveSignalResponseObject, error) {
|
|
id, err := s.resolveEntityID(ctx, req.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var sig gen.Signal
|
|
err = tx.QueryRow(ctx, `
|
|
UPDATE signals SET state = 'resolved', updated_at = now()
|
|
WHERE entity_id = $1 AND state IN ('raised','acknowledged','acting','failed')
|
|
RETURNING entity_id, (SELECT slug FROM entities WHERE id = $1),
|
|
kind, severity, 'resolved',
|
|
(SELECT slug FROM entities WHERE id = target_entity_id),
|
|
check_id::text, evidence, likely_cause,
|
|
occurrence_count, flap_count, hold_down_until,
|
|
mute_until, first_seen_at, last_seen_at`,
|
|
id).Scan(&sig.Id, &sig.Slug, &sig.Kind, &sig.Severity, &sig.State,
|
|
&sig.Target, &sig.CheckId, &sig.Evidence, &sig.LikelyCause,
|
|
&sig.OccurrenceCount, &sig.FlapCount, &sig.HoldDownUntil,
|
|
&sig.MuteUntil, &sig.FirstSeenAt, &sig.LastSeenAt)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, fmt.Errorf("%w: signal %s not in a state that can be resolved", domain.ErrInvalidTransition, req.Id)
|
|
}
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.ResolveSignal200JSONResponse{gen.SignalUpdatedJSONResponse(sig)}, nil
|
|
}
|
|
|
|
func (s *Server) MuteSignal(ctx context.Context, req gen.MuteSignalRequestObject) (gen.MuteSignalResponseObject, error) {
|
|
id, err := s.resolveEntityID(ctx, req.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var sig gen.Signal
|
|
err = tx.QueryRow(ctx, `
|
|
UPDATE signals SET state = 'muted', mute_until = $2, updated_at = now()
|
|
WHERE entity_id = $1 AND state IN ('raised','acknowledged')
|
|
RETURNING entity_id, (SELECT slug FROM entities WHERE id = $1),
|
|
kind, severity, 'muted',
|
|
(SELECT slug FROM entities WHERE id = target_entity_id),
|
|
check_id::text, evidence, likely_cause,
|
|
occurrence_count, flap_count, hold_down_until,
|
|
mute_until, first_seen_at, last_seen_at`,
|
|
id, req.Body.MuteUntil).Scan(&sig.Id, &sig.Slug, &sig.Kind, &sig.Severity, &sig.State,
|
|
&sig.Target, &sig.CheckId, &sig.Evidence, &sig.LikelyCause,
|
|
&sig.OccurrenceCount, &sig.FlapCount, &sig.HoldDownUntil,
|
|
&sig.MuteUntil, &sig.FirstSeenAt, &sig.LastSeenAt)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, fmt.Errorf("%w: signal %s not in a state that can be muted", domain.ErrInvalidTransition, req.Id)
|
|
}
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return gen.MuteSignal200JSONResponse{gen.SignalUpdatedJSONResponse(sig)}, nil
|
|
}
|
|
|
|
// ─── Observability reads ─────────────────────────────────────────────
|
|
|
|
func (s *Server) QueryEvents(ctx context.Context, req gen.QueryEventsRequestObject) (gen.QueryEventsResponseObject, error) {
|
|
limit := clampLimit(req.Params.Limit)
|
|
var eventType, entityID, severity, correlationID *string
|
|
if req.Params.Type != nil {
|
|
eventType = req.Params.Type
|
|
}
|
|
if req.Params.EntityId != nil {
|
|
entityID = req.Params.EntityId
|
|
}
|
|
if req.Params.Severity != nil {
|
|
severity = req.Params.Severity
|
|
}
|
|
if req.Params.CorrelationId != nil {
|
|
correlationID = req.Params.CorrelationId
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, ts, type, entity_id::text, severity, source, data, correlation_id
|
|
FROM events
|
|
WHERE ($1::text IS NULL OR type = $1)
|
|
AND ($2::text IS NULL OR entity_id::text = $2)
|
|
AND ($3::text IS NULL OR severity = $3)
|
|
AND ($4::text IS NULL OR correlation_id = $4)
|
|
AND ($5::timestamptz IS NULL OR ts >= $5)
|
|
AND ($6::timestamptz IS NULL OR ts <= $6)
|
|
ORDER BY ts DESC
|
|
LIMIT $7`,
|
|
eventType, entityID, severity, correlationID, req.Params.From, req.Params.To, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []gen.Event{}
|
|
for rows.Next() {
|
|
var e gen.Event
|
|
var dataBytes []byte
|
|
var entID, corrID *string
|
|
if err := rows.Scan(&e.Id, &e.Ts, &e.Type, &entID, &e.Severity, &e.Source, &dataBytes, &corrID); err != nil {
|
|
return nil, err
|
|
}
|
|
e.EntityId = entID
|
|
e.CorrelationId = corrID
|
|
var data map[string]any
|
|
if json.Unmarshal(dataBytes, &data) == nil {
|
|
e.Data = &data
|
|
}
|
|
items = append(items, e)
|
|
}
|
|
return gen.QueryEvents200JSONResponse{Items: items}, rows.Err()
|
|
}
|
|
|
|
func (s *Server) QueryAudit(ctx context.Context, req gen.QueryAuditRequestObject) (gen.QueryAuditResponseObject, error) {
|
|
limit := clampLimit(req.Params.Limit)
|
|
var actorType, actorID, action, entityID, correlationID *string
|
|
if req.Params.ActorType != nil {
|
|
actorType = req.Params.ActorType
|
|
}
|
|
if req.Params.ActorId != nil {
|
|
actorID = req.Params.ActorId
|
|
}
|
|
if req.Params.Action != nil {
|
|
action = req.Params.Action
|
|
}
|
|
if req.Params.EntityId != nil {
|
|
entityID = req.Params.EntityId
|
|
}
|
|
if req.Params.CorrelationId != nil {
|
|
correlationID = req.Params.CorrelationId
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, ts, actor_type, actor_id::text, action, entity_id::text,
|
|
method, path, status_code, detail, source_ip, correlation_id
|
|
FROM audit_log
|
|
WHERE ($1::text IS NULL OR actor_type = $1)
|
|
AND ($2::text IS NULL OR actor_id::text = $2)
|
|
AND ($3::text IS NULL OR action = $3)
|
|
AND ($4::text IS NULL OR entity_id::text = $4)
|
|
AND ($5::text IS NULL OR correlation_id = $5)
|
|
AND ($6::timestamptz IS NULL OR ts >= $6)
|
|
AND ($7::timestamptz IS NULL OR ts <= $7)
|
|
ORDER BY ts DESC
|
|
LIMIT $8`,
|
|
actorType, actorID, action, entityID, correlationID, req.Params.From, req.Params.To, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []gen.AuditEntry{}
|
|
for rows.Next() {
|
|
var a gen.AuditEntry
|
|
var detailBytes []byte
|
|
var actID, entID, method, path, sourceIP, corrID *string
|
|
var statusCode *int
|
|
if err := rows.Scan(&a.Id, &a.Ts, &a.ActorType, &actID, &a.Action, &entID,
|
|
&method, &path, &statusCode, &detailBytes, &sourceIP, &corrID); err != nil {
|
|
return nil, err
|
|
}
|
|
a.ActorId = actID
|
|
a.EntityId = entID
|
|
a.Method = method
|
|
a.Path = path
|
|
a.StatusCode = statusCode
|
|
a.SourceIp = sourceIP
|
|
a.CorrelationId = corrID
|
|
var detail map[string]any
|
|
if json.Unmarshal(detailBytes, &detail) == nil {
|
|
a.Detail = &detail
|
|
}
|
|
items = append(items, a)
|
|
}
|
|
return gen.QueryAudit200JSONResponse{Items: items}, rows.Err()
|
|
}
|
|
|
|
// ─── Entity mutations ──────────────────────────────────────────────
|
|
|
|
func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestObject) (gen.CreateEntityResponseObject, error) {
|
|
if req.Body == nil {
|
|
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
|
}
|
|
|
|
// Check idempotency if a key was provided.
|
|
actor := "operator"
|
|
var bodyHash string
|
|
if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" {
|
|
key := *req.Params.IdempotencyKey
|
|
q := sqlcgen.New(s.pool)
|
|
cached, err := q.GetIdempotentResponse(ctx, sqlcgen.GetIdempotentResponseParams{
|
|
Actor: actor,
|
|
Key: key,
|
|
})
|
|
if err == nil {
|
|
// Verify the request body hasn't changed.
|
|
bodyJSON, _ := json.Marshal(req.Body)
|
|
bodyHash = fmt.Sprintf("%x", sha256.Sum256(bodyJSON))
|
|
if cached.RequestHash != bodyHash {
|
|
return nil, fmt.Errorf("%w: idempotency key %s used with different request body", domain.ErrConflict, key)
|
|
}
|
|
// Replay the cached response.
|
|
if cached.ResponseCode != nil && *cached.ResponseCode == 201 {
|
|
var entity gen.Entity
|
|
if len(cached.ResponseBody) > 0 {
|
|
if err := json.Unmarshal(cached.ResponseBody, &entity); err != nil {
|
|
return nil, fmt.Errorf("unmarshal cached response: %w", err)
|
|
}
|
|
}
|
|
return gen.CreateEntity201JSONResponse{
|
|
Body: entity,
|
|
Headers: gen.CreateEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
|
|
}, nil
|
|
}
|
|
// Forward cached error response.
|
|
return gen.CreateEntitydefaultApplicationProblemPlusJSONResponse{
|
|
Body: gen.Problem{Status: int(*cached.ResponseCode), Title: "replayed error"},
|
|
StatusCode: int(*cached.ResponseCode),
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
id := uuid.New()
|
|
slug := req.Body.Slug
|
|
if slug == "" {
|
|
slug = req.Body.Type + ":" + req.Body.Name
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
q := sqlcgen.New(tx)
|
|
|
|
// Validate type exists and is NOT abstract.
|
|
var isAbstract bool
|
|
if err := tx.QueryRow(ctx, `SELECT is_abstract FROM entity_types WHERE name = $1`, req.Body.Type).Scan(&isAbstract); err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, fmt.Errorf("%w: entity type %q", domain.ErrNotFound, req.Body.Type)
|
|
}
|
|
return nil, err
|
|
}
|
|
if isAbstract {
|
|
return nil, fmt.Errorf("%w: %s", domain.ErrAbstractType, req.Body.Type)
|
|
}
|
|
|
|
// Get default state from lifecycle.
|
|
var defaultState *string
|
|
var lcDefault string
|
|
if err := tx.QueryRow(ctx, `SELECT ld.default_state FROM lifecycle_defs ld
|
|
JOIN entity_types et ON et.lifecycle_id = ld.id
|
|
WHERE et.name = $1`, req.Body.Type).Scan(&lcDefault); err == nil {
|
|
defaultState = &lcDefault
|
|
}
|
|
|
|
state := req.Body.State
|
|
if state == nil && defaultState != nil {
|
|
state = defaultState
|
|
}
|
|
|
|
var attrsJSON []byte
|
|
if req.Body.Attributes != nil {
|
|
attrsJSON, _ = json.Marshal(req.Body.Attributes)
|
|
}
|
|
|
|
// Insert the entity.
|
|
inserted, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
|
|
ID: id,
|
|
Slug: slug,
|
|
Type: req.Body.Type,
|
|
Name: req.Body.Name,
|
|
State: state,
|
|
Attributes: attrsJSON,
|
|
})
|
|
if err != nil {
|
|
// Duplicate slug.
|
|
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
|
|
return nil, fmt.Errorf("%w: slug %q already exists", domain.ErrAlreadyExists, slug)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Convert sqlcgen.Entity → gen.Entity.
|
|
entity := sqlcEntityToGen(inserted)
|
|
|
|
// Cache idempotent response.
|
|
if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" {
|
|
respBody, _ := json.Marshal(entity)
|
|
code := int32(201)
|
|
if bodyHash == "" {
|
|
bodyJSON, _ := json.Marshal(req.Body)
|
|
bodyHash = fmt.Sprintf("%x", sha256.Sum256(bodyJSON))
|
|
}
|
|
if putErr := q.PutIdempotentResponse(ctx, sqlcgen.PutIdempotentResponseParams{
|
|
Actor: actor,
|
|
Key: *req.Params.IdempotencyKey,
|
|
RequestHash: bodyHash,
|
|
ResponseCode: &code,
|
|
ResponseBody: respBody,
|
|
}); putErr != nil {
|
|
return nil, putErr
|
|
}
|
|
}
|
|
|
|
// Audit.
|
|
entityID := inserted.ID
|
|
if auditErr := observability.Audit(ctx, q, "operator", actor, "create",
|
|
&entityID, "POST", "/api/v1/entities", "",
|
|
map[string]any{"type": req.Body.Type, "slug": slug}); auditErr != nil {
|
|
return nil, auditErr
|
|
}
|
|
|
|
if eventErr := observability.Event(ctx, q, "entity.created", &entityID,
|
|
"info", "oikos-api", "",
|
|
map[string]any{"slug": slug, "type": req.Body.Type}); eventErr != nil {
|
|
return nil, eventErr
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return gen.CreateEntity201JSONResponse{
|
|
Body: entity,
|
|
Headers: gen.CreateEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObject) (gen.PatchEntityResponseObject, error) {
|
|
if req.Body == nil {
|
|
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
|
}
|
|
|
|
id, err := s.resolveEntityID(ctx, req.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parse If-Match header (quoted version string).
|
|
ifMatch := strings.Trim(req.Params.IfMatch, `"`)
|
|
expectedVersion, err := strconv.Atoi(ifMatch)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: invalid If-Match header %q", domain.ErrInvalidInput, req.Params.IfMatch)
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
// Get current entity for version check + lifecycle validation.
|
|
current, err := sqlcgen.New(tx).GetEntityByID(ctx, id)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, fmt.Errorf("%w: %s", domain.ErrNotFound, req.Id)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if int(current.Version) != expectedVersion {
|
|
return nil, fmt.Errorf("%w: expected version %d, current version %d",
|
|
domain.ErrConflict, expectedVersion, current.Version)
|
|
}
|
|
|
|
// Validate lifecycle transition if state is being changed.
|
|
if req.Body.State != nil && *req.Body.State != "" {
|
|
// Get lifecycle def for the entity's type.
|
|
lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, current.Type)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
// No lifecycle defined — any state is allowed.
|
|
_ = lc
|
|
} else {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
// Check the transition is valid.
|
|
var transitions map[string][]string
|
|
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
|
|
return nil, fmt.Errorf("parse lifecycle transitions: %w", err)
|
|
}
|
|
|
|
fromState := ""
|
|
if current.State != nil {
|
|
fromState = *current.State
|
|
}
|
|
toState := *req.Body.State
|
|
|
|
if allowed, ok := transitions[fromState]; ok {
|
|
found := false
|
|
for _, s := range allowed {
|
|
if s == toState {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("%w: %s → %s", domain.ErrInvalidTransition, fromState, toState)
|
|
}
|
|
} else if fromState != "" {
|
|
// No transitions defined from current state.
|
|
return nil, fmt.Errorf("%w: %s → %s", domain.ErrInvalidTransition, fromState, toState)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check idempotency (note: the spec doesn't define Idempotency-Key for PATCH,
|
|
// but we handle it if the generated code ever adds it).
|
|
// For now, no idempotency check on PATCH.
|
|
|
|
// Marshal attributes if provided.
|
|
var attrsJSON []byte
|
|
if req.Body.Attributes != nil {
|
|
attrsJSON, _ = json.Marshal(req.Body.Attributes)
|
|
}
|
|
|
|
// Perform the update via sqlcgen.
|
|
q := sqlcgen.New(tx)
|
|
updated, err := q.UpdateEntity(ctx, sqlcgen.UpdateEntityParams{
|
|
Name: req.Body.Name,
|
|
State: req.Body.State,
|
|
Attributes: attrsJSON,
|
|
SetMaintenance: req.Body.MaintenanceUntil != nil,
|
|
MaintenanceUntil: req.Body.MaintenanceUntil,
|
|
ID: id,
|
|
Version: int32(expectedVersion),
|
|
})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
// Version mismatch or entity not found.
|
|
return nil, fmt.Errorf("%w: entity was modified concurrently", domain.ErrConflict)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
entity := sqlcEntityToGen(updated)
|
|
|
|
// Audit.
|
|
if auditErr := observability.Audit(ctx, q, "operator", "operator", "patch",
|
|
&id, "PATCH", "/api/v1/entities/"+req.Id, "",
|
|
map[string]any{"version": expectedVersion}); auditErr != nil {
|
|
return nil, auditErr
|
|
}
|
|
|
|
if eventErr := observability.Event(ctx, q, "entity.updated", &id,
|
|
"info", "oikos-api", "",
|
|
map[string]any{"slug": entity.Slug, "type": entity.Type, "version": updated.Version}); eventErr != nil {
|
|
return nil, eventErr
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return gen.PatchEntity200JSONResponse{
|
|
Body: entity,
|
|
Headers: gen.PatchEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
|
|
}, nil
|
|
}
|
|
|
|
// 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
|
|
}
|