Files
oikos/internal/httpapi/checks.go
dtoro 64f7d54011
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat: Phase 2 — ports package, secrets port move, postgres adapter move
Problem: the hexagon's Phase 2 (plans/2026-08-15-hexagonal-architecture.md)
must give the use-cases-to-be their contract surface: driven-port
interfaces, test fakes, the secrets interface moved into core, and the
postgres package inside the adapters tree — before the first vertical
slice (Phase 3) can wire a composition root.

Change:
- internal/core/ports: full driven-port catalog per plan §3.3 —
  repositories as transaction-scoped aggregates whose inputs carry
  derived checks, audit, and events (§3.6), plus CommandExecutor,
  TargetResolver, Checker, Secrets, EventPublisher, Provisioner.
  Port-local payload types (Event, AuditEntry, CheckDef, KnowledgeEntry,
  ExecResult) keep signatures off infrastructure; TypeTree aliases
  internal/ontology (pure over domain) until checkdefaults is absorbed.
  ReadModels intentionally not declared yet — it materializes with the
  Phase 3 slice and grows as report handlers rewire.
- secrets.Backend is now an alias of ports.Secrets; implementations
  (Infisical, SOPS, Manager) unchanged. mcp's local secretBackend
  subset is deleted; tool constructors take ports.Secrets.
- internal/db → internal/adapters/postgres (mechanical import rewrite;
  package identifier stays db until the Phase 3 repository split).
  sqlc.yaml, Makefile, golangci exclusions, and docs follow the move;
  make generate-check verified.
- internal/adapters/ssh: Executor implements ports.CommandExecutor over
  the actuator dial pool + RunStreaming (10-min default timeout carried
  over from the httpapi path).
- internal/adapters/remote: Resolver implements ports.TargetResolver
  delegating to internal/remote (still pool-based; drops onto
  ports.EntityRepository when repositories land in Phase 3 — documented
  transitional import).
- internal/core/ports/portstest: importable fakes — in-memory
  EntityRepo (with check-then-act SetState, side-effect recording),
  RecordingExecutor, FakeChecker, SpyPublisher; port-satisfaction
  guards; tests.

Risk: ports are declared ahead of implementations — signatures firm up
per phase as slices land (documented in the package doc); the
remote→postgres transitional import is explicit and dissolves in
Phase 3.

Verification: go vet, make test (race, 19 packages), generate-check,
golangci on core+adapters — 0 issues; full-repo baseline down
365→344.
2026-08-15 22:56:56 +02:00

325 lines
8.6 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/core/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
}