Stage 1 — Foundation:
- Target validation: iptables + systemctl/docker target-type gates
- Async run for long-running commands (sleep/wait/poll loops)
- audit_log.session_id plumbing (SQL, sqlcgen, 18 call sites)
Stage 2 — External agent observe (11 tools):
- get_dashboard_summary, get_ontology, list_checks, list_executions
- get_knowledge_revisions, get_knowledge_duplicates, get_knowledge_orphans
- list_knowledge_tags, list_entity_sessions, find_entities_by
- 3 resource templates: oikos://entity/{slug}, knowledge/{id}, execution/{id}
Stage 3 — Nomos reliability:
- complete_task(success) refused without verification (upgraded from warn)
- sessionHasPlan excludes replaced steps (forces propose_plan after reopen)
- Bash syntax validation in run() (rejects literal \n, flag-space typos)
- Scope gate in SOUL.md (ask before pivoting to unrelated subsystem)
Stage 4 — External agent act (9 mutation tools):
- ack_signal, resolve_signal, mute_signal, cancel_execution
- update_check, delete_knowledge, restore_knowledge
- merge_knowledge, rename_knowledge_tag
325 lines
8.6 KiB
Go
325 lines
8.6 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"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"
|
|
)
|
|
|
|
// ─── Checks ────────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject) (gen.ListChecksResponseObject, error) {
|
|
limit := clampLimit(req.Params.Limit)
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT cd.entity_id, e.slug, cd.kind,
|
|
COALESCE(te.slug, '') AS target_slug, cd.target_type,
|
|
cd.config, cd.interval_s, cd.timeout_s, cd.zone, cd.enabled,
|
|
e.version, cd.last_health, cd.last_run_at
|
|
FROM check_defs cd
|
|
JOIN entities e ON e.id = cd.entity_id
|
|
LEFT JOIN entities te ON te.id = cd.target_id
|
|
WHERE ($1::text IS NULL OR cd.kind = $1)
|
|
AND ($2::text IS NULL OR te.slug = $2)
|
|
AND ($3::bool IS NULL OR cd.enabled = $3)
|
|
AND ($4::text IS NULL OR e.slug > $4)
|
|
ORDER BY e.slug
|
|
LIMIT $5`,
|
|
req.Params.Kind, req.Params.Target, req.Params.Enabled, req.Params.Cursor, limit+1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []gen.Check{}
|
|
for rows.Next() {
|
|
var c gen.Check
|
|
var targetSlug string
|
|
var configBytes []byte
|
|
// last_health is what turns a check list from configuration into an
|
|
// explanation: an entity's health is the worst of these, so this is
|
|
// the field that says which probe is responsible.
|
|
var lastHealth *string
|
|
if err := rows.Scan(&c.Id, &c.Slug, &c.Kind, &targetSlug, &c.TargetType,
|
|
&configBytes, &c.IntervalS, &c.TimeoutS, &c.Zone, &c.Enabled, &c.Version,
|
|
&lastHealth, &c.LastRunAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if lastHealth != nil {
|
|
h := gen.CheckLastHealth(*lastHealth)
|
|
c.LastHealth = &h
|
|
}
|
|
if targetSlug != "" {
|
|
c.Target = &targetSlug
|
|
}
|
|
var config map[string]any
|
|
if len(configBytes) > 0 && json.Unmarshal(configBytes, &config) == nil && len(config) > 0 {
|
|
c.Config = &config
|
|
}
|
|
items = append(items, c)
|
|
}
|
|
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.Check{}
|
|
}
|
|
return gen.ListChecks200JSONResponse{Items: items, NextCursor: next}, nil
|
|
}
|
|
|
|
func (s *Server) CreateCheck(ctx context.Context, req gen.CreateCheckRequestObject) (gen.CreateCheckResponseObject, error) {
|
|
if req.Body == nil {
|
|
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
|
}
|
|
|
|
id, err := uuid.NewV7()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
slug := req.Body.Slug
|
|
if slug == "" {
|
|
slug = "check:" + string(req.Body.Kind) + ":" + uuid.New().String()[:8]
|
|
}
|
|
|
|
// Resolve target if provided.
|
|
var targetID *uuid.UUID
|
|
if req.Body.Target != nil && *req.Body.Target != "" {
|
|
tid, rerr := s.resolveEntityID(ctx, *req.Body.Target)
|
|
if rerr != nil {
|
|
return nil, rerr
|
|
}
|
|
targetID = &tid
|
|
}
|
|
|
|
intervalS := int32(300)
|
|
if req.Body.IntervalS != nil {
|
|
intervalS = int32(*req.Body.IntervalS)
|
|
}
|
|
timeoutS := int32(30)
|
|
if req.Body.TimeoutS != nil {
|
|
timeoutS = int32(*req.Body.TimeoutS)
|
|
}
|
|
enabled := true
|
|
if req.Body.Enabled != nil {
|
|
enabled = *req.Body.Enabled
|
|
}
|
|
|
|
configJSON := []byte("{}")
|
|
if req.Body.Config != nil {
|
|
configJSON, _ = json.Marshal(req.Body.Config)
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
q := sqlcgen.New(tx)
|
|
|
|
// Create the entity row (checks are entities).
|
|
entity, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
|
|
ID: id,
|
|
Slug: slug,
|
|
Type: "check",
|
|
Name: slug,
|
|
Attributes: []byte("{}"),
|
|
})
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
|
|
return nil, fmt.Errorf("%w: check %q already exists", domain.ErrAlreadyExists, slug)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if err := q.InsertCheckDef(ctx, sqlcgen.InsertCheckDefParams{
|
|
EntityID: id,
|
|
TargetID: targetID,
|
|
TargetType: req.Body.TargetType,
|
|
Kind: string(req.Body.Kind),
|
|
Config: configJSON,
|
|
IntervalS: intervalS,
|
|
TimeoutS: timeoutS,
|
|
Zone: req.Body.Zone,
|
|
Enabled: enabled,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Build response Check.
|
|
check := gen.Check{
|
|
Id: id,
|
|
Slug: entity.Slug,
|
|
Kind: gen.CheckKind(req.Body.Kind),
|
|
IntervalS: int(intervalS),
|
|
TimeoutS: int(timeoutS),
|
|
Enabled: enabled,
|
|
TargetType: req.Body.TargetType,
|
|
Zone: req.Body.Zone,
|
|
Version: int(entity.Version),
|
|
}
|
|
if req.Body.Config != nil {
|
|
check.Config = req.Body.Config
|
|
}
|
|
if targetID != nil && req.Body.Target != nil {
|
|
check.Target = req.Body.Target
|
|
}
|
|
|
|
actorType, actor := actorInfo(ctx)
|
|
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
|
|
&id, "POST", "/api/v1/checks", "",
|
|
nil,
|
|
map[string]any{"kind": req.Body.Kind, "slug": slug}); auditErr != nil {
|
|
return nil, auditErr
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return gen.CreateCheck201JSONResponse(check), nil
|
|
}
|
|
|
|
func (s *Server) PatchCheck(ctx context.Context, req gen.PatchCheckRequestObject) (gen.PatchCheckResponseObject, 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
|
|
ifMatch := strings.Trim(req.Params.IfMatch, `"`)
|
|
expectedVersion, err := parseIntIfMatch(ifMatch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = expectedVersion // check_defs don't track version via If-Match today, but we validate the header is present
|
|
|
|
if ifMatch == "" {
|
|
return nil, fmt.Errorf("%w: invalid If-Match header", domain.ErrInvalidInput)
|
|
}
|
|
|
|
// Get current check def
|
|
current, err := sqlcgen.New(s.pool).GetCheckDef(ctx, id)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, fmt.Errorf("%w: check %s", domain.ErrNotFound, req.Id)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
// Apply patch.
|
|
if req.Body.Config != nil {
|
|
current.Config, _ = json.Marshal(req.Body.Config)
|
|
}
|
|
if req.Body.IntervalS != nil {
|
|
current.IntervalS = int32(*req.Body.IntervalS)
|
|
}
|
|
if req.Body.TimeoutS != nil {
|
|
current.TimeoutS = int32(*req.Body.TimeoutS)
|
|
}
|
|
if req.Body.Enabled != nil {
|
|
current.Enabled = *req.Body.Enabled
|
|
}
|
|
|
|
if err := sqlcgen.New(tx).UpdateCheckDef(ctx, sqlcgen.UpdateCheckDefParams{
|
|
EntityID: id,
|
|
Kind: current.Kind,
|
|
Config: current.Config,
|
|
IntervalS: current.IntervalS,
|
|
TimeoutS: current.TimeoutS,
|
|
TargetID: current.TargetID,
|
|
TargetType: current.TargetType,
|
|
Zone: current.Zone,
|
|
Enabled: current.Enabled,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Re-read to get updated timestamp.
|
|
updated, err := sqlcgen.New(tx).GetCheckDef(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
check := checkDefToGen(updated)
|
|
|
|
actorType, actor := actorInfo(ctx)
|
|
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
|
|
&id, "PATCH", "/api/v1/checks/"+req.Id, "",
|
|
nil,
|
|
map[string]any{"enabled": updated.Enabled}); auditErr != nil {
|
|
return nil, auditErr
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return gen.PatchCheck200JSONResponse(check), nil
|
|
}
|
|
|
|
func checkDefToGen(cd sqlcgen.CheckDef) gen.Check {
|
|
c := gen.Check{
|
|
Id: cd.EntityID,
|
|
Kind: gen.CheckKind(cd.Kind),
|
|
IntervalS: int(cd.IntervalS),
|
|
TimeoutS: int(cd.TimeoutS),
|
|
Enabled: cd.Enabled,
|
|
TargetType: cd.TargetType,
|
|
Zone: cd.Zone,
|
|
}
|
|
var config map[string]any
|
|
if len(cd.Config) > 0 && json.Unmarshal(cd.Config, &config) == nil && len(config) > 0 {
|
|
c.Config = &config
|
|
}
|
|
// Carried through so toggling a check does not blank its verdict in the
|
|
// UI — the entity window renders last_health to explain which probe is
|
|
// responsible for an entity's health, and a patch response missing it
|
|
// would erase that until the next poll.
|
|
c.LastRunAt = cd.LastRunAt
|
|
if cd.LastHealth != nil {
|
|
h := gen.CheckLastHealth(*cd.LastHealth)
|
|
c.LastHealth = &h
|
|
}
|
|
return c
|
|
}
|
|
|
|
// parseIntIfMatch parses an integer from a raw If-Match header value (with quotes stripped).
|
|
func parseIntIfMatch(s string) (int, error) {
|
|
if s == "" {
|
|
return 0, fmt.Errorf("empty version")
|
|
}
|
|
var v int
|
|
for _, c := range s {
|
|
if c < '0' || c > '9' {
|
|
return 0, fmt.Errorf("invalid version: %q", s)
|
|
}
|
|
v = v*10 + int(c-'0')
|
|
}
|
|
return v, nil
|
|
}
|