internal/httpapi/phase3.go (2627 lines, 12+ resource domains) split into 15 per-resource files: - actuator.go: SSH execution machinery (initSSH, sshExec, resolveRunTarget, executeApprovedAction, jsonErr, gatewayPreflightPassed, resolveTemplate) - checks.go, classifications.go, executions.go, approvals.go, patterns.go, skills.go, approval_rules.go, autonomy.go, risk_classes.go, relationships.go, entity_types.go, metrics.go, agent_activity.go, helpers.go — one file per resource domain, each with its own imports. internal/mcp/server.go: newServer (708 lines, 33 inline tool registrations) refactored to a registry pattern: - internal/mcp/tools.go (new): toolReg struct + allTools() returning all 33 tool definitions. Handler logic moved verbatim — no changes to tool names, descriptions, schemas, or behavior. - server.go: newServer is now 9 lines (iterate registry, AddTool each). -699 lines. No function logic, names, or signatures changed. go vet, build, and all tests pass (httpapi, mcp, db, policy).
305 lines
7.8 KiB
Go
305 lines
7.8 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
|
|
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
|
|
if err := rows.Scan(&c.Id, &c.Slug, &c.Kind, &targetSlug, &c.TargetType,
|
|
&configBytes, &c.IntervalS, &c.TimeoutS, &c.Zone, &c.Enabled, &c.Version); err != nil {
|
|
return nil, err
|
|
}
|
|
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", "",
|
|
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, "",
|
|
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
|
|
}
|
|
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
|
|
}
|