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 }