Compare commits
20 Commits
claude/heu
...
claude/goo
| Author | SHA1 | Date | |
|---|---|---|---|
| 095a3967c4 | |||
| 7e802bbb14 | |||
| f1b0b65149 | |||
| 6b61495e3b | |||
| 1bfc18ea3a | |||
| 9c63a1bfa9 | |||
| c9975d60a5 | |||
| f2fe812cda | |||
| 1b04683639 | |||
| aa2ca0ae6f | |||
| 55710bd254 | |||
| 18cb79caf9 | |||
| ea3b2c3662 | |||
| 8850b85325 | |||
| 3a35289f46 | |||
| 2d75544362 | |||
| d44979aca7 | |||
| fe54af30f6 | |||
| bead722fac | |||
| a434a4096c |
72
.gitea/workflows/ci.yml
Normal file
72
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Oikos CI (Gitea Actions). Gates the deploy webhook on a green run (plan M1).
|
||||||
|
# Mirrors `make lint`, `make test`, and the generated-code drift guard.
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: timescale/timescaledb:2.17.2-pg16
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: oikos
|
||||||
|
POSTGRES_USER: oikos
|
||||||
|
POSTGRES_PASSWORD: oikos_dev
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U oikos"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
env:
|
||||||
|
OIKOS_TEST_DATABASE_URL: postgres://oikos:oikos_dev@postgres:5432/oikos?sslmode=disable
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.26"
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@v6
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: --timeout 5m
|
||||||
|
continue-on-error: true # advisory until the lint baseline is clean
|
||||||
|
|
||||||
|
- name: govulncheck
|
||||||
|
run: |
|
||||||
|
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
|
govulncheck ./... || true # advisory
|
||||||
|
|
||||||
|
- name: generated code is up to date
|
||||||
|
run: make generate-check
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: go build ./...
|
||||||
|
|
||||||
|
- name: test (race + coverage)
|
||||||
|
run: go test -race -covermode=atomic -coverprofile=coverage.out -timeout 300s ./...
|
||||||
|
|
||||||
|
- name: coverage gates (policy + learning ≥ 80%, others ≥ 60%)
|
||||||
|
run: |
|
||||||
|
go tool cover -func=coverage.out | tail -1
|
||||||
|
# Note: policy/ and learning/ packages land in Phase 3; enforce
|
||||||
|
# their 80% gate then. For now, report total coverage.
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: docker build (verify image builds; no push)
|
||||||
|
run: docker build -f compose/oikos/Dockerfile -t oikos:ci .
|
||||||
50
Makefile
Normal file
50
Makefile
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
.PHONY: build test test-db lint generate generate-check dev migrate seed export clean tidy
|
||||||
|
|
||||||
|
BINARY := oikos
|
||||||
|
GO ?= go
|
||||||
|
|
||||||
|
build:
|
||||||
|
$(GO) build -o $(BINARY) -tags timetzdata ./cmd/oikos
|
||||||
|
|
||||||
|
test:
|
||||||
|
$(GO) test -race -cover ./...
|
||||||
|
|
||||||
|
# Integration tests against the compose Postgres (starts it if needed)
|
||||||
|
test-db:
|
||||||
|
docker compose up -d postgres
|
||||||
|
@sleep 3
|
||||||
|
OIKOS_TEST_DATABASE_URL="postgres://oikos:$${OIKOS_DB_PASSWORD:-oikos_dev}@localhost:5432/oikos?sslmode=disable" \
|
||||||
|
$(GO) test -race -count=1 ./internal/db/ ./internal/httpapi/ ./internal/mcp/
|
||||||
|
|
||||||
|
lint:
|
||||||
|
$(GO) vet ./...
|
||||||
|
@command -v golangci-lint >/dev/null 2>&1 && golangci-lint run || echo "golangci-lint not installed, skipping"
|
||||||
|
|
||||||
|
generate:
|
||||||
|
$(GO) run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1 \
|
||||||
|
-config api/codegen.yaml api/openapi.yaml
|
||||||
|
$(GO) run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.29.0 generate
|
||||||
|
|
||||||
|
# CI drift guard: regenerate and fail if the committed output changed.
|
||||||
|
generate-check: generate
|
||||||
|
@git diff --exit-code -- internal/httpapi/gen internal/db/sqlcgen \
|
||||||
|
|| (echo "generated code is stale — run 'make generate' and commit" && exit 1)
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
$(GO) run ./cmd/oikos migrate
|
||||||
|
|
||||||
|
seed:
|
||||||
|
$(GO) run ./cmd/oikos seed
|
||||||
|
|
||||||
|
export:
|
||||||
|
$(GO) run ./cmd/oikos export
|
||||||
|
|
||||||
|
dev:
|
||||||
|
docker compose --profile dev up -d
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(BINARY)
|
||||||
|
$(GO) clean -testcache
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
$(GO) mod tidy
|
||||||
8
api/codegen.yaml
Normal file
8
api/codegen.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# oapi-codegen config — `make generate` regenerates internal/httpapi/gen.
|
||||||
|
package: gen
|
||||||
|
output: internal/httpapi/gen/api.gen.go
|
||||||
|
generate:
|
||||||
|
models: true
|
||||||
|
chi-server: true
|
||||||
|
strict-server: true
|
||||||
|
embedded-spec: true
|
||||||
2891
api/openapi.yaml
Normal file
2891
api/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
7
api/redocly.yaml
Normal file
7
api/redocly.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Redocly lint config for api/openapi.yaml (CI runs: redocly lint api/openapi.yaml)
|
||||||
|
extends:
|
||||||
|
- recommended
|
||||||
|
rules:
|
||||||
|
# Every operation declares `default` → RFC 9457 problem+json instead of
|
||||||
|
# enumerating each 4XX (plan R3-3); oapi-codegen handles `default` fine.
|
||||||
|
operation-4xx-response: off
|
||||||
254
cmd/oikos/main.go
Normal file
254
cmd/oikos/main.go
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/config"
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/dtoro/oikos/internal/httpapi"
|
||||||
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SchedulerRunner is set by the scheduler init() to avoid circular imports.
|
||||||
|
var SchedulerRunner func(context.Context, *db.Pool, config.Config)
|
||||||
|
|
||||||
|
// NotifierRunner is set by the notifier init() to avoid circular imports.
|
||||||
|
var NotifierRunner func(context.Context, *db.Pool, config.Config)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
role := os.Args[1]
|
||||||
|
cfg := config.FromEnv()
|
||||||
|
|
||||||
|
// Structured logging (slog)
|
||||||
|
logger := observability.NewLogger(cfg.Debug)
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
slog.Info("starting oikos", "role", role, "config", cfg)
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(),
|
||||||
|
syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
switch role {
|
||||||
|
case "migrate":
|
||||||
|
if err := runMigrate(ctx, cfg); err != nil {
|
||||||
|
slog.Error("migrate failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
case "seed":
|
||||||
|
if err := runSeed(ctx, cfg); err != nil {
|
||||||
|
slog.Error("seed failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
case "export":
|
||||||
|
if err := runExport(ctx, cfg); err != nil {
|
||||||
|
slog.Error("export failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
case "api":
|
||||||
|
if err := runAPI(ctx, cfg); err != nil {
|
||||||
|
slog.Error("api failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
case "scheduler":
|
||||||
|
if SchedulerRunner != nil {
|
||||||
|
SchedulerRunner(ctx, nil, cfg)
|
||||||
|
} else {
|
||||||
|
slog.Error("scheduler not compiled in (import internal/scheduler)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
case "notifier":
|
||||||
|
if NotifierRunner != nil {
|
||||||
|
NotifierRunner(ctx, nil, cfg)
|
||||||
|
} else {
|
||||||
|
slog.Error("notifier not compiled in (import internal/notifier)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
case "all":
|
||||||
|
slog.Info("all role not yet implemented (runs api + scheduler + notifier in one process)")
|
||||||
|
os.Exit(1)
|
||||||
|
case "version":
|
||||||
|
fmt.Println("oikos dev (Phase 1)")
|
||||||
|
case "help", "--help", "-h":
|
||||||
|
usage()
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown role: %s\n", role)
|
||||||
|
usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage() {
|
||||||
|
fmt.Println(`oikos — the homelab OS
|
||||||
|
|
||||||
|
Usage: oikos <role> [flags]
|
||||||
|
|
||||||
|
Roles:
|
||||||
|
migrate Run database migrations (forward-only, idempotent)
|
||||||
|
seed Ingest seed YAML files into the database
|
||||||
|
export Export DB state back to seed YAMLs (DR / version control)
|
||||||
|
api Run the REST + MCP API server (Phase 2)
|
||||||
|
scheduler Run the observe + act loop (Phase 3)
|
||||||
|
notifier Run the notification service (Phase 3)
|
||||||
|
all Run all roles in one process (dev mode)
|
||||||
|
version Print version info
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
OIKOS_DATABASE_URL Postgres connection string
|
||||||
|
OIKOS_API_LISTEN API listen address (default :8090)
|
||||||
|
OIKOS_ENV Environment (dev, prod)
|
||||||
|
OIKOS_DEBUG Enable verbose logging (true/1)
|
||||||
|
OIKOS_SEEDS_DIR Path to seeds directory (default: seeds)
|
||||||
|
OIKOS_MCP_BEARER_TOKEN Shared secret for MCP auth`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMigrate(ctx context.Context, cfg config.Config) error {
|
||||||
|
pool, err := db.New(ctx, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
slog.Info("running migrations")
|
||||||
|
if err := pool.Migrate(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("migrations complete")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSeed(ctx context.Context, cfg config.Config) error {
|
||||||
|
pool, err := db.New(ctx, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
// Ensure migrations are applied first
|
||||||
|
if err := pool.Migrate(ctx); err != nil {
|
||||||
|
return fmt.Errorf("migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seedsDir := cfg.SeedsDir
|
||||||
|
if seedsDir == "" {
|
||||||
|
seedsDir = "seeds"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest ontology seed
|
||||||
|
ontoContent, err := os.ReadFile(seedsDir + "/ontology.yaml")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read ontology seed: %w", err)
|
||||||
|
}
|
||||||
|
err = pool.SeedIngest(ctx, "ontology.yaml", ontoContent,
|
||||||
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||||
|
r, err := db.IngestOntologySeed(ctx, tx, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("ontology ingested",
|
||||||
|
"lifecycles", r.Lifecycles,
|
||||||
|
"entity_types", r.EntityTypes,
|
||||||
|
"relationship_types", r.RelationshipTypes)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest inventory seed
|
||||||
|
invContent, err := os.ReadFile(seedsDir + "/inventory.yaml")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read inventory seed: %w", err)
|
||||||
|
}
|
||||||
|
err = pool.SeedIngest(ctx, "inventory.yaml", invContent,
|
||||||
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||||
|
r, err := db.IngestInventorySeed(ctx, tx, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("inventory ingested",
|
||||||
|
"entities", r.Entities,
|
||||||
|
"relationships", r.Relationships)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest policy seed
|
||||||
|
polContent, err := os.ReadFile(seedsDir + "/policy.yaml")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read policy seed: %w", err)
|
||||||
|
}
|
||||||
|
err = pool.SeedIngest(ctx, "policy.yaml", polContent,
|
||||||
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||||
|
r, err := db.IngestPolicySeed(ctx, tx, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("policy ingested",
|
||||||
|
"risk_classes", r.RiskClasses,
|
||||||
|
"approval_rules", r.ApprovalRules,
|
||||||
|
"autonomy_settings", r.AutonomySettings)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("seed ingest complete")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAPI(ctx context.Context, cfg config.Config) error {
|
||||||
|
pool, err := db.New(ctx, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
if err := pool.Migrate(ctx); err != nil {
|
||||||
|
return fmt.Errorf("migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = httpapi.ListenAndServe(ctx, pool, cfg)
|
||||||
|
if err == http.ErrServerClosed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func runExport(ctx context.Context, cfg config.Config) error {
|
||||||
|
pool, err := db.New(ctx, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
exports, err := db.ExportToYAML(ctx, pool)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, content := range exports {
|
||||||
|
path := cfg.SeedsDir + "/" + name
|
||||||
|
if err := os.WriteFile(path, content, 0644); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
slog.Info("exported", "file", path, "bytes", len(content))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
21
compose/oikos/Dockerfile
Normal file
21
compose/oikos/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Multi-stage Dockerfile for Oikos (ADR 0001: single binary)
|
||||||
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 go build -o /oikos -tags timetzdata -ldflags="-s -w" ./cmd/oikos
|
||||||
|
|
||||||
|
# --- Runtime: distroless static ---
|
||||||
|
FROM gcr.io/distroless/static:nonroot
|
||||||
|
|
||||||
|
COPY --from=builder /oikos /oikos
|
||||||
|
COPY --from=builder /build/seeds /seeds
|
||||||
|
COPY --from=builder /build/migrations /migrations
|
||||||
|
|
||||||
|
ENTRYPOINT ["/oikos"]
|
||||||
70
docker-compose.yml
Normal file
70
docker-compose.yml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Docker Compose for Oikos development
|
||||||
|
# Usage: docker compose up -d postgres (just the DB)
|
||||||
|
# make dev (full dev stack)
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: timescale/timescaledb:2.17.2-pg16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: oikos
|
||||||
|
POSTGRES_USER: oikos
|
||||||
|
POSTGRES_PASSWORD: ${OIKOS_DB_PASSWORD:-oikos_dev}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pg-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "pg_isready", "-U", "oikos"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# One-shot: run migrations then exit
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: compose/oikos/Dockerfile
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
OIKOS_DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
||||||
|
command: ["migrate"]
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# One-shot: ingest seeds then exit
|
||||||
|
seed:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: compose/oikos/Dockerfile
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
OIKOS_DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
||||||
|
OIKOS_SEEDS_DIR: /app/seeds
|
||||||
|
command: ["seed"]
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# API server (Phase 2)
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: compose/oikos/Dockerfile
|
||||||
|
profiles: ["dev", "full"]
|
||||||
|
depends_on:
|
||||||
|
seed:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
OIKOS_DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
||||||
|
OIKOS_API_LISTEN: ":8090"
|
||||||
|
OIKOS_ENV: dev
|
||||||
|
OIKOS_DEBUG: "true"
|
||||||
|
ports:
|
||||||
|
- "8090:8090"
|
||||||
|
command: ["api"]
|
||||||
|
stop_signal: SIGTERM
|
||||||
|
stop_grace_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg-data:
|
||||||
23
docs/adr/0001-go-single-binary.md
Normal file
23
docs/adr/0001-go-single-binary.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# ADR 0001 — Go with single-binary role packaging
|
||||||
|
|
||||||
|
Status: accepted (2026-07-07) · Plan: rev 3, R3-4
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The OS has three long-running roles (api, scheduler+actuator+learning,
|
||||||
|
notifier) plus one-shot jobs (migrate, seed, export). Rev 2 planned three
|
||||||
|
binaries with three Dockerfiles.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
One Go binary `oikos` with role subcommands (`oikos api | scheduler |
|
||||||
|
notifier | all | migrate | seed | export`), one multi-stage Dockerfile, one
|
||||||
|
image tagged `oikos:<git-sha>`. Compose runs the image N times with
|
||||||
|
different commands (Loki/Temporal pattern). Go over Python for static
|
||||||
|
typing, small static binaries (CGO_ENABLED=0, distroless), and goroutines
|
||||||
|
for concurrent probes.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- One build, guaranteed version consistency across roles, trivial local dev
|
||||||
|
(`oikos all`), simpler rollback (retag one image).
|
||||||
|
- Full rewrite of ~4,400 Python lines (logic carries over per plan reuse map).
|
||||||
|
- All roles share a dependency set; image is slightly larger than per-role
|
||||||
|
minimal images — accepted.
|
||||||
25
docs/adr/0002-postgres-timescale-only-datastore.md
Normal file
25
docs/adr/0002-postgres-timescale-only-datastore.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# ADR 0002 — PostgreSQL + TimescaleDB as the only datastore
|
||||||
|
|
||||||
|
Status: accepted (2026-07-07) · Plan: rev 3
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The OS needs a graph (entities/relationships), operational tables
|
||||||
|
(signals/executions/approvals), a learning corpus, time-series metrics,
|
||||||
|
audit and event logs. Alternatives: dedicated graph DB (Neo4j), dedicated
|
||||||
|
TSDB (Prometheus/VictoriaMetrics), or one Postgres.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
One PostgreSQL 16 instance with the TimescaleDB extension
|
||||||
|
(timescale/timescaledb:2-pg16). Graph traversal via recursive CTEs
|
||||||
|
(cycle-safe blast_radius); time-series via hypertables + continuous
|
||||||
|
aggregates + retention policies; events via table + LISTEN/NOTIFY.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- One backup/restore/DR story, one connection pool, transactional
|
||||||
|
consistency between graph and operational writes (e.g. event emission in
|
||||||
|
the same transaction as state change).
|
||||||
|
- Postgres is the accepted SPOF — mitigated by daily pg_dump + WAL PITR +
|
||||||
|
off-host copies + monthly restore drills; streaming replication is the
|
||||||
|
future path if needed.
|
||||||
|
- Homelab graph scale (hundreds of nodes) is far below where a dedicated
|
||||||
|
graph DB pays for itself.
|
||||||
23
docs/adr/0003-db-native-ontology-yaml-seeds.md
Normal file
23
docs/adr/0003-db-native-ontology-yaml-seeds.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# ADR 0003 — DB-native ontology with YAML seed manifests
|
||||||
|
|
||||||
|
Status: accepted (2026-07-07) · Plan: rev 3, R3-1
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Rev 1 kept inventory/ontology/policy as YAML files parsed at runtime.
|
||||||
|
Agents need graph queries (blast radius), transactional mutations with
|
||||||
|
audit, and a future UI needs to edit the model without file round-trips.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
The DB is the runtime source of truth. entity_types form an is-a hierarchy
|
||||||
|
(parent_type, is_abstract); relationship endpoint constraints may name
|
||||||
|
abstract types and validation walks the hierarchy. YAML files under seeds/
|
||||||
|
bootstrap the DB (idempotent, content-hashed via seed_versions) and serve
|
||||||
|
DR; `GET /api/v1/export` regenerates them for version control (round-trip
|
||||||
|
byte-stable, tested in CI).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- Ontology changes are API calls (policy-gated), not redeploys.
|
||||||
|
- Seeds can drift from DB between exports — export is part of the routine
|
||||||
|
(commit after meaningful model edits).
|
||||||
|
- Abstract types let policy rules and relationships bind once at the right
|
||||||
|
altitude (e.g. `compute-entity provides service`).
|
||||||
21
docs/adr/0004-openapi-first.md
Normal file
21
docs/adr/0004-openapi-first.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# ADR 0004 — Contract-first OpenAPI API
|
||||||
|
|
||||||
|
Status: accepted (2026-07-07) · Plan: rev 3, R3-2/R3-3
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Future UIs, a CLI client, and an MCP surface must stay in sync with the
|
||||||
|
API. Code-first (Gin + generated docs) drifts.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
api/openapi.yaml (OpenAPI 3.1) is the source of truth. Server stubs via
|
||||||
|
oapi-codegen (strict server, chi router); clients generated for Go (CLI)
|
||||||
|
and TypeScript (future UI). Conventions: RFC 9457 problem+json errors,
|
||||||
|
{items, next_cursor} envelopes, cursor pagination, Idempotency-Key on
|
||||||
|
unsafe POSTs, ETag/If-Match optimistic concurrency, scopes
|
||||||
|
(operator/viewer/agent) annotated per operation. CI fails on spec/handler
|
||||||
|
drift. MCP tools wrap the same service layer.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- UI development needs only the running API (spec served at /openapi.yaml).
|
||||||
|
- Handler changes require spec changes first — deliberate friction.
|
||||||
|
- Breaking changes ship as /api/v2 side by side; v1 is additive-only.
|
||||||
18
docs/adr/0005-uuidv7-plus-slug-identity.md
Normal file
18
docs/adr/0005-uuidv7-plus-slug-identity.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# ADR 0005 — UUIDv7 + slug entity identity
|
||||||
|
|
||||||
|
Status: accepted (2026-07-07) · Plan: rev 3, R3-5 (resolves audit D1)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Rev 2 used TEXT primary keys ('host:hubris') — renames break FKs, and
|
||||||
|
date-string signal IDs are race-prone.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Primary keys are UUIDv7 (time-ordered, generated in Go). Every entity also
|
||||||
|
carries a unique human slug ('host:hubris'); (type, name) is unique too.
|
||||||
|
The API accepts UUID or slug everywhere; slugs may change (rename), UUIDs
|
||||||
|
never do.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- Renames are metadata updates; history and edges survive.
|
||||||
|
- UUIDv7's time-ordering keeps B-tree inserts append-mostly.
|
||||||
|
- Seeds and exports use slugs (human-diffable); ingest resolves to UUIDs.
|
||||||
23
docs/adr/0006-learning-proposal-only.md
Normal file
23
docs/adr/0006-learning-proposal-only.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# ADR 0006 — Learning is proposal-only (no self-authorization)
|
||||||
|
|
||||||
|
Status: accepted (2026-07-07) · Plan: rev 3 (resolves audit S3/S4/SA2)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The learning loop (feedback → patterns → skills) informs the classifier
|
||||||
|
that decides auto-act vs escalate. If learning could expand its own
|
||||||
|
autonomy, poisoned feedback (flapping services, biased probes) could
|
||||||
|
unlock destructive auto-act.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
The learning engine cannot write to governance (policy/autonomy) tables —
|
||||||
|
enforced structurally: its DB role has no grants on them. Pattern
|
||||||
|
activation (validated → active) and any autonomy expansion require operator
|
||||||
|
approval. Confidence is the Wilson lower bound capped by evidence_count/5;
|
||||||
|
anomalous feedback bursts quarantine the pattern; no skill ever
|
||||||
|
auto-promotes an action into destructive autonomy (hard-coded). Lowering
|
||||||
|
autonomy (kill-switch) is always immediate, never gated.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- Cold start is slow by design — the agent escalates until trust is earned.
|
||||||
|
- The operator is the only path to more autonomy; the audit trail shows
|
||||||
|
every grant.
|
||||||
27
docs/adr/0007-threat-model.md
Normal file
27
docs/adr/0007-threat-model.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# ADR 0007 — Threat model and trust zones
|
||||||
|
|
||||||
|
Status: accepted (2026-07-07) · Plan: rev 3, Security model section
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The control plane can restart services and (eventually) mutate config
|
||||||
|
fleet-wide. Compromise of any one container must not equal compromise of
|
||||||
|
the fleet.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Trust zones as Docker networks: net-front (Caddy→api only), net-data
|
||||||
|
(Postgres), net-ops (SSH egress, actuator only). Hermes holds no SSH keys;
|
||||||
|
the actuator uses a restricted key (command=/from= in authorized_keys)
|
||||||
|
until the /executions gateway fully brokers actions. Caddy is an explicit
|
||||||
|
trust root but the API independently validates OIDC JWTs — network origin
|
||||||
|
is defense-in-depth, never the auth (this enables the LAN break-glass API
|
||||||
|
binding; the Hermes gateway remains mesh-only). Policy changes are
|
||||||
|
dual-controlled with before/after hash auditing and a startup
|
||||||
|
hash-vs-known-good check. Approval tokens are single-use HMAC, hashed at
|
||||||
|
rest, TTL-bound.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- Documented residual risks: plaintext LAN break-glass hop (emergency use),
|
||||||
|
Postgres as shared dependency of all roles, macOS host itself unmanaged
|
||||||
|
by the OS.
|
||||||
|
- Rotation cadences: actuator SSH key 6mo, machine tokens 90d, webhook
|
||||||
|
HMAC 1y — scheduler raises expiry signals 2 weeks ahead.
|
||||||
20
docs/adr/0008-forward-only-migrations.md
Normal file
20
docs/adr/0008-forward-only-migrations.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# ADR 0008 — Forward-only migrations
|
||||||
|
|
||||||
|
Status: accepted (2026-07-07) · Plan: rev 3 (resolves audit D5/O1)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Down-migrations are rarely tested and lie about reversibility once data
|
||||||
|
has flowed. Rollback needs a strategy that works with real data.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
golang-migrate, embedded (//go:embed), up-only. Migrations run in a
|
||||||
|
one-shot init container with a DDL-only DB user before app roles start.
|
||||||
|
Within one deploy window migrations are additive-only (new columns
|
||||||
|
nullable, new tables optional) so previous-SHA images tolerate the new
|
||||||
|
schema. Rollback = redeploy previous image tag; if the migration itself is
|
||||||
|
the problem, pg_restore the automatic pre-deploy dump. Mistakes roll
|
||||||
|
forward via compensating migrations.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- No down.sql to write or test; the pre-deploy dump is the real safety net.
|
||||||
|
- Destructive schema changes (drop/rename) take two deploys by design.
|
||||||
19
docs/adr/0009-sse-over-websocket.md
Normal file
19
docs/adr/0009-sse-over-websocket.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# ADR 0009 — SSE over WebSocket for the event stream
|
||||||
|
|
||||||
|
Status: accepted (2026-07-07) · Plan: rev 3, R3-14
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Live updates (signals, executions, approvals) push server→client only.
|
||||||
|
Rev 2 specified WebSocket.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Server-Sent Events at GET /api/v1/events/stream: plain HTTP (proxies
|
||||||
|
through Caddy without upgrade handling), native browser EventSource with
|
||||||
|
auto-reconnect, Last-Event-ID resume backed by the events table. Bounded
|
||||||
|
per-subscriber buffers with drop-oldest; heartbeat comments every 15s.
|
||||||
|
Delivery is best-effort — GET /events backfills. Transactional emission +
|
||||||
|
post-commit LISTEN/NOTIFY feed the stream.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- No bidirectional channel; if one is ever needed (interactive terminals),
|
||||||
|
add WebSocket alongside — this ADR covers the event feed only.
|
||||||
20
docs/adr/0010-infisical-with-sops-fallback.md
Normal file
20
docs/adr/0010-infisical-with-sops-fallback.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# ADR 0010 — Infisical secrets with SOPS DR fallback
|
||||||
|
|
||||||
|
Status: accepted (2026-07-07) · Plan: rev 3, Phase 5 (resolves audit S9)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
SOPS+age is file-based: no runtime API, no machine identities, no
|
||||||
|
rotation tracking, and every consumer needs the age key.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Infisical in the Docker stack; services fetch via machine identities;
|
||||||
|
secrets never in env files or plain config (config hierarchy: defaults →
|
||||||
|
file → env → Infisical, secrets only). Bootstrap root of trust: Infisical
|
||||||
|
master key in the mac-mini Keychain, backed up offline. One age key is
|
||||||
|
retained and all secrets are exported to a SOPS-encrypted fallback file
|
||||||
|
until an Infisical restore drill has passed; the fallback is refreshed on
|
||||||
|
rotation.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- Chicken-and-egg is explicit: the Keychain + offline copy are the root.
|
||||||
|
- SOPS retirement is gated on a passed restore drill, not on the calendar.
|
||||||
18
docs/adr/README.md
Normal file
18
docs/adr/README.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Architecture Decision Records
|
||||||
|
|
||||||
|
MADR-style records for Oikos. One decision per file, numbered, never edited
|
||||||
|
after acceptance — superseding decisions get a new ADR that links back.
|
||||||
|
Statuses: proposed | accepted | superseded-by-NNNN.
|
||||||
|
|
||||||
|
| ADR | Title |
|
||||||
|
|---|---|
|
||||||
|
| [0001](0001-go-single-binary.md) | Go with single-binary role packaging |
|
||||||
|
| [0002](0002-postgres-timescale-only-datastore.md) | PostgreSQL + TimescaleDB as the only datastore |
|
||||||
|
| [0003](0003-db-native-ontology-yaml-seeds.md) | DB-native ontology with YAML seed manifests |
|
||||||
|
| [0004](0004-openapi-first.md) | Contract-first OpenAPI API |
|
||||||
|
| [0005](0005-uuidv7-plus-slug-identity.md) | UUIDv7 + slug entity identity |
|
||||||
|
| [0006](0006-learning-proposal-only.md) | Learning is proposal-only (no self-authorization) |
|
||||||
|
| [0007](0007-threat-model.md) | Threat model and trust zones |
|
||||||
|
| [0008](0008-forward-only-migrations.md) | Forward-only migrations |
|
||||||
|
| [0009](0009-sse-over-websocket.md) | SSE over WebSocket for the event stream |
|
||||||
|
| [0010](0010-infisical-with-sops-fallback.md) | Infisical secrets with SOPS DR fallback |
|
||||||
36
go.mod
Normal file
36
go.mod
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
module github.com/dtoro/oikos
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/getkin/kin-openapi v0.140.0
|
||||||
|
github.com/go-chi/chi/v5 v5.3.1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/google/jsonschema-go v0.4.3
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||||
|
github.com/oapi-codegen/runtime v1.4.2
|
||||||
|
golang.org/x/crypto v0.53.0
|
||||||
|
golang.org/x/sync v0.21.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.22.5 // indirect
|
||||||
|
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/oasdiff/yaml v0.1.0 // indirect
|
||||||
|
github.com/oasdiff/yaml3 v0.0.13 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||||
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
|
golang.org/x/oauth2 v0.35.0 // indirect
|
||||||
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
golang.org/x/text v0.38.0 // indirect
|
||||||
|
)
|
||||||
88
go.sum
Normal file
88
go.sum
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||||
|
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g=
|
||||||
|
github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
|
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||||
|
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||||
|
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||||
|
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||||
|
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||||
|
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||||
|
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||||
|
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
|
||||||
|
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
|
||||||
|
github.com/oapi-codegen/runtime v1.4.2 h1:GMxFVYLzoYLua+/KvzgSphkyK1lLTReQI9Vf4hvATKE=
|
||||||
|
github.com/oapi-codegen/runtime v1.4.2/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
|
||||||
|
github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg=
|
||||||
|
github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0=
|
||||||
|
github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg=
|
||||||
|
github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
|
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
|
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
|
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
|
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
|
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||||
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
198
internal/actuator/actuator.go
Normal file
198
internal/actuator/actuator.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// Package actuator executes classified actions against the fleet.
|
||||||
|
// Consumes auto-act signals, runs stored skill procedures over SSH,
|
||||||
|
// manages circuit breakers, and enforces autonomy policy.
|
||||||
|
package actuator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/config"
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run starts the actuator loop. Blocks until ctx is cancelled.
|
||||||
|
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
||||||
|
slog.Info("actuator: starting")
|
||||||
|
interval := 10 * time.Second
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
circuitBreaker := newCircuitBreaker(cfg.CircuitThreshold, cfg.CircuitSeconds)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Info("actuator: shutting down")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
processAutoActSignals(ctx, pool, cfg, circuitBreaker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processAutoActSignals(ctx context.Context, pool *db.Pool, cfg config.Config, cb *circuitBreaker) {
|
||||||
|
q := sqlcgen.New(pool)
|
||||||
|
|
||||||
|
// Check kill-switch
|
||||||
|
autoAct := getAutonomySetting(ctx, q, "global.auto_act")
|
||||||
|
if autoAct == "off" || autoAct == "false" {
|
||||||
|
slog.Debug("actuator: global auto_act disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
signals, err := q.GetOpenSignalsForAutoAct(ctx, 5)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("actuator: get signals", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sig := range signals {
|
||||||
|
// Check per-target kill-switch
|
||||||
|
slug := ""
|
||||||
|
if sig.TargetEntityID != nil {
|
||||||
|
var s string
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", *sig.TargetEntityID).Scan(&s); err == nil {
|
||||||
|
slug = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if slug != "" {
|
||||||
|
ns := getAutonomySetting(ctx, q, "never_auto_act."+slug)
|
||||||
|
if ns == "true" {
|
||||||
|
slog.Debug("actuator: per-target auto_act disabled", "slug", slug)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check circuit breaker
|
||||||
|
targetKey := slug
|
||||||
|
if targetKey == "" {
|
||||||
|
targetKey = sig.TargetEntityID.String()
|
||||||
|
}
|
||||||
|
if cb.isOpen(targetKey) {
|
||||||
|
slog.Warn("actuator: circuit open", "target", targetKey)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute with advisory lock for per-target serialization
|
||||||
|
lockKey := 0
|
||||||
|
if sig.TargetEntityID != nil {
|
||||||
|
// Use hash of the target UUID as lock key
|
||||||
|
idBytes := []byte(sig.TargetEntityID.String())
|
||||||
|
for _, b := range idBytes {
|
||||||
|
lockKey = (lockKey*31 + int(b)) & 0x7fffffff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, lockErr := pool.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", lockKey)
|
||||||
|
if lockErr != nil {
|
||||||
|
slog.Error("actuator: lock", "error", lockErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create execution record
|
||||||
|
execID, _ := uuid.NewV7()
|
||||||
|
err = q.InsertExecution(ctx, sqlcgen.InsertExecutionParams{
|
||||||
|
EntityID: execID,
|
||||||
|
ClassificationID: &sig.ClassificationID,
|
||||||
|
SignalEntityID: &sig.EntityID,
|
||||||
|
TargetEntityID: sig.TargetEntityID,
|
||||||
|
Action: sig.Action,
|
||||||
|
RiskClass: sig.RiskClass,
|
||||||
|
CorrelationID: sig.CorrelationID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("actuator: insert execution", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark execution as running
|
||||||
|
_ = q.UpdateExecutionStatus(ctx, sqlcgen.UpdateExecutionStatusParams{
|
||||||
|
EntityID: execID,
|
||||||
|
Status: "running",
|
||||||
|
Result: []byte(`{}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Execute (stub for now)
|
||||||
|
result := map[string]any{"success": true, "message": "stub execution"}
|
||||||
|
resultJSON, _ := json.Marshal(result)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
duration := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
_ = q.UpdateExecutionStatus(ctx, sqlcgen.UpdateExecutionStatusParams{
|
||||||
|
EntityID: execID,
|
||||||
|
Status: "completed",
|
||||||
|
Result: resultJSON,
|
||||||
|
DurationMs: &[]int32{int32(duration)}[0],
|
||||||
|
Verified: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update circuit breaker
|
||||||
|
cb.recordSuccess(targetKey)
|
||||||
|
|
||||||
|
slog.Info("actuator: execution complete",
|
||||||
|
"execution", execID, "action", sig.Action, "target", targetKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAutonomySetting(ctx context.Context, q *sqlcgen.Queries, key string) string {
|
||||||
|
val, err := q.GetAutonomySetting(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// circuit breaker prevents repeated attempts against failing targets.
|
||||||
|
type circuitBreaker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
failures map[string]int
|
||||||
|
cooldowns map[string]time.Time
|
||||||
|
threshold int
|
||||||
|
cooldownS int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCircuitBreaker(threshold, cooldownSec int) *circuitBreaker {
|
||||||
|
if threshold <= 0 { threshold = 3 }
|
||||||
|
if cooldownSec <= 0 { cooldownSec = 300 }
|
||||||
|
return &circuitBreaker{
|
||||||
|
failures: make(map[string]int),
|
||||||
|
cooldowns: make(map[string]time.Time),
|
||||||
|
threshold: threshold,
|
||||||
|
cooldownS: cooldownSec,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *circuitBreaker) isOpen(target string) bool {
|
||||||
|
cb.mu.Lock()
|
||||||
|
defer cb.mu.Unlock()
|
||||||
|
if expiry, ok := cb.cooldowns[target]; ok {
|
||||||
|
if time.Now().Before(expiry) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
delete(cb.cooldowns, target)
|
||||||
|
cb.failures[target] = 0
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *circuitBreaker) recordSuccess(target string) {
|
||||||
|
cb.mu.Lock()
|
||||||
|
defer cb.mu.Unlock()
|
||||||
|
cb.failures[target] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *circuitBreaker) recordFailure(target string) {
|
||||||
|
cb.mu.Lock()
|
||||||
|
defer cb.mu.Unlock()
|
||||||
|
cb.failures[target]++
|
||||||
|
if cb.failures[target] >= cb.threshold {
|
||||||
|
cb.cooldowns[target] = time.Now().Add(time.Duration(cb.cooldownS) * time.Second)
|
||||||
|
slog.Warn("actuator: circuit opened", "target", target, "cooldown_s", cb.cooldownS)
|
||||||
|
}
|
||||||
|
}
|
||||||
349
internal/actuator/ssh.go
Normal file
349
internal/actuator/ssh.go
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
// Package actuator provides SSH-based skill procedure execution for the Oikos
|
||||||
|
// Phase 3 actuator loop. It runs stored skill procedures over SSH with a
|
||||||
|
// restricted key, classifies SSH errors into retryable/fatal/timeout, and
|
||||||
|
// supports step-by-step procedure verification.
|
||||||
|
package actuator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Procedure types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Procedure represents a parsed skill procedure from JSON config.
|
||||||
|
type Procedure struct {
|
||||||
|
Steps []Step `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step is a single step within a procedure.
|
||||||
|
type Step struct {
|
||||||
|
Runner string `json:"runner"` // "shell", "script", "verify"
|
||||||
|
Target string `json:"target,omitempty"` // hostname/IP (empty = local)
|
||||||
|
Command string `json:"command"` // shell command or script path
|
||||||
|
TimeoutS int `json:"timeout_s,omitempty"` // per-step timeout in seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHResult holds the outcome of an SSH execution.
|
||||||
|
type SSHResult struct {
|
||||||
|
Output string `json:"output"`
|
||||||
|
Duration time.Duration `json:"duration"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Err error `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Error classification ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// SSHErrorClass categorises SSH errors.
|
||||||
|
type SSHErrorClass int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SSHErrorUnknown SSHErrorClass = iota
|
||||||
|
SSHErrorNetwork // dial/connect timeout — retryable
|
||||||
|
SSHErrorAuth // auth failure — fatal
|
||||||
|
SSHErrorTimeout // command timed out
|
||||||
|
SSHErrorRemote // remote command returned non-zero
|
||||||
|
SSHErrorOther // other non-retryable
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c SSHErrorClass) String() string {
|
||||||
|
switch c {
|
||||||
|
case SSHErrorNetwork:
|
||||||
|
return "network"
|
||||||
|
case SSHErrorAuth:
|
||||||
|
return "auth"
|
||||||
|
case SSHErrorTimeout:
|
||||||
|
return "timed_out"
|
||||||
|
case SSHErrorRemote:
|
||||||
|
return "remote"
|
||||||
|
case SSHErrorOther:
|
||||||
|
return "other"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifySSHError maps an SSH error to a class for retry/fatal decisions.
|
||||||
|
func classifySSHError(err error) SSHErrorClass {
|
||||||
|
if err == nil {
|
||||||
|
return SSHErrorOther
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context deadline/cancel → timeout
|
||||||
|
if err == context.DeadlineExceeded {
|
||||||
|
return SSHErrorTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network-level errors
|
||||||
|
var netErr net.Error
|
||||||
|
if ok := errorsAs(err, &netErr); ok {
|
||||||
|
if netErr.Timeout() {
|
||||||
|
return SSHErrorNetwork
|
||||||
|
}
|
||||||
|
return SSHErrorNetwork
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSH auth errors
|
||||||
|
if strings.Contains(err.Error(), "unable to authenticate") ||
|
||||||
|
strings.Contains(err.Error(), "no supported methods remain") ||
|
||||||
|
strings.Contains(err.Error(), "ssh: handshake failed") ||
|
||||||
|
strings.Contains(err.Error(), "publickey") ||
|
||||||
|
strings.Contains(err.Error(), "permission denied") {
|
||||||
|
return SSHErrorAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit errors (non-zero remote exit)
|
||||||
|
var exitErr *ssh.ExitError
|
||||||
|
if ok := errorsAs(err, &exitErr); ok {
|
||||||
|
return SSHErrorRemote
|
||||||
|
}
|
||||||
|
|
||||||
|
return SSHErrorOther
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorsAs is a small wrapper to work with Go 1.26's errors.As signature.
|
||||||
|
func errorsAs(err error, target interface{}) bool {
|
||||||
|
// Use the standard errors.As
|
||||||
|
return as(err, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func as(err error, target interface{}) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Walk the error chain
|
||||||
|
for err != nil {
|
||||||
|
if assignable(err, target) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if u, ok := err.(interface{ Unwrap() error }); ok {
|
||||||
|
err = u.Unwrap()
|
||||||
|
} else if u, ok := err.(interface{ Unwrap() []error }); ok {
|
||||||
|
// Multi-error: check first
|
||||||
|
for _, e := range u.Unwrap() {
|
||||||
|
if as(e, target) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignable(err error, target interface{}) bool {
|
||||||
|
switch t := target.(type) {
|
||||||
|
case *error:
|
||||||
|
return false
|
||||||
|
case **net.OpError:
|
||||||
|
*t, _ = err.(*net.OpError)
|
||||||
|
return *t != nil
|
||||||
|
case **ssh.ExitError:
|
||||||
|
*t, _ = err.(*ssh.ExitError)
|
||||||
|
return *t != nil
|
||||||
|
default:
|
||||||
|
// Use the original errors.As for typed interfaces
|
||||||
|
return tryAssign(err, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryAssign(err error, target interface{}) bool {
|
||||||
|
// Standard reflection-free check: if target is *E where E is an interface
|
||||||
|
// and err implements E, it matches.
|
||||||
|
// For concrete pointer types, use type assertion.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SSH execution ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// SSHConfig holds connection parameters for SSH sessions.
|
||||||
|
type SSHConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
KeyPath string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteProcedure runs a complete procedure over SSH, step by step.
|
||||||
|
// Returns the combined result, duration, and verified status.
|
||||||
|
//
|
||||||
|
// Context cancellation aborts the running session. Returns the last
|
||||||
|
// successfully completed step's output on partial failure.
|
||||||
|
func ExecuteProcedure(
|
||||||
|
ctx context.Context,
|
||||||
|
cfg SSHConfig,
|
||||||
|
proc Procedure,
|
||||||
|
) SSHResult {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Parse the SSH key
|
||||||
|
key, err := os.ReadFile(cfg.KeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return SSHResult{
|
||||||
|
Err: fmt.Errorf("read ssh key: %w", err),
|
||||||
|
Duration: time.Since(start),
|
||||||
|
Verified: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
signer, err := ssh.ParsePrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return SSHResult{
|
||||||
|
Err: fmt.Errorf("parse ssh key: %w", err),
|
||||||
|
Duration: time.Since(start),
|
||||||
|
Verified: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
||||||
|
if cfg.Port == 0 {
|
||||||
|
addr = net.JoinHostPort(cfg.Host, "22")
|
||||||
|
}
|
||||||
|
|
||||||
|
clientCfg := &ssh.ClientConfig{
|
||||||
|
User: cfg.User,
|
||||||
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // restricted key; host trust via inventory
|
||||||
|
Timeout: cfg.Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := ssh.Dial("tcp", addr, clientCfg)
|
||||||
|
if err != nil {
|
||||||
|
class := classifySSHError(err)
|
||||||
|
return SSHResult{
|
||||||
|
Err: fmt.Errorf("ssh dial (%s): %w", class, err),
|
||||||
|
Duration: time.Since(start),
|
||||||
|
Verified: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// Execute each step in sequence
|
||||||
|
var lastOutput string
|
||||||
|
verified := true
|
||||||
|
|
||||||
|
for i, step := range proc.Steps {
|
||||||
|
// Check context before each step
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return SSHResult{
|
||||||
|
Output: lastOutput,
|
||||||
|
Duration: time.Since(start),
|
||||||
|
Verified: false,
|
||||||
|
Err: fmt.Errorf("cancelled before step %d: %w", i, ctx.Err()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(step.TimeoutS) * time.Second
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
stepCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
output, err := runSSHCommand(stepCtx, client, step.Command)
|
||||||
|
if err != nil {
|
||||||
|
class := classifySSHError(err)
|
||||||
|
// Verify steps that fail are not counted as verified failures
|
||||||
|
if step.Runner == "verify" {
|
||||||
|
verified = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-verify step failure is a real failure
|
||||||
|
if step.Runner != "verify" {
|
||||||
|
return SSHResult{
|
||||||
|
Output: lastOutput,
|
||||||
|
Duration: time.Since(start),
|
||||||
|
Err: fmt.Errorf("step %d (%s) failed (%s): %w", i, step.Runner, class, err),
|
||||||
|
Verified: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastOutput = output
|
||||||
|
|
||||||
|
slog.Debug("ssh step completed",
|
||||||
|
"step", i,
|
||||||
|
"runner", step.Runner,
|
||||||
|
"duration", time.Since(start).Round(time.Millisecond),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SSHResult{
|
||||||
|
Output: lastOutput,
|
||||||
|
Duration: time.Since(start),
|
||||||
|
Verified: verified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSSHCommand executes a single command over an established SSH session.
|
||||||
|
// Uses context-aware goroutines: ctx.Done() closes the session.
|
||||||
|
func runSSHCommand(ctx context.Context, client *ssh.Client, command string) (string, error) {
|
||||||
|
session, err := client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
// Wrap in goroutine so we can abort on ctx.Done()
|
||||||
|
type result struct {
|
||||||
|
output string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
out, err := session.CombinedOutput(command)
|
||||||
|
ch <- result{output: string(out), err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Close the session to abort the SSH command
|
||||||
|
session.Close()
|
||||||
|
return "", ctx.Err()
|
||||||
|
case res := <-ch:
|
||||||
|
if res.err != nil {
|
||||||
|
return res.output, fmt.Errorf("command: %w", res.err)
|
||||||
|
}
|
||||||
|
return res.output, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Procedure parsing ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ParseProcedure deserialises a JSON procedure (from skill.procedure).
|
||||||
|
func ParseProcedure(data []byte) (Procedure, error) {
|
||||||
|
var proc Procedure
|
||||||
|
if err := json.Unmarshal(data, &proc); err != nil {
|
||||||
|
return Procedure{}, fmt.Errorf("parse procedure: %w", err)
|
||||||
|
}
|
||||||
|
return proc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Global SSH client options ────────────────────────────────────────────
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
// defaultSSHTimeout is the default dial timeout for SSH connections.
|
||||||
|
defaultSSHTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetDefaultSSHTimeout overrides the default SSH dial timeout. Not safe for
|
||||||
|
// concurrent use during active execution.
|
||||||
|
func SetDefaultSSHTimeout(d time.Duration) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
defaultSSHTimeout = d
|
||||||
|
}
|
||||||
192
internal/config/config.go
Normal file
192
internal/config/config.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds all runtime configuration for an Oikos role.
|
||||||
|
// Hierarchy: compiled defaults → config file → env vars → Infisical (secrets only).
|
||||||
|
type Config struct {
|
||||||
|
// Database
|
||||||
|
DatabaseURL string // postgres://user:pass@host:5432/oikos?sslmode=disable
|
||||||
|
|
||||||
|
// API
|
||||||
|
APIListen string // :8090
|
||||||
|
APIEnv string // dev, prod
|
||||||
|
|
||||||
|
// Auth (Phase 2: static bearer tokens + OIDC JWT)
|
||||||
|
APIToken string // operator/CI bearer token for the REST API
|
||||||
|
MCPBearerToken string // shared secret for Hermes→API MCP calls
|
||||||
|
OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/)
|
||||||
|
OIDCClientID string // OIDC client ID (aud claim expected in JWT)
|
||||||
|
|
||||||
|
// Observability
|
||||||
|
Debug bool // verbose logging, probe payloads, SQL
|
||||||
|
|
||||||
|
// Seeds directory (for ingest/export)
|
||||||
|
SeedsDir string
|
||||||
|
|
||||||
|
// Migrations directory (embedded at build time, but path for fallback)
|
||||||
|
MigrationsDir string
|
||||||
|
|
||||||
|
// Scheduler (Phase 3)
|
||||||
|
SchedulerInterval time.Duration // check loop interval (default 30s)
|
||||||
|
|
||||||
|
// Notifier (Phase 3)
|
||||||
|
MatrixHomeserver string // Matrix server URL
|
||||||
|
MatrixUserID string // bot user ID (e.g. @oikos:matrix.hubris.network)
|
||||||
|
MatrixToken string // Matrix access token
|
||||||
|
MatrixRoomID string // alert room ID
|
||||||
|
|
||||||
|
// Actuator (Phase 3)
|
||||||
|
SSHKeyPath string // path to the restricted SSH key
|
||||||
|
SSHUser string // SSH user on targets (default "oikos")
|
||||||
|
CircuitThreshold int // N consecutive failures before opening circuit (default 3)
|
||||||
|
CircuitSeconds int // circuit breaker cooldown seconds (default 300)
|
||||||
|
|
||||||
|
// Learning (Phase 3)
|
||||||
|
LearningInterval time.Duration // pattern extraction interval (default 3600s)
|
||||||
|
|
||||||
|
// Approval HMAC secret (Phase 3)
|
||||||
|
ApprovalHMACSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default returns a Config with compiled defaults.
|
||||||
|
func Default() Config {
|
||||||
|
return Config{
|
||||||
|
DatabaseURL: "postgres://oikos:***@localhost:5432/oikos?sslmode=disable",
|
||||||
|
APIListen: ":8090",
|
||||||
|
APIEnv: "dev",
|
||||||
|
SeedsDir: "seeds",
|
||||||
|
MigrationsDir: "migrations",
|
||||||
|
SchedulerInterval: 30 * time.Second,
|
||||||
|
SSHUser: "oikos",
|
||||||
|
CircuitThreshold: 3,
|
||||||
|
CircuitSeconds: 300,
|
||||||
|
LearningInterval: 3600 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromEnv loads config from environment variables, overlaying defaults.
|
||||||
|
func FromEnv() Config {
|
||||||
|
c := Default()
|
||||||
|
|
||||||
|
if v := os.Getenv("OIKOS_DATABASE_URL"); v != "" {
|
||||||
|
c.DatabaseURL = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_API_LISTEN"); v != "" {
|
||||||
|
c.APIListen = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_ENV"); v != "" {
|
||||||
|
c.APIEnv = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_OIDC_ISSUER"); v != "" {
|
||||||
|
c.OIDCIssuer = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_OIDC_CLIENT_ID"); v != "" {
|
||||||
|
c.OIDCClientID = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_API_TOKEN"); v != "" {
|
||||||
|
c.APIToken = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_MCP_BEARER_TOKEN"); v != "" {
|
||||||
|
c.MCPBearerToken = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_SEEDS_DIR"); v != "" {
|
||||||
|
c.SeedsDir = v
|
||||||
|
}
|
||||||
|
c.Debug = os.Getenv("OIKOS_DEBUG") == "true" || os.Getenv("OIKOS_DEBUG") == "1"
|
||||||
|
|
||||||
|
// Phase 3 config
|
||||||
|
if v := os.Getenv("OIKOS_SCHEDULER_INTERVAL"); v != "" {
|
||||||
|
if d, err := time.ParseDuration(v); err == nil {
|
||||||
|
c.SchedulerInterval = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_MATRIX_HOMESERVER"); v != "" {
|
||||||
|
c.MatrixHomeserver = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_MATRIX_USER"); v != "" {
|
||||||
|
c.MatrixUserID = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_MATRIX_TOKEN"); v != "" {
|
||||||
|
c.MatrixToken = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_MATRIX_ROOM"); v != "" {
|
||||||
|
c.MatrixRoomID = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_SSH_KEY_PATH"); v != "" {
|
||||||
|
c.SSHKeyPath = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_SSH_USER"); v != "" {
|
||||||
|
c.SSHUser = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_CIRCUIT_THRESHOLD"); v != "" {
|
||||||
|
c.CircuitThreshold = parseInt(v)
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_CIRCUIT_SECONDS"); v != "" {
|
||||||
|
c.CircuitSeconds = parseInt(v)
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_LEARNING_INTERVAL"); v != "" {
|
||||||
|
if d, err := time.ParseDuration(v); err == nil {
|
||||||
|
c.LearningInterval = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_APPROVAL_HMAC_SECRET"); v != "" {
|
||||||
|
c.ApprovalHMACSecret = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInt parses a decimal integer from an env var string. Returns 0 on error.
|
||||||
|
func parseInt(s string) int {
|
||||||
|
var n int
|
||||||
|
fmt.Sscanf(s, "%d", &n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactedDBURL masks credentials in a postgres:// URL.
|
||||||
|
func (c Config) redactedDBURL() string {
|
||||||
|
dbURL := c.DatabaseURL
|
||||||
|
if i := strings.Index(dbURL, "@"); i >= 0 {
|
||||||
|
if j := strings.Index(dbURL, "://"); j >= 0 && j < i {
|
||||||
|
dbURL = dbURL[:j+3] + "***" + dbURL[i:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dbURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a human-safe representation (secrets redacted).
|
||||||
|
func (c Config) String() string {
|
||||||
|
token := ""
|
||||||
|
if c.MCPBearerToken != "" {
|
||||||
|
token = "***"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Config{DB=%s Listen=%s Env=%s Debug=%v MCPToken=%s SeedsDir=%s OIDCIssuer=%s OIDCClientID=%s}",
|
||||||
|
c.redactedDBURL(), c.APIListen, c.APIEnv, c.Debug, token, c.SeedsDir, c.OIDCIssuer, c.OIDCClientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogValue implements slog.LogValuer so structured handlers (JSON) never
|
||||||
|
// serialize raw secrets — without this, slog marshals struct fields
|
||||||
|
// directly and String() is bypassed.
|
||||||
|
func (c Config) LogValue() slog.Value {
|
||||||
|
token := ""
|
||||||
|
if c.MCPBearerToken != "" {
|
||||||
|
token = "***"
|
||||||
|
}
|
||||||
|
return slog.GroupValue(
|
||||||
|
slog.String("db", c.redactedDBURL()),
|
||||||
|
slog.String("listen", c.APIListen),
|
||||||
|
slog.String("env", c.APIEnv),
|
||||||
|
slog.Bool("debug", c.Debug),
|
||||||
|
slog.String("mcp_token", token),
|
||||||
|
slog.String("seeds_dir", c.SeedsDir),
|
||||||
|
slog.String("oidc_issuer", c.OIDCIssuer),
|
||||||
|
slog.String("oidc_client_id", c.OIDCClientID),
|
||||||
|
)
|
||||||
|
}
|
||||||
42
internal/config/config_test.go
Normal file
42
internal/config/config_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func secretConfig() Config {
|
||||||
|
c := Default()
|
||||||
|
c.DatabaseURL = "postgres://oikos:supersecretpw@localhost:5432/oikos"
|
||||||
|
c.MCPBearerToken = "supersecrettoken"
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringRedactsSecrets(t *testing.T) {
|
||||||
|
s := secretConfig().String()
|
||||||
|
for _, leak := range []string{"supersecretpw", "supersecrettoken"} {
|
||||||
|
if strings.Contains(s, leak) {
|
||||||
|
t.Errorf("String() leaks %q: %s", leak, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSlogJSONRedactsSecrets guards the bug where slog's JSON handler
|
||||||
|
// serialized Config struct fields directly, bypassing String() and leaking
|
||||||
|
// the DB password into logs.
|
||||||
|
func TestSlogJSONRedactsSecrets(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
logger.Info("starting", "config", secretConfig())
|
||||||
|
out := buf.String()
|
||||||
|
for _, leak := range []string{"supersecretpw", "supersecrettoken"} {
|
||||||
|
if strings.Contains(out, leak) {
|
||||||
|
t.Errorf("slog JSON output leaks %q: %s", leak, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "***") {
|
||||||
|
t.Errorf("expected redaction marker in log output: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
300
internal/db/export.go
Normal file
300
internal/db/export.go
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExportToYAML regenerates the three seed YAMLs from the DB (DR / version
|
||||||
|
// control, plan D6). Output is deterministic: maps marshal with sorted keys
|
||||||
|
// (yaml.v3 default for map[string]any) and lists are ordered by slug/name,
|
||||||
|
// so export → ingest → export is byte-stable.
|
||||||
|
//
|
||||||
|
// Cognition-layer entities (signals, executions, patterns, …) are runtime
|
||||||
|
// state, not inventory — they are excluded from the export.
|
||||||
|
func ExportToYAML(ctx context.Context, pool *Pool) (map[string][]byte, error) {
|
||||||
|
result := make(map[string][]byte)
|
||||||
|
|
||||||
|
for name, fn := range map[string]func(context.Context, *Pool) (map[string]any, error){
|
||||||
|
"ontology.yaml": exportOntology,
|
||||||
|
"inventory.yaml": exportInventory,
|
||||||
|
"policy.yaml": exportPolicy,
|
||||||
|
} {
|
||||||
|
doc, err := fn(ctx, pool)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("export %s: %w", name, err)
|
||||||
|
}
|
||||||
|
out, err := yaml.Marshal(doc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal %s: %w", name, err)
|
||||||
|
}
|
||||||
|
result[name] = out
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportOntology(ctx context.Context, pool *Pool) (map[string]any, error) {
|
||||||
|
lifecycles := map[string]any{}
|
||||||
|
rows, err := 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 id, def string
|
||||||
|
var states, terminal []string
|
||||||
|
var transitionsJSON []byte
|
||||||
|
if err := rows.Scan(&id, &states, &def, &terminal, &transitionsJSON); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var transitions map[string]any
|
||||||
|
if err := json.Unmarshal(transitionsJSON, &transitions); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("lifecycle %s transitions: %w", id, err)
|
||||||
|
}
|
||||||
|
lifecycles[id] = map[string]any{
|
||||||
|
"states": states,
|
||||||
|
"default_state": def,
|
||||||
|
"terminal_states": terminal,
|
||||||
|
"transitions": transitions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
entityTypes := map[string]any{}
|
||||||
|
rows, err = pool.Query(ctx,
|
||||||
|
`SELECT name, COALESCE(parent_type,''), is_abstract, domain, layer,
|
||||||
|
COALESCE(description,''), COALESCE(lifecycle_id,''), attribute_schema
|
||||||
|
FROM entity_types ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var name, parent, dom, layer, desc, lc string
|
||||||
|
var isAbstract bool
|
||||||
|
var schemaJSON []byte
|
||||||
|
if err := rows.Scan(&name, &parent, &isAbstract, &dom, &layer, &desc, &lc, &schemaJSON); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
et := map[string]any{"domain": dom, "layer": layer}
|
||||||
|
if parent != "" {
|
||||||
|
et["parent"] = parent
|
||||||
|
}
|
||||||
|
if isAbstract {
|
||||||
|
et["abstract"] = true
|
||||||
|
}
|
||||||
|
if desc != "" {
|
||||||
|
et["description"] = desc
|
||||||
|
}
|
||||||
|
if lc != "" {
|
||||||
|
et["lifecycle"] = lc
|
||||||
|
}
|
||||||
|
if len(schemaJSON) > 0 && string(schemaJSON) != "null" {
|
||||||
|
var schema map[string]any
|
||||||
|
if err := json.Unmarshal(schemaJSON, &schema); err == nil && schema != nil {
|
||||||
|
et["attributes"] = schema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entityTypes[name] = et
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
relTypes := map[string]any{}
|
||||||
|
rows, err = pool.Query(ctx,
|
||||||
|
`SELECT name, COALESCE(inverse,''), source_type, target_type, cardinality,
|
||||||
|
COALESCE(description,'')
|
||||||
|
FROM relationship_types ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var name, inverse, src, tgt, card, desc string
|
||||||
|
if err := rows.Scan(&name, &inverse, &src, &tgt, &card, &desc); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rt := map[string]any{"source": src, "target": tgt, "cardinality": card}
|
||||||
|
if inverse != "" {
|
||||||
|
rt["inverse"] = inverse
|
||||||
|
}
|
||||||
|
if desc != "" {
|
||||||
|
rt["description"] = desc
|
||||||
|
}
|
||||||
|
relTypes[name] = rt
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]any{
|
||||||
|
"version": 1,
|
||||||
|
"lifecycles": lifecycles,
|
||||||
|
"entity_types": entityTypes,
|
||||||
|
"relationship_types": relTypes,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportInventory(ctx context.Context, pool *Pool) (map[string]any, error) {
|
||||||
|
var entities []any
|
||||||
|
rows, err := pool.Query(ctx, `
|
||||||
|
SELECT e.slug, e.type, e.name, COALESCE(e.state,''), e.attributes
|
||||||
|
FROM entities e
|
||||||
|
JOIN entity_types et ON et.name = e.type
|
||||||
|
WHERE et.layer <> 'cognition'
|
||||||
|
ORDER BY e.slug`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var slug, typ, name, state string
|
||||||
|
var attrsJSON []byte
|
||||||
|
if err := rows.Scan(&slug, &typ, &name, &state, &attrsJSON); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e := map[string]any{"slug": slug, "type": typ, "name": name}
|
||||||
|
if state != "" {
|
||||||
|
e["state"] = state
|
||||||
|
}
|
||||||
|
var attrs map[string]any
|
||||||
|
if err := json.Unmarshal(attrsJSON, &attrs); err == nil && len(attrs) > 0 {
|
||||||
|
e["attributes"] = attrs
|
||||||
|
}
|
||||||
|
entities = append(entities, e)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
var rels []any
|
||||||
|
rows, err = pool.Query(ctx, `
|
||||||
|
SELECT se.slug, te.slug, r.type, r.attributes
|
||||||
|
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
|
||||||
|
ORDER BY r.type, se.slug, te.slug`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var src, tgt, typ string
|
||||||
|
var attrsJSON []byte
|
||||||
|
if err := rows.Scan(&src, &tgt, &typ, &attrsJSON); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rel := map[string]any{"source": src, "target": tgt, "type": typ}
|
||||||
|
var attrs map[string]any
|
||||||
|
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
|
||||||
|
rel["attributes"] = attrs
|
||||||
|
}
|
||||||
|
rels = append(rels, rel)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]any{
|
||||||
|
"version": 1,
|
||||||
|
"entities": entities,
|
||||||
|
"relationships": rels,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportPolicy(ctx context.Context, pool *Pool) (map[string]any, error) {
|
||||||
|
riskClasses := map[string]any{}
|
||||||
|
rows, err := pool.Query(ctx,
|
||||||
|
`SELECT name, COALESCE(description,''), approval_required, autonomy_allowed
|
||||||
|
FROM risk_classes ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var name, desc, approval string
|
||||||
|
var autonomy bool
|
||||||
|
if err := rows.Scan(&name, &desc, &approval, &autonomy); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rc := map[string]any{"approval_required": approval, "autonomy_allowed": autonomy}
|
||||||
|
if desc != "" {
|
||||||
|
rc["description"] = desc
|
||||||
|
}
|
||||||
|
riskClasses[name] = rc
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules []any
|
||||||
|
rows, err = pool.Query(ctx, `
|
||||||
|
SELECT COALESCE(ar.entity_type,''), ar.action, ar.risk_class,
|
||||||
|
ar.autonomy_level, COALESCE(se.slug,'')
|
||||||
|
FROM approval_rules ar
|
||||||
|
LEFT JOIN entities se ON se.id = ar.scope_entity
|
||||||
|
ORDER BY COALESCE(ar.entity_type,''), ar.action, COALESCE(se.slug,'')`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var et, action, rc, al, scope string
|
||||||
|
if err := rows.Scan(&et, &action, &rc, &al, &scope); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rule := map[string]any{"action": action, "risk_class": rc, "autonomy_level": al}
|
||||||
|
if et != "" {
|
||||||
|
rule["entity_type"] = et
|
||||||
|
}
|
||||||
|
if scope != "" {
|
||||||
|
rule["scope_entity"] = scope
|
||||||
|
}
|
||||||
|
rules = append(rules, rule)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := map[string]any{}
|
||||||
|
rows, err = pool.Query(ctx, `SELECT key, value FROM autonomy_settings ORDER BY key`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var k, v string
|
||||||
|
if err := rows.Scan(&k, &v); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
settings[k] = v
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]any{
|
||||||
|
"version": 1,
|
||||||
|
"risk_classes": riskClasses,
|
||||||
|
"approval_rules": rules,
|
||||||
|
"autonomy_settings": settings,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
338
internal/db/integration_test.go
Normal file
338
internal/db/integration_test.go
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
// Integration tests against a real TimescaleDB. Guarded by
|
||||||
|
// OIKOS_TEST_DATABASE_URL — skipped when unset. Run with:
|
||||||
|
//
|
||||||
|
// docker compose up -d postgres
|
||||||
|
// OIKOS_TEST_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" go test ./internal/db/
|
||||||
|
//
|
||||||
|
// or `make test-db`. Each run creates a throwaway database and drops it.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/domain"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedsDir() string { return "../../seeds" }
|
||||||
|
|
||||||
|
// newTestPool creates a throwaway database (dropped on cleanup), runs all
|
||||||
|
// migrations, and returns a pool connected to it.
|
||||||
|
func newTestPool(t *testing.T) *Pool {
|
||||||
|
t.Helper()
|
||||||
|
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
|
||||||
|
if baseURL == "" {
|
||||||
|
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
admin, err := pgx.Connect(ctx, baseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect admin: %v", err)
|
||||||
|
}
|
||||||
|
dbName := fmt.Sprintf("oikos_test_%08x", rand.Int63())
|
||||||
|
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
|
||||||
|
admin.Close(ctx)
|
||||||
|
t.Fatalf("create test db: %v", err)
|
||||||
|
}
|
||||||
|
admin.Close(ctx)
|
||||||
|
|
||||||
|
testURL := swapDatabase(baseURL, dbName)
|
||||||
|
pool, err := New(ctx, testURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect test db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pool.Close()
|
||||||
|
admin, err := pgx.Connect(ctx, baseURL)
|
||||||
|
if err == nil {
|
||||||
|
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
|
||||||
|
admin.Close(ctx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := pool.Migrate(ctx); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// swapDatabase replaces the database name in a postgres URL.
|
||||||
|
func swapDatabase(url, db string) string {
|
||||||
|
// postgres://user:pass@host:port/dbname?params
|
||||||
|
qi := strings.Index(url, "?")
|
||||||
|
params := ""
|
||||||
|
base := url
|
||||||
|
if qi >= 0 {
|
||||||
|
base, params = url[:qi], url[qi:]
|
||||||
|
}
|
||||||
|
si := strings.LastIndex(base, "/")
|
||||||
|
return base[:si+1] + db + params
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedAll(t *testing.T, pool *Pool, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, f := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
|
||||||
|
content, err := os.ReadFile(dir + "/" + f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", f, err)
|
||||||
|
}
|
||||||
|
ingestSeedContent(t, pool, f, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ingestSeedContent(t *testing.T, pool *Pool, name string, content []byte) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
err := pool.SeedIngest(ctx, name, content,
|
||||||
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||||
|
var err error
|
||||||
|
switch name {
|
||||||
|
case "ontology.yaml":
|
||||||
|
_, err = IngestOntologySeed(ctx, tx, data)
|
||||||
|
case "inventory.yaml":
|
||||||
|
_, err = IngestInventorySeed(ctx, tx, data)
|
||||||
|
case "policy.yaml":
|
||||||
|
_, err = IngestPolicySeed(ctx, tx, data)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ingest %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func count(t *testing.T, pool *Pool, query string) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
if err := pool.QueryRow(context.Background(), query).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count %q: %v", query, err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateIdempotent(t *testing.T) {
|
||||||
|
pool := newTestPool(t)
|
||||||
|
// second run must be a clean no-op
|
||||||
|
if err := pool.Migrate(context.Background()); err != nil {
|
||||||
|
t.Fatalf("second migrate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedIngestIdempotentAndNoDuplicateEdges(t *testing.T) {
|
||||||
|
pool := newTestPool(t)
|
||||||
|
seedAll(t, pool, seedsDir())
|
||||||
|
|
||||||
|
entities := count(t, pool, "SELECT count(*) FROM entities")
|
||||||
|
edges := count(t, pool, "SELECT count(*) FROM relationships WHERE valid_to IS NULL")
|
||||||
|
if entities == 0 || edges == 0 {
|
||||||
|
t.Fatalf("seed produced empty graph: %d entities, %d edges", entities, edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same content → hash no-op
|
||||||
|
seedAll(t, pool, seedsDir())
|
||||||
|
if got := count(t, pool, "SELECT count(*) FROM relationships WHERE valid_to IS NULL"); got != edges {
|
||||||
|
t.Errorf("unchanged re-seed altered edges: %d → %d", edges, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed content (hash differs) → full re-ingest must NOT duplicate edges
|
||||||
|
// (regression: the old upsert conflicted on valid_from and duplicated all
|
||||||
|
// 144 edges on every re-ingest)
|
||||||
|
content, err := os.ReadFile(seedsDir() + "/inventory.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
touched := append(content, []byte("\n# touched for hash change\n")...)
|
||||||
|
ingestSeedContent(t, pool, "inventory.yaml", touched)
|
||||||
|
|
||||||
|
if got := count(t, pool, "SELECT count(*) FROM relationships WHERE valid_to IS NULL"); got != edges {
|
||||||
|
t.Errorf("touched re-seed duplicated edges: %d → %d", edges, got)
|
||||||
|
}
|
||||||
|
if dup := count(t, pool, `SELECT count(*) FROM (
|
||||||
|
SELECT source_id, target_id, type FROM relationships
|
||||||
|
WHERE valid_to IS NULL GROUP BY 1,2,3 HAVING count(*) > 1) d`); dup != 0 {
|
||||||
|
t.Errorf("%d duplicated current edges", dup)
|
||||||
|
}
|
||||||
|
if got := count(t, pool, "SELECT count(*) FROM entities"); got != entities {
|
||||||
|
t.Errorf("touched re-seed altered entity count: %d → %d", entities, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbstractTypeRejected(t *testing.T) {
|
||||||
|
pool := newTestPool(t)
|
||||||
|
seedAll(t, pool, seedsDir())
|
||||||
|
|
||||||
|
bad := []byte(`
|
||||||
|
version: 1
|
||||||
|
entities:
|
||||||
|
- {slug: "machine:ghost", type: machine, name: ghost}
|
||||||
|
`)
|
||||||
|
ctx := context.Background()
|
||||||
|
err := pool.SeedIngest(ctx, "inventory.yaml", bad,
|
||||||
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||||
|
_, err := IngestInventorySeed(ctx, tx, data)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if !errors.Is(err, domain.ErrAbstractType) {
|
||||||
|
t.Errorf("abstract instantiation = %v, want ErrAbstractType", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdgeEndpointValidation(t *testing.T) {
|
||||||
|
pool := newTestPool(t)
|
||||||
|
seedAll(t, pool, seedsDir())
|
||||||
|
|
||||||
|
// routes-to requires source ingress-route; a service source must fail
|
||||||
|
bad := []byte(`
|
||||||
|
version: 1
|
||||||
|
relationships:
|
||||||
|
- {source: "service:gitea", target: "service:caddy", type: routes-to}
|
||||||
|
`)
|
||||||
|
ctx := context.Background()
|
||||||
|
err := pool.SeedIngest(ctx, "inventory.yaml", bad,
|
||||||
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||||
|
_, err := IngestInventorySeed(ctx, tx, data)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if !errors.Is(err, domain.ErrInvalidEdge) {
|
||||||
|
t.Errorf("bad edge = %v, want ErrInvalidEdge", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hosts from a proxmox-host (is-a machine) to an lxc (is-a compute-entity)
|
||||||
|
// must PASS via hierarchy walk — already covered by the seed itself, but
|
||||||
|
// assert an explicit one for clarity
|
||||||
|
good := []byte(`
|
||||||
|
version: 1
|
||||||
|
relationships:
|
||||||
|
- {source: "host:strong", target: "lxc:jellyfin", type: hosts}
|
||||||
|
`)
|
||||||
|
err = pool.SeedIngest(ctx, "inventory.yaml", good,
|
||||||
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||||
|
_, err := IngestInventorySeed(ctx, tx, data)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("valid inherited edge rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCardinalityEnforced(t *testing.T) {
|
||||||
|
pool := newTestPool(t)
|
||||||
|
seedAll(t, pool, seedsDir())
|
||||||
|
|
||||||
|
// routes-to is many-to-one: one ingress route cannot point at two services
|
||||||
|
bad := []byte(`
|
||||||
|
version: 1
|
||||||
|
relationships:
|
||||||
|
- {source: "ingress:git.hubris.network", target: "service:jellyfin", type: routes-to}
|
||||||
|
`)
|
||||||
|
ctx := context.Background()
|
||||||
|
err := pool.SeedIngest(ctx, "inventory.yaml", bad,
|
||||||
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||||
|
_, err := IngestInventorySeed(ctx, tx, data)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "cardinality") {
|
||||||
|
t.Errorf("cardinality violation = %v, want cardinality error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlastRadiusTerminatesOnCycles(t *testing.T) {
|
||||||
|
pool := newTestPool(t)
|
||||||
|
seedAll(t, pool, seedsDir())
|
||||||
|
|
||||||
|
// Build a dependency cycle: gitea → caddy → authentik → gitea
|
||||||
|
cycle := []byte(`
|
||||||
|
version: 1
|
||||||
|
relationships:
|
||||||
|
- {source: "service:gitea", target: "service:caddy", type: depends-on}
|
||||||
|
- {source: "service:caddy", target: "service:authentik", type: depends-on}
|
||||||
|
- {source: "service:authentik", target: "service:gitea", type: depends-on}
|
||||||
|
`)
|
||||||
|
ctx := context.Background()
|
||||||
|
err := pool.SeedIngest(ctx, "inventory.yaml", cycle,
|
||||||
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||||
|
_, err := IngestInventorySeed(ctx, tx, data)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cycle ingest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := pool.Query(ctx, `
|
||||||
|
SELECT e.slug, b.depth
|
||||||
|
FROM blast_radius((SELECT id FROM entities WHERE slug='service:gitea'), 5,
|
||||||
|
ARRAY['depends-on']) b
|
||||||
|
JOIN entities e ON e.id = b.entity_id ORDER BY b.depth`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("blast_radius: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
got := map[string]int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var slug string
|
||||||
|
var depth int
|
||||||
|
if err := rows.Scan(&slug, &depth); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got[slug] = depth
|
||||||
|
}
|
||||||
|
want := map[string]int{"service:gitea": 0, "service:caddy": 1, "service:authentik": 2}
|
||||||
|
for slug, depth := range want {
|
||||||
|
if got[slug] != depth {
|
||||||
|
t.Errorf("blast_radius[%s] = %d, want %d (full: %v)", slug, got[slug], depth, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Errorf("blast_radius returned %d nodes, want %d: %v", len(got), len(want), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExportRoundTripStable: export → ingest into a fresh DB → export again
|
||||||
|
// must yield byte-identical YAML (the canonical-form fixpoint, plan D6).
|
||||||
|
func TestExportRoundTripStable(t *testing.T) {
|
||||||
|
pool := newTestPool(t)
|
||||||
|
seedAll(t, pool, seedsDir())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
export1, err := ExportToYAML(ctx, pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("export 1: %v", err)
|
||||||
|
}
|
||||||
|
for name, content := range export1 {
|
||||||
|
var doc map[string]any
|
||||||
|
if err := yaml.Unmarshal(content, &doc); err != nil {
|
||||||
|
t.Fatalf("export %s is not valid YAML: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pool2 := newTestPool(t)
|
||||||
|
for _, name := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
|
||||||
|
ingestSeedContent(t, pool2, name, export1[name])
|
||||||
|
}
|
||||||
|
export2, err := ExportToYAML(ctx, pool2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("export 2: %v", err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
|
||||||
|
if !bytes.Equal(export1[name], export2[name]) {
|
||||||
|
t.Errorf("%s round-trip not byte-stable (len %d vs %d)",
|
||||||
|
name, len(export1[name]), len(export2[name]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanity: exported inventory carries the real graph, not a stub
|
||||||
|
// (regression: export used to write 11-byte "version: 1" stubs)
|
||||||
|
if len(export1["inventory.yaml"]) < 1000 {
|
||||||
|
t.Errorf("inventory export suspiciously small: %d bytes", len(export1["inventory.yaml"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
250
internal/db/pool.go
Normal file
250
internal/db/pool.go
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/migrations"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pool wraps a pgx connection pool.
|
||||||
|
type Pool struct {
|
||||||
|
*pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new connection pool.
|
||||||
|
func New(ctx context.Context, databaseURL string) (*Pool, error) {
|
||||||
|
cfg, err := pgxpool.ParseConfig(databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse database url: %w", err)
|
||||||
|
}
|
||||||
|
cfg.MaxConns = 15
|
||||||
|
|
||||||
|
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create pool: %w", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("ping db: %w", err)
|
||||||
|
}
|
||||||
|
return &Pool{pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrationLockKey is the advisory-lock key serializing migration runs —
|
||||||
|
// two concurrent `oikos migrate` invocations must not interleave DDL.
|
||||||
|
const migrationLockKey = 0x01c05e5
|
||||||
|
|
||||||
|
// Migrate runs all embedded forward migrations in order.
|
||||||
|
// Uses a schema_migrations table to track applied versions. The whole run
|
||||||
|
// happens on one connection holding a session advisory lock.
|
||||||
|
func (p *Pool) Migrate(ctx context.Context) error {
|
||||||
|
conn, err := p.Acquire(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("acquire migration conn: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Release()
|
||||||
|
|
||||||
|
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
|
||||||
|
return fmt.Errorf("acquire migration lock: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", migrationLockKey)
|
||||||
|
|
||||||
|
// Create tracking table if not exists
|
||||||
|
_, err = conn.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List migration files
|
||||||
|
entries, err := fs.ReadDir(migrations.FS, ".")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration fs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && hasSuffix(e.Name(), ".up.sql") {
|
||||||
|
files = append(files, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
|
||||||
|
for _, fname := range files {
|
||||||
|
// Extract version number (001, 002, etc.)
|
||||||
|
var version int
|
||||||
|
if _, err := fmt.Sscanf(fname, "%03d", &version); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already applied
|
||||||
|
var applied int
|
||||||
|
err := conn.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM schema_migrations WHERE version = $1", version).Scan(&applied)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check migration %d: %w", version, err)
|
||||||
|
}
|
||||||
|
if applied > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and execute migration — split into individual statements
|
||||||
|
// because TimescaleDB CAGGs and some DDL can't run inside a transaction,
|
||||||
|
// and pgx's multi-statement Exec wraps them implicitly.
|
||||||
|
content, err := migrations.FS.ReadFile(fname)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s: %w", fname, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stmts := splitSQL(string(content))
|
||||||
|
for i, stmt := range stmts {
|
||||||
|
stmt = strings.TrimSpace(stmt)
|
||||||
|
if stmt == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, err := conn.Exec(ctx, stmt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("exec migration %s stmt %d: %w", fname, i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = conn.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("record migration %d: %w", version, err)
|
||||||
|
}
|
||||||
|
slog.Info("migration applied", "file", fname, "version", version, "statements", len(stmts))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedIngest ingests a YAML seed file into the database.
|
||||||
|
// Idempotent: if the file's content hash matches seed_versions, it's a no-op (A4).
|
||||||
|
func (p *Pool) SeedIngest(ctx context.Context, filename string, content []byte,
|
||||||
|
ingestFn func(ctx context.Context, tx pgx.Tx, data map[string]any) error) error {
|
||||||
|
|
||||||
|
hash := contentHash(content)
|
||||||
|
|
||||||
|
// Check if already applied with same hash
|
||||||
|
var existing string
|
||||||
|
err := p.QueryRow(ctx,
|
||||||
|
"SELECT content_hash FROM seed_versions WHERE file = $1", filename).Scan(&existing)
|
||||||
|
if err == nil && existing == hash {
|
||||||
|
return nil // no-op, same content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse YAML
|
||||||
|
var data map[string]any
|
||||||
|
if err := yaml.Unmarshal(content, &data); err != nil {
|
||||||
|
return fmt.Errorf("parse %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply in a single transaction
|
||||||
|
tx, err := p.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if err := ingestFn(ctx, tx, data); err != nil {
|
||||||
|
return fmt.Errorf("ingest %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the seed version
|
||||||
|
_, err = tx.Exec(ctx,
|
||||||
|
`INSERT INTO seed_versions (file, content_hash) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (file) DO UPDATE SET content_hash = $2, applied_at = now()`,
|
||||||
|
filename, hash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("record seed version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit seed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentHash returns a SHA-256 hex digest of the content.
|
||||||
|
func contentHash(content []byte) string {
|
||||||
|
h := sha256.Sum256(content)
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasSuffix reports whether the string ends with the given suffix.
|
||||||
|
func hasSuffix(s, suffix string) bool {
|
||||||
|
return strings.HasSuffix(s, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitSQL splits a SQL string into individual statements.
|
||||||
|
// Handles $$ ... $$ dollar-quoted blocks and -- line comments.
|
||||||
|
func splitSQL(sql string) []string {
|
||||||
|
var statements []string
|
||||||
|
var current strings.Builder
|
||||||
|
inDollarQuote := false
|
||||||
|
dollarTag := ""
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for i < len(sql) {
|
||||||
|
// Handle line comments (-- to end of line)
|
||||||
|
if !inDollarQuote && i+1 < len(sql) && sql[i] == '-' && sql[i+1] == '-' {
|
||||||
|
// Skip to end of line
|
||||||
|
for i < len(sql) && sql[i] != '\n' {
|
||||||
|
current.WriteByte(sql[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dollar-quote start/end
|
||||||
|
if !inDollarQuote && sql[i] == '$' {
|
||||||
|
j := i + 1
|
||||||
|
for j < len(sql) && (sql[j] == '_' || (sql[j] >= 'a' && sql[j] <= 'z') || (sql[j] >= 'A' && sql[j] <= 'Z') || (sql[j] >= '0' && sql[j] <= '9')) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j < len(sql) && sql[j] == '$' {
|
||||||
|
dollarTag = sql[i : j+1]
|
||||||
|
current.WriteString(dollarTag)
|
||||||
|
inDollarQuote = true
|
||||||
|
i = j + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inDollarQuote && strings.HasPrefix(sql[i:], dollarTag) {
|
||||||
|
current.WriteString(dollarTag)
|
||||||
|
i += len(dollarTag)
|
||||||
|
inDollarQuote = false
|
||||||
|
dollarTag = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !inDollarQuote && sql[i] == ';' {
|
||||||
|
statements = append(statements, current.String())
|
||||||
|
current.Reset()
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
current.WriteByte(sql[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(current.String()) != "" {
|
||||||
|
statements = append(statements, current.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return statements
|
||||||
|
}
|
||||||
52
internal/db/queries/entities.sql
Normal file
52
internal/db/queries/entities.sql
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
-- Entity read + mutation queries (API paths). Aliased `e` throughout to
|
||||||
|
-- avoid ambiguity with joined tables.
|
||||||
|
|
||||||
|
-- name: GetEntityByID :one
|
||||||
|
SELECT e.* FROM entities e WHERE e.id = $1;
|
||||||
|
|
||||||
|
-- name: GetEntityBySlug :one
|
||||||
|
SELECT e.* FROM entities e WHERE e.slug = $1;
|
||||||
|
|
||||||
|
-- name: ListEntities :many
|
||||||
|
WITH RECURSIVE tt AS (
|
||||||
|
SELECT name FROM entity_types WHERE sqlc.narg('type')::text IS NULL OR name = sqlc.narg('type')
|
||||||
|
UNION
|
||||||
|
SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name
|
||||||
|
WHERE sqlc.narg('type')::text IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT e.* FROM entities e
|
||||||
|
JOIN entity_types et ON et.name = e.type
|
||||||
|
WHERE e.type IN (SELECT name FROM tt)
|
||||||
|
AND (sqlc.narg('state')::text IS NULL OR e.state = sqlc.narg('state'))
|
||||||
|
AND (sqlc.narg('domain')::text IS NULL OR et.domain = sqlc.narg('domain'))
|
||||||
|
AND (sqlc.narg('layer')::text IS NULL OR et.layer = sqlc.narg('layer'))
|
||||||
|
AND (sqlc.narg('q')::text IS NULL
|
||||||
|
OR e.slug ILIKE '%'||sqlc.narg('q')||'%'
|
||||||
|
OR e.name ILIKE '%'||sqlc.narg('q')||'%')
|
||||||
|
AND (sqlc.narg('cursor')::text IS NULL OR e.slug > sqlc.narg('cursor'))
|
||||||
|
ORDER BY e.slug
|
||||||
|
LIMIT sqlc.arg('lim');
|
||||||
|
|
||||||
|
-- name: ListEntitiesCapped :many
|
||||||
|
SELECT e.* FROM entities e ORDER BY e.slug LIMIT $1;
|
||||||
|
|
||||||
|
-- name: InsertEntity :one
|
||||||
|
INSERT INTO entities (id, slug, type, name, state, attributes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateEntity :one
|
||||||
|
UPDATE entities SET
|
||||||
|
name = COALESCE(sqlc.narg('name'), name),
|
||||||
|
state = COALESCE(sqlc.narg('state'), state),
|
||||||
|
attributes = COALESCE(sqlc.narg('attributes'), attributes),
|
||||||
|
maintenance_until = CASE WHEN sqlc.arg('set_maintenance')::bool
|
||||||
|
THEN sqlc.narg('maintenance_until') ELSE maintenance_until END,
|
||||||
|
version = version + 1,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = sqlc.arg('id') AND version = sqlc.arg('version')
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- blast_radius(): the recursive-CTE traversal function's TABLE return type
|
||||||
|
-- is opaque to sqlc's analyzer — that one query stays hand-written pgx in
|
||||||
|
-- internal/httpapi (see impl.go).
|
||||||
13
internal/db/queries/ontology.sql
Normal file
13
internal/db/queries/ontology.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- name: ListEntityTypes :many
|
||||||
|
SELECT * FROM entity_types ORDER BY name;
|
||||||
|
|
||||||
|
-- name: ListRelationshipTypes :many
|
||||||
|
SELECT * FROM relationship_types ORDER BY name;
|
||||||
|
|
||||||
|
-- name: ListLifecycleDefs :many
|
||||||
|
SELECT * FROM lifecycle_defs ORDER BY id;
|
||||||
|
|
||||||
|
-- name: GetLifecycleForType :one
|
||||||
|
SELECT ld.* FROM lifecycle_defs ld
|
||||||
|
JOIN entity_types et ON et.lifecycle_id = ld.id
|
||||||
|
WHERE et.name = $1;
|
||||||
269
internal/db/queries/operations.sql
Normal file
269
internal/db/queries/operations.sql
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
-- name: ListSignals :many
|
||||||
|
SELECT sig.entity_id, se.slug, sig.kind, sig.severity, sig.state,
|
||||||
|
te.slug AS target_slug, sig.check_id, 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 (sqlc.narg('state')::text IS NULL OR sig.state = sqlc.narg('state'))
|
||||||
|
AND (sqlc.narg('severity')::text IS NULL OR sig.severity = sqlc.narg('severity'))
|
||||||
|
AND (sqlc.narg('target')::text IS NULL OR te.slug = sqlc.narg('target'))
|
||||||
|
AND (sqlc.narg('kind')::text IS NULL OR sig.kind = sqlc.narg('kind'))
|
||||||
|
AND (sqlc.narg('cursor')::text IS NULL OR se.slug > sqlc.narg('cursor'))
|
||||||
|
ORDER BY se.slug
|
||||||
|
LIMIT sqlc.arg('lim');
|
||||||
|
|
||||||
|
-- name: ListEntityStatus :many
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- name: GetIdempotentResponse :one
|
||||||
|
SELECT response_code, response_body, request_hash FROM idempotency_keys
|
||||||
|
WHERE actor = $1 AND key = $2;
|
||||||
|
|
||||||
|
-- name: PutIdempotentResponse :exec
|
||||||
|
INSERT INTO idempotency_keys (actor, key, request_hash, response_code, response_body)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (actor, key) DO NOTHING;
|
||||||
|
|
||||||
|
-- name: InsertAuditEntry :exec
|
||||||
|
INSERT INTO audit_log (actor_type, actor_id, action, entity_id, method, path,
|
||||||
|
status_code, detail, source_ip, correlation_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
|
||||||
|
|
||||||
|
-- name: InsertEvent :one
|
||||||
|
INSERT INTO events (type, entity_id, severity, source, data, correlation_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, ts;
|
||||||
|
|
||||||
|
-- name: ListEvents :many
|
||||||
|
SELECT id, ts, type, entity_id, severity, source, data, correlation_id
|
||||||
|
FROM events
|
||||||
|
WHERE (sqlc.narg('type')::text IS NULL OR type = sqlc.narg('type'))
|
||||||
|
AND (sqlc.narg('entity_id')::uuid IS NULL OR entity_id = sqlc.narg('entity_id'))
|
||||||
|
AND (sqlc.narg('severity')::text IS NULL OR severity = sqlc.narg('severity'))
|
||||||
|
AND (sqlc.narg('correlation_id')::text IS NULL OR correlation_id = sqlc.narg('correlation_id'))
|
||||||
|
AND (sqlc.narg('from_ts')::timestamptz IS NULL OR ts >= sqlc.narg('from_ts'))
|
||||||
|
AND (sqlc.narg('to_ts')::timestamptz IS NULL OR ts <= sqlc.narg('to_ts'))
|
||||||
|
AND (sqlc.narg('before_id')::bigint IS NULL OR id < sqlc.narg('before_id'))
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT sqlc.arg('lim');
|
||||||
|
|
||||||
|
-- name: ListEventsAfter :many
|
||||||
|
SELECT id, ts, type, entity_id, severity, source, data, correlation_id
|
||||||
|
FROM events WHERE id > $1 ORDER BY id ASC LIMIT $2;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Phase 3 queries
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- name: ListEnabledCheckDefs :many
|
||||||
|
SELECT cd.entity_id, cd.target_id, cd.target_type, cd.kind, cd.config,
|
||||||
|
cd.interval_s, cd.timeout_s, cd.zone, cd.enabled, cd.updated_at,
|
||||||
|
e.slug AS entity_slug
|
||||||
|
FROM check_defs cd
|
||||||
|
JOIN entities e ON e.id = cd.entity_id
|
||||||
|
WHERE cd.enabled = true;
|
||||||
|
|
||||||
|
-- name: GetCheckDef :one
|
||||||
|
SELECT * FROM check_defs WHERE entity_id = $1;
|
||||||
|
|
||||||
|
-- name: InsertCheckDef :exec
|
||||||
|
INSERT INTO check_defs (entity_id, target_id, target_type, kind, config, interval_s, timeout_s, zone, enabled)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);
|
||||||
|
|
||||||
|
-- name: UpdateCheckDef :exec
|
||||||
|
UPDATE check_defs SET kind = $2, config = $3, interval_s = $4, timeout_s = $5,
|
||||||
|
target_id = $6, target_type = $7, zone = $8, enabled = $9, updated_at = now()
|
||||||
|
WHERE entity_id = $1;
|
||||||
|
|
||||||
|
-- name: UpsertSignal :one
|
||||||
|
INSERT INTO signals (entity_id, kind, severity, target_entity_id, check_id, evidence, likely_cause, state)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'raised')
|
||||||
|
ON CONFLICT (target_entity_id, kind) WHERE state NOT IN ('resolved','failed')
|
||||||
|
DO UPDATE SET occurrence_count = signals.occurrence_count + 1,
|
||||||
|
last_seen_at = now(),
|
||||||
|
evidence = EXCLUDED.evidence,
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateSignalState :exec
|
||||||
|
UPDATE signals SET state = $2, updated_at = now() WHERE entity_id = $1;
|
||||||
|
|
||||||
|
-- name: GetOpenSignalsForAutoAct :many
|
||||||
|
-- Signals with auto-act classifications that haven't been executed yet
|
||||||
|
SELECT s.*, c.entity_id AS classification_id, c.action, c.risk_class, c.route,
|
||||||
|
c.blast_radius, c.correlation_id, c.reasoning
|
||||||
|
FROM classifications c
|
||||||
|
JOIN signals s ON s.entity_id = c.signal_entity_id
|
||||||
|
LEFT JOIN executions e ON e.classification_id = c.entity_id
|
||||||
|
WHERE c.route = 'auto-act'
|
||||||
|
AND e.entity_id IS NULL
|
||||||
|
AND (s.hold_down_until IS NULL OR s.hold_down_until < now())
|
||||||
|
AND (s.mute_until IS NULL OR s.mute_until < now())
|
||||||
|
ORDER BY s.last_seen_at ASC
|
||||||
|
LIMIT $1;
|
||||||
|
|
||||||
|
-- name: InsertClassification :exec
|
||||||
|
INSERT INTO classifications (entity_id, signal_entity_id, target_entity_id, action,
|
||||||
|
recommended_action, risk_class, route, blast_radius, pattern_confidence,
|
||||||
|
skill_id, autonomy_check, reasoning, correlation_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13);
|
||||||
|
|
||||||
|
-- name: ListClassifications :many
|
||||||
|
SELECT c.entity_id, c.signal_entity_id, c.target_entity_id, c.action,
|
||||||
|
c.recommended_action, c.risk_class, c.route, c.blast_radius,
|
||||||
|
c.pattern_confidence, c.skill_id, c.autonomy_check, c.reasoning,
|
||||||
|
c.correlation_id, c.created_at,
|
||||||
|
e.slug AS target_slug
|
||||||
|
FROM classifications c
|
||||||
|
JOIN entities e ON e.id = c.target_entity_id
|
||||||
|
WHERE (sqlc.narg('route')::text IS NULL OR c.route = sqlc.narg('route'))
|
||||||
|
AND (sqlc.narg('cursor')::text IS NULL OR e.slug > sqlc.narg('cursor'))
|
||||||
|
ORDER BY e.slug
|
||||||
|
LIMIT sqlc.arg('lim');
|
||||||
|
|
||||||
|
-- name: InsertExecution :exec
|
||||||
|
INSERT INTO executions (entity_id, classification_id, signal_entity_id,
|
||||||
|
target_entity_id, action, risk_class, approval_id, agent_id,
|
||||||
|
skill_id, skill_version, status, correlation_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'proposed', $11);
|
||||||
|
|
||||||
|
-- name: UpdateExecutionStatus :exec
|
||||||
|
UPDATE executions SET status = $2, result = $3, duration_ms = $4,
|
||||||
|
verified = $5, started_at = COALESCE(started_at, now()),
|
||||||
|
completed_at = CASE WHEN $2 IN ('completed','failed','cancelled') THEN now() ELSE completed_at END
|
||||||
|
WHERE entity_id = $1;
|
||||||
|
|
||||||
|
-- name: GetExecution :one
|
||||||
|
SELECT * FROM executions WHERE entity_id = $1;
|
||||||
|
|
||||||
|
-- name: ListExecutions :many
|
||||||
|
SELECT e.entity_id, e.classification_id, e.signal_entity_id, e.target_entity_id,
|
||||||
|
e.action, e.risk_class, e.approval_id, e.agent_id,
|
||||||
|
e.skill_id, e.skill_version, e.status, e.result, e.duration_ms,
|
||||||
|
e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
|
||||||
|
te.slug AS target_slug
|
||||||
|
FROM executions e
|
||||||
|
JOIN entities te ON te.id = e.target_entity_id
|
||||||
|
WHERE (sqlc.narg('status')::text IS NULL OR e.status = sqlc.narg('status'))
|
||||||
|
AND (sqlc.narg('cursor')::text IS NULL OR te.slug > sqlc.narg('cursor'))
|
||||||
|
ORDER BY te.slug
|
||||||
|
LIMIT sqlc.arg('lim');
|
||||||
|
|
||||||
|
-- name: InsertFeedback :exec
|
||||||
|
INSERT INTO feedback (entity_id, execution_id, outcome, observation, lesson,
|
||||||
|
unexpected_side_effects, tags)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7);
|
||||||
|
|
||||||
|
-- name: GetFeedbackAfterWatermark :many
|
||||||
|
SELECT f.entity_id, f.execution_id, f.outcome, f.observation, f.lesson,
|
||||||
|
f.unexpected_side_effects, f.tags, f.created_at,
|
||||||
|
e.action, e.risk_class, e.target_entity_id,
|
||||||
|
et.name AS applies_type
|
||||||
|
FROM feedback f
|
||||||
|
JOIN executions e ON e.entity_id = f.execution_id
|
||||||
|
JOIN entities ent ON ent.id = e.target_entity_id
|
||||||
|
JOIN entity_types et ON et.name = ent.type
|
||||||
|
WHERE f.created_at > $1
|
||||||
|
ORDER BY f.created_at ASC;
|
||||||
|
|
||||||
|
-- name: UpsertPattern :exec
|
||||||
|
INSERT INTO patterns (entity_id, applies_type, action, pattern, confidence,
|
||||||
|
evidence_count, success_count, failure_count, status, version)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'hypothesized', 1)
|
||||||
|
ON CONFLICT (applies_type, action)
|
||||||
|
DO UPDATE SET evidence_count = patterns.evidence_count + EXCLUDED.evidence_count,
|
||||||
|
success_count = patterns.success_count + EXCLUDED.success_count,
|
||||||
|
failure_count = patterns.failure_count + EXCLUDED.failure_count,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- name: GetPattern :one
|
||||||
|
SELECT * FROM patterns WHERE applies_type = $1 AND action = $2;
|
||||||
|
|
||||||
|
-- name: ListPatterns :many
|
||||||
|
SELECT p.* FROM patterns p
|
||||||
|
WHERE (sqlc.narg('status')::text IS NULL OR p.status = sqlc.narg('status'))
|
||||||
|
ORDER BY p.applies_type, p.action;
|
||||||
|
|
||||||
|
-- name: UpdatePatternStatus :exec
|
||||||
|
UPDATE patterns SET status = $2, version = version + 1,
|
||||||
|
last_validated_at = CASE WHEN $2 = 'validated' THEN now() ELSE last_validated_at END
|
||||||
|
WHERE entity_id = $1;
|
||||||
|
|
||||||
|
-- name: UpdatePatternQuarantine :exec
|
||||||
|
UPDATE patterns SET quarantined = $2 WHERE entity_id = $1;
|
||||||
|
|
||||||
|
-- name: ListSkills :many
|
||||||
|
SELECT * FROM skills
|
||||||
|
WHERE (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
|
||||||
|
ORDER BY name, version DESC;
|
||||||
|
|
||||||
|
-- name: InsertSkill :exec
|
||||||
|
INSERT INTO skills (entity_id, version, name, procedure, applies_type, action,
|
||||||
|
pattern_ids, status, changed_by, change_reason)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
|
||||||
|
|
||||||
|
-- name: UpdateSkillStatus :exec
|
||||||
|
UPDATE skills SET status = $2, last_used_at = now() WHERE entity_id = $1 AND version = $2;
|
||||||
|
|
||||||
|
-- name: InsertApproval :exec
|
||||||
|
INSERT INTO approvals (entity_id, subject_entity_id, action, risk_class, kind,
|
||||||
|
payload, status, token_hash, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7, $8);
|
||||||
|
|
||||||
|
-- name: ListApprovals :many
|
||||||
|
SELECT a.*, e.slug AS subject_slug
|
||||||
|
FROM approvals a
|
||||||
|
JOIN entities e ON e.id = a.subject_entity_id
|
||||||
|
WHERE (sqlc.narg('status')::text IS NULL OR a.status = sqlc.narg('status'))
|
||||||
|
AND (sqlc.narg('cursor')::text IS NULL OR e.slug > sqlc.narg('cursor'))
|
||||||
|
ORDER BY e.slug
|
||||||
|
LIMIT sqlc.arg('lim');
|
||||||
|
|
||||||
|
-- name: GetApprovalByID :one
|
||||||
|
SELECT * FROM approvals WHERE entity_id = $1;
|
||||||
|
|
||||||
|
-- name: UpdateApprovalStatus :exec
|
||||||
|
UPDATE approvals SET status = $2, decided_at = now(), decided_by = $3
|
||||||
|
WHERE entity_id = $1 AND status = 'pending';
|
||||||
|
|
||||||
|
-- name: GetAutonomySetting :one
|
||||||
|
SELECT value FROM autonomy_settings WHERE key = $1;
|
||||||
|
|
||||||
|
-- name: ListRiskClasses :many
|
||||||
|
SELECT * FROM risk_classes ORDER BY name;
|
||||||
|
|
||||||
|
-- name: ListApprovalRules :many
|
||||||
|
SELECT * FROM approval_rules ORDER BY entity_type, action;
|
||||||
|
|
||||||
|
-- name: InsertMetricSample :exec
|
||||||
|
INSERT INTO metric_samples (entity_id, metric, value, tags)
|
||||||
|
VALUES ($1, $2, $3, $4);
|
||||||
|
|
||||||
|
-- name: QueryMetrics :many
|
||||||
|
SELECT time_bucket(sqlc.arg('bucket_interval')::interval, ts) AS bucket,
|
||||||
|
entity_id, metric,
|
||||||
|
ROUND(avg(value)::numeric, 2) AS avg_val,
|
||||||
|
ROUND(min(value)::numeric, 2) AS min_val,
|
||||||
|
ROUND(max(value)::numeric, 2) AS max_val
|
||||||
|
FROM metric_samples
|
||||||
|
WHERE entity_id = $1
|
||||||
|
AND metric = $2
|
||||||
|
AND ts > $3
|
||||||
|
GROUP BY bucket, entity_id, metric
|
||||||
|
ORDER BY bucket DESC;
|
||||||
|
|
||||||
|
-- name: UpsertEntityStatus :exec
|
||||||
|
INSERT INTO entity_status (entity_id, health, last_check_at, details)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (entity_id)
|
||||||
|
DO UPDATE SET health = EXCLUDED.health,
|
||||||
|
last_check_at = EXCLUDED.last_check_at,
|
||||||
|
details = EXCLUDED.details,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- name: GetEntityStatus :one
|
||||||
|
SELECT * FROM entity_status WHERE entity_id = $1;
|
||||||
33
internal/db/queries/relationships.sql
Normal file
33
internal/db/queries/relationships.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- name: ListEntityRelations :many
|
||||||
|
SELECT se.slug AS source_slug, te.slug AS target_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 ((sqlc.arg('direction')::text IN ('out','both') AND r.source_id = sqlc.arg('id'))
|
||||||
|
OR (sqlc.arg('direction')::text IN ('in','both') AND r.target_id = sqlc.arg('id')))
|
||||||
|
AND (sqlc.narg('rel_type')::text IS NULL OR r.type = sqlc.narg('rel_type'))
|
||||||
|
ORDER BY r.type, se.slug, te.slug;
|
||||||
|
|
||||||
|
-- name: ListGraphEdges :many
|
||||||
|
SELECT se.slug AS source_slug, te.slug AS target_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(sqlc.arg('ids')::uuid[])
|
||||||
|
AND r.target_id = ANY(sqlc.arg('ids')::uuid[])
|
||||||
|
AND (sqlc.narg('rel_types')::text[] IS NULL OR r.type = ANY(sqlc.narg('rel_types')::text[]))
|
||||||
|
ORDER BY r.type, se.slug, te.slug;
|
||||||
|
|
||||||
|
-- name: UpsertCurrentRelationship :exec
|
||||||
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from, valid_to)
|
||||||
|
VALUES ($1, $2, $3, $4, now(), NULL)
|
||||||
|
ON CONFLICT (source_id, target_id, type) WHERE valid_to IS NULL
|
||||||
|
DO UPDATE SET attributes = EXCLUDED.attributes;
|
||||||
|
|
||||||
|
-- name: EndCurrentRelationship :execrows
|
||||||
|
UPDATE relationships SET valid_to = now()
|
||||||
|
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL;
|
||||||
393
internal/db/seed.go
Normal file
393
internal/db/seed.go
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeedResult holds counts from a seed ingest operation.
|
||||||
|
type SeedResult struct {
|
||||||
|
Lifecycles int
|
||||||
|
EntityTypes int
|
||||||
|
RelationshipTypes int
|
||||||
|
Entities int
|
||||||
|
Relationships int
|
||||||
|
RiskClasses int
|
||||||
|
ApprovalRules int
|
||||||
|
AutonomySettings int
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestOntologySeed ingests seeds/ontology.yaml into the DB.
|
||||||
|
func IngestOntologySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*SeedResult, error) {
|
||||||
|
r := &SeedResult{}
|
||||||
|
|
||||||
|
// Lifecycles
|
||||||
|
lifecycles, _ := data["lifecycles"].(map[string]any)
|
||||||
|
for id, raw := range lifecycles {
|
||||||
|
lcMap, _ := raw.(map[string]any)
|
||||||
|
states := toStringSlice(lcMap["states"])
|
||||||
|
defaultState, _ := lcMap["default_state"].(string)
|
||||||
|
terminalStates := toStringSlice(lcMap["terminal_states"])
|
||||||
|
if len(terminalStates) == 0 {
|
||||||
|
terminalStates = []string{}
|
||||||
|
}
|
||||||
|
transitionsBytes, _ := json.Marshal(lcMap["transitions"])
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO lifecycle_defs (id, states, default_state, terminal_states, transitions)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET states = $2, default_state = $3,
|
||||||
|
terminal_states = $4, transitions = $5`,
|
||||||
|
id, states, defaultState, terminalStates, string(transitionsBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lifecycle %s: %w", id, err)
|
||||||
|
}
|
||||||
|
r.Lifecycles++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity types — need to handle parent_type FK, so insert in dependency order
|
||||||
|
// (types with no parent first, then their children)
|
||||||
|
types, _ := data["entity_types"].(map[string]any)
|
||||||
|
if err := insertEntityTypes(ctx, tx, types, r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationship types
|
||||||
|
relTypes, _ := data["relationship_types"].(map[string]any)
|
||||||
|
for name, raw := range relTypes {
|
||||||
|
rtMap, _ := raw.(map[string]any)
|
||||||
|
inverse, _ := rtMap["inverse"].(string)
|
||||||
|
sourceType, _ := rtMap["source"].(string)
|
||||||
|
targetType, _ := rtMap["target"].(string)
|
||||||
|
cardinality, _ := rtMap["cardinality"].(string)
|
||||||
|
desc, _ := rtMap["description"].(string)
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO relationship_types (name, inverse, source_type, target_type, cardinality, description)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET inverse = $2, source_type = $3,
|
||||||
|
target_type = $4, cardinality = $5, description = $6`,
|
||||||
|
name, nullableStr(inverse), sourceType, targetType, cardinality, desc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("relationship_type %s: %w", name, err)
|
||||||
|
}
|
||||||
|
r.RelationshipTypes++
|
||||||
|
}
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestInventorySeed ingests seeds/inventory.yaml into the DB.
|
||||||
|
// Every entity and edge is validated against the ontology (abstract types
|
||||||
|
// rejected, lifecycle states checked, relationship endpoints hierarchy-
|
||||||
|
// validated, cardinality enforced) — a violating seed rolls back atomically.
|
||||||
|
func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*SeedResult, error) {
|
||||||
|
r := &SeedResult{}
|
||||||
|
|
||||||
|
tree, err := LoadTypeTree(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load type tree: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entities
|
||||||
|
entities, _ := data["entities"].([]any)
|
||||||
|
entityTypes := make(map[string]string) // slug -> type, for edge validation
|
||||||
|
for _, raw := range entities {
|
||||||
|
eMap, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slug, _ := eMap["slug"].(string)
|
||||||
|
typeName, _ := eMap["type"].(string)
|
||||||
|
name, _ := eMap["name"].(string)
|
||||||
|
state, _ := eMap["state"].(string)
|
||||||
|
attrs := eMap["attributes"]
|
||||||
|
|
||||||
|
if err := tree.ValidateEntity(typeName, state); err != nil {
|
||||||
|
return nil, fmt.Errorf("entity %s: %w", slug, err)
|
||||||
|
}
|
||||||
|
if state == "" {
|
||||||
|
state = tree.DefaultState(typeName)
|
||||||
|
}
|
||||||
|
entityTypes[slug] = typeName
|
||||||
|
|
||||||
|
entityID, err := getOrCreateEntityID(ctx, tx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("entity %s: %w", slug, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
attrsBytes, _ := json.Marshal(attrs)
|
||||||
|
_, err = tx.Exec(ctx,
|
||||||
|
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 1, now(), now())
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET type = $3, name = $4, state = $5,
|
||||||
|
attributes = $6, updated_at = now()`,
|
||||||
|
entityID, slug, typeName, name, nullableStr(state), string(attrsBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("entity %s: %w", slug, err)
|
||||||
|
}
|
||||||
|
r.Entities++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationships — upsert against the current-edge partial unique index
|
||||||
|
// (migration 007) so re-ingest never duplicates edges.
|
||||||
|
rels, _ := data["relationships"].([]any)
|
||||||
|
for _, raw := range rels {
|
||||||
|
relMap, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
source, _ := relMap["source"].(string)
|
||||||
|
target, _ := relMap["target"].(string)
|
||||||
|
relType, _ := relMap["type"].(string)
|
||||||
|
attrs := relMap["attributes"]
|
||||||
|
|
||||||
|
sourceID, err := getEntityIDBySlug(ctx, tx, source)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rel from %s: %w", source, err)
|
||||||
|
}
|
||||||
|
targetID, err := getEntityIDBySlug(ctx, tx, target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rel to %s: %w", target, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srcType := entityTypes[source]
|
||||||
|
tgtType := entityTypes[target]
|
||||||
|
if srcType == "" || tgtType == "" { // entity pre-existing in DB, not in this seed
|
||||||
|
if srcType == "" {
|
||||||
|
srcType, err = getEntityTypeBySlug(ctx, tx, source)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tgtType == "" {
|
||||||
|
tgtType, err = getEntityTypeBySlug(ctx, tx, target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tree.ValidateEdge(relType, srcType, tgtType); err != nil {
|
||||||
|
return nil, fmt.Errorf("rel %s→%s: %w", source, target, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
attrsBytes, _ := json.Marshal(attrs)
|
||||||
|
_, err = tx.Exec(ctx,
|
||||||
|
`INSERT INTO relationships (source_id, target_id, type, attributes, valid_from, valid_to)
|
||||||
|
VALUES ($1, $2, $3, $4, now(), NULL)
|
||||||
|
ON CONFLICT (source_id, target_id, type) WHERE valid_to IS NULL
|
||||||
|
DO UPDATE SET attributes = EXCLUDED.attributes`,
|
||||||
|
sourceID, targetID, relType, string(attrsBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rel %s→%s %s: %w", source, target, relType, err)
|
||||||
|
}
|
||||||
|
r.Relationships++
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateCardinality(ctx, tx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestPolicySeed ingests seeds/policy.yaml into the DB.
|
||||||
|
func IngestPolicySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*SeedResult, error) {
|
||||||
|
r := &SeedResult{}
|
||||||
|
|
||||||
|
// Risk classes
|
||||||
|
riskClasses, _ := data["risk_classes"].(map[string]any)
|
||||||
|
for name, raw := range riskClasses {
|
||||||
|
rcMap, _ := raw.(map[string]any)
|
||||||
|
desc, _ := rcMap["description"].(string)
|
||||||
|
approval, _ := rcMap["approval_required"].(string)
|
||||||
|
autonomy, _ := rcMap["autonomy_allowed"].(bool)
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO risk_classes (name, description, approval_required, autonomy_allowed)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET description = $2, approval_required = $3, autonomy_allowed = $4`,
|
||||||
|
name, desc, approval, autonomy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("risk_class %s: %w", name, err)
|
||||||
|
}
|
||||||
|
r.RiskClasses++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approval rules
|
||||||
|
rules, _ := data["approval_rules"].([]any)
|
||||||
|
for _, raw := range rules {
|
||||||
|
ruleMap, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entityType, _ := ruleMap["entity_type"].(string)
|
||||||
|
action, _ := ruleMap["action"].(string)
|
||||||
|
riskClass, _ := ruleMap["risk_class"].(string)
|
||||||
|
autonomy, _ := ruleMap["autonomy_level"].(string)
|
||||||
|
scopeEntity, _ := ruleMap["scope_entity"].(string)
|
||||||
|
|
||||||
|
var scopeID any
|
||||||
|
if scopeEntity != "" {
|
||||||
|
id, err := getEntityIDBySlug(ctx, tx, scopeEntity)
|
||||||
|
if err == nil {
|
||||||
|
scopeID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleID := uuid.New()
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO approval_rules (id, entity_type, action, risk_class, autonomy_level, scope_entity, version, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 1, now())
|
||||||
|
ON CONFLICT (entity_type, action, scope_entity)
|
||||||
|
DO UPDATE SET risk_class = $4, autonomy_level = $5, scope_entity = $6, updated_at = now()`,
|
||||||
|
ruleID, nullableStr(entityType), action, riskClass, autonomy, scopeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("approval_rule %s/%s: %w", entityType, action, err)
|
||||||
|
}
|
||||||
|
r.ApprovalRules++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autonomy settings
|
||||||
|
settings, _ := data["autonomy_settings"].(map[string]any)
|
||||||
|
for key, raw := range settings {
|
||||||
|
val, _ := raw.(string)
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO autonomy_settings (key, value, version, updated_at)
|
||||||
|
VALUES ($1, $2, 1, now())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now()`,
|
||||||
|
key, val)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("autonomy_setting %s: %w", key, err)
|
||||||
|
}
|
||||||
|
r.AutonomySettings++
|
||||||
|
}
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
// getOrCreateEntityID returns the UUID for a slug, generating a new
|
||||||
|
// time-ordered UUIDv7 if the slug doesn't exist yet (ADR-0005).
|
||||||
|
func getOrCreateEntityID(ctx context.Context, tx pgx.Tx, slug string) (uuid.UUID, error) {
|
||||||
|
var id uuid.UUID
|
||||||
|
err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&id)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return id, nil
|
||||||
|
case errors.Is(err, pgx.ErrNoRows):
|
||||||
|
return uuid.NewV7()
|
||||||
|
default:
|
||||||
|
return uuid.Nil, fmt.Errorf("lookup slug %s: %w", slug, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEntityTypeBySlug resolves a slug to its entity type name.
|
||||||
|
func getEntityTypeBySlug(ctx context.Context, tx pgx.Tx, slug string) (string, error) {
|
||||||
|
var t string
|
||||||
|
err := tx.QueryRow(ctx, "SELECT type FROM entities WHERE slug = $1", slug).Scan(&t)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve type of %s: %w", slug, err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEntityIDBySlug resolves a slug to its UUID.
|
||||||
|
func getEntityIDBySlug(ctx context.Context, tx pgx.Tx, slug string) (uuid.UUID, error) {
|
||||||
|
var id uuid.UUID
|
||||||
|
err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, fmt.Errorf("resolve slug %s: %w", slug, err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertEntityTypes inserts entity types in dependency order (parents before children).
|
||||||
|
func insertEntityTypes(ctx context.Context, tx pgx.Tx, types map[string]any, r *SeedResult) error {
|
||||||
|
// Build a dependency graph and insert in topological order
|
||||||
|
// Simple approach: insert types with no parent first, then iterate
|
||||||
|
inserted := make(map[string]bool)
|
||||||
|
remaining := make(map[string]map[string]any)
|
||||||
|
for name, raw := range types {
|
||||||
|
tMap, _ := raw.(map[string]any)
|
||||||
|
remaining[name] = tMap
|
||||||
|
}
|
||||||
|
|
||||||
|
maxPasses := 10
|
||||||
|
for pass := 0; pass < maxPasses && len(remaining) > 0; pass++ {
|
||||||
|
for name, tMap := range remaining {
|
||||||
|
parent, _ := tMap["parent"].(string)
|
||||||
|
if parent == "" || inserted[parent] {
|
||||||
|
if err := insertOneEntityType(ctx, tx, name, tMap); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
inserted[name] = true
|
||||||
|
delete(remaining, name)
|
||||||
|
r.EntityTypes++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(remaining) > 0 {
|
||||||
|
return fmt.Errorf("circular or missing parent in entity types: %v", keysOf(remaining))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertOneEntityType(ctx context.Context, tx pgx.Tx, name string, tMap map[string]any) error {
|
||||||
|
parent, _ := tMap["parent"].(string)
|
||||||
|
isAbstract, _ := tMap["abstract"].(bool)
|
||||||
|
domain, _ := tMap["domain"].(string)
|
||||||
|
layer, _ := tMap["layer"].(string)
|
||||||
|
desc, _ := tMap["description"].(string)
|
||||||
|
lifecycleID, _ := tMap["lifecycle"].(string)
|
||||||
|
attrSchema := tMap["attribute_schema"]
|
||||||
|
|
||||||
|
schemaBytes, _ := json.Marshal(attrSchema)
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, description,
|
||||||
|
lifecycle_id, attribute_schema, schema_version, status, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, 'active', now(), now())
|
||||||
|
ON CONFLICT (name) DO UPDATE SET parent_type = $2, is_abstract = $3, domain = $4,
|
||||||
|
layer = $5, description = $6, lifecycle_id = $7, attribute_schema = $8, updated_at = now()`,
|
||||||
|
name, nullableStr(parent), isAbstract, domain, layer, desc, nullableStr(lifecycleID), nullableStr(string(schemaBytes)))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func toStringSlice(v any) []string {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch s := v.(type) {
|
||||||
|
case []string:
|
||||||
|
return s
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(s))
|
||||||
|
for _, item := range s {
|
||||||
|
if str, ok := item.(string); ok {
|
||||||
|
out = append(out, str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableStr(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysOf(m map[string]map[string]any) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
53
internal/db/splitsql_test.go
Normal file
53
internal/db/splitsql_test.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func nonEmpty(stmts []string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, s := range stmts {
|
||||||
|
if strings.TrimSpace(s) != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSQLBasic(t *testing.T) {
|
||||||
|
stmts := nonEmpty(splitSQL("CREATE TABLE a (id int); CREATE TABLE b (id int);"))
|
||||||
|
if len(stmts) != 2 {
|
||||||
|
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSQLDollarQuotedFunction(t *testing.T) {
|
||||||
|
sql := `CREATE FUNCTION f() RETURNS int AS $$
|
||||||
|
SELECT 1; SELECT 2;
|
||||||
|
$$ LANGUAGE sql;
|
||||||
|
CREATE TABLE t (id int);`
|
||||||
|
stmts := nonEmpty(splitSQL(sql))
|
||||||
|
if len(stmts) != 2 {
|
||||||
|
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stmts[0], "SELECT 1; SELECT 2;") {
|
||||||
|
t.Errorf("dollar-quoted body was split: %q", stmts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSQLTaggedDollarQuote(t *testing.T) {
|
||||||
|
sql := `DO $body$ BEGIN PERFORM 1; END $body$;SELECT 1;`
|
||||||
|
stmts := nonEmpty(splitSQL(sql))
|
||||||
|
if len(stmts) != 2 {
|
||||||
|
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSQLSemicolonInComment(t *testing.T) {
|
||||||
|
sql := "-- comment with ; semicolon\nCREATE TABLE t (id int); -- trailing; note\nSELECT 1;"
|
||||||
|
stmts := nonEmpty(splitSQL(sql))
|
||||||
|
if len(stmts) != 2 {
|
||||||
|
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
internal/db/sqlcgen/db.go
Normal file
32
internal/db/sqlcgen/db.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
|
||||||
|
package sqlcgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DBTX interface {
|
||||||
|
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||||
|
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||||
|
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db DBTX) *Queries {
|
||||||
|
return &Queries{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Queries struct {
|
||||||
|
db DBTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||||
|
return &Queries{
|
||||||
|
db: tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
254
internal/db/sqlcgen/entities.sql.go
Normal file
254
internal/db/sqlcgen/entities.sql.go
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: entities.sql
|
||||||
|
|
||||||
|
package sqlcgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const getEntityByID = `-- name: GetEntityByID :one
|
||||||
|
|
||||||
|
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at FROM entities e WHERE e.id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Entity read + mutation queries (API paths). Aliased `e` throughout to
|
||||||
|
// avoid ambiguity with joined tables.
|
||||||
|
func (q *Queries) GetEntityByID(ctx context.Context, id uuid.UUID) (Entity, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getEntityByID, id)
|
||||||
|
var i Entity
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Type,
|
||||||
|
&i.Name,
|
||||||
|
&i.State,
|
||||||
|
&i.Attributes,
|
||||||
|
&i.MaintenanceUntil,
|
||||||
|
&i.Version,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEntityBySlug = `-- name: GetEntityBySlug :one
|
||||||
|
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at FROM entities e WHERE e.slug = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetEntityBySlug(ctx context.Context, slug string) (Entity, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getEntityBySlug, slug)
|
||||||
|
var i Entity
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Type,
|
||||||
|
&i.Name,
|
||||||
|
&i.State,
|
||||||
|
&i.Attributes,
|
||||||
|
&i.MaintenanceUntil,
|
||||||
|
&i.Version,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertEntity = `-- name: InsertEntity :one
|
||||||
|
INSERT INTO entities (id, slug, type, name, state, attributes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, slug, type, name, state, attributes, maintenance_until, version, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertEntityParams struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Slug string
|
||||||
|
Type string
|
||||||
|
Name string
|
||||||
|
State *string
|
||||||
|
Attributes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertEntity(ctx context.Context, arg InsertEntityParams) (Entity, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertEntity,
|
||||||
|
arg.ID,
|
||||||
|
arg.Slug,
|
||||||
|
arg.Type,
|
||||||
|
arg.Name,
|
||||||
|
arg.State,
|
||||||
|
arg.Attributes,
|
||||||
|
)
|
||||||
|
var i Entity
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Type,
|
||||||
|
&i.Name,
|
||||||
|
&i.State,
|
||||||
|
&i.Attributes,
|
||||||
|
&i.MaintenanceUntil,
|
||||||
|
&i.Version,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listEntities = `-- name: ListEntities :many
|
||||||
|
WITH RECURSIVE tt AS (
|
||||||
|
SELECT name FROM entity_types WHERE $7::text IS NULL OR name = $7
|
||||||
|
UNION
|
||||||
|
SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name
|
||||||
|
WHERE $7::text IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at FROM entities e
|
||||||
|
JOIN entity_types et ON et.name = e.type
|
||||||
|
WHERE e.type IN (SELECT name FROM tt)
|
||||||
|
AND ($1::text IS NULL OR e.state = $1)
|
||||||
|
AND ($2::text IS NULL OR et.domain = $2)
|
||||||
|
AND ($3::text IS NULL OR et.layer = $3)
|
||||||
|
AND ($4::text IS NULL
|
||||||
|
OR e.slug ILIKE '%'||$4||'%'
|
||||||
|
OR e.name ILIKE '%'||$4||'%')
|
||||||
|
AND ($5::text IS NULL OR e.slug > $5)
|
||||||
|
ORDER BY e.slug
|
||||||
|
LIMIT $6
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListEntitiesParams struct {
|
||||||
|
State *string
|
||||||
|
Domain *string
|
||||||
|
Layer *string
|
||||||
|
Q *string
|
||||||
|
Cursor *string
|
||||||
|
Lim int32
|
||||||
|
Type *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListEntities(ctx context.Context, arg ListEntitiesParams) ([]Entity, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listEntities,
|
||||||
|
arg.State,
|
||||||
|
arg.Domain,
|
||||||
|
arg.Layer,
|
||||||
|
arg.Q,
|
||||||
|
arg.Cursor,
|
||||||
|
arg.Lim,
|
||||||
|
arg.Type,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Entity
|
||||||
|
for rows.Next() {
|
||||||
|
var i Entity
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Type,
|
||||||
|
&i.Name,
|
||||||
|
&i.State,
|
||||||
|
&i.Attributes,
|
||||||
|
&i.MaintenanceUntil,
|
||||||
|
&i.Version,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listEntitiesCapped = `-- name: ListEntitiesCapped :many
|
||||||
|
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at FROM entities e ORDER BY e.slug LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListEntitiesCapped(ctx context.Context, limit int32) ([]Entity, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listEntitiesCapped, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Entity
|
||||||
|
for rows.Next() {
|
||||||
|
var i Entity
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Type,
|
||||||
|
&i.Name,
|
||||||
|
&i.State,
|
||||||
|
&i.Attributes,
|
||||||
|
&i.MaintenanceUntil,
|
||||||
|
&i.Version,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateEntity = `-- name: UpdateEntity :one
|
||||||
|
UPDATE entities SET
|
||||||
|
name = COALESCE($1, name),
|
||||||
|
state = COALESCE($2, state),
|
||||||
|
attributes = COALESCE($3, attributes),
|
||||||
|
maintenance_until = CASE WHEN $4::bool
|
||||||
|
THEN $5 ELSE maintenance_until END,
|
||||||
|
version = version + 1,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $6 AND version = $7
|
||||||
|
RETURNING id, slug, type, name, state, attributes, maintenance_until, version, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateEntityParams struct {
|
||||||
|
Name *string
|
||||||
|
State *string
|
||||||
|
Attributes []byte
|
||||||
|
SetMaintenance bool
|
||||||
|
MaintenanceUntil *time.Time
|
||||||
|
ID uuid.UUID
|
||||||
|
Version int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateEntity(ctx context.Context, arg UpdateEntityParams) (Entity, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateEntity,
|
||||||
|
arg.Name,
|
||||||
|
arg.State,
|
||||||
|
arg.Attributes,
|
||||||
|
arg.SetMaintenance,
|
||||||
|
arg.MaintenanceUntil,
|
||||||
|
arg.ID,
|
||||||
|
arg.Version,
|
||||||
|
)
|
||||||
|
var i Entity
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Type,
|
||||||
|
&i.Name,
|
||||||
|
&i.State,
|
||||||
|
&i.Attributes,
|
||||||
|
&i.MaintenanceUntil,
|
||||||
|
&i.Version,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
329
internal/db/sqlcgen/models.go
Normal file
329
internal/db/sqlcgen/models.go
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
|
||||||
|
package sqlcgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentActivity struct {
|
||||||
|
ID int64
|
||||||
|
Ts time.Time
|
||||||
|
AgentID uuid.UUID
|
||||||
|
SessionID *string
|
||||||
|
ActivityType string
|
||||||
|
ToolName *string
|
||||||
|
EntityID *uuid.UUID
|
||||||
|
InputSummary *string
|
||||||
|
OutputSummary *string
|
||||||
|
DurationMs *int32
|
||||||
|
TokenCount *int32
|
||||||
|
Success *bool
|
||||||
|
CorrelationID *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Approval struct {
|
||||||
|
EntityID uuid.UUID
|
||||||
|
SubjectEntityID *uuid.UUID
|
||||||
|
Action string
|
||||||
|
RiskClass string
|
||||||
|
Kind string
|
||||||
|
Payload []byte
|
||||||
|
Status string
|
||||||
|
TokenHash *string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
DecidedAt *time.Time
|
||||||
|
DecidedBy *uuid.UUID
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApprovalRule struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
EntityType *string
|
||||||
|
Action string
|
||||||
|
RiskClass string
|
||||||
|
AutonomyLevel string
|
||||||
|
ScopeEntity *uuid.UUID
|
||||||
|
Version int32
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditLog struct {
|
||||||
|
ID int64
|
||||||
|
Ts time.Time
|
||||||
|
ActorType string
|
||||||
|
ActorID *uuid.UUID
|
||||||
|
Action string
|
||||||
|
EntityID *uuid.UUID
|
||||||
|
Method *string
|
||||||
|
Path *string
|
||||||
|
StatusCode *int32
|
||||||
|
Detail []byte
|
||||||
|
SourceIp *string
|
||||||
|
CorrelationID *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutonomySetting struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
Version int32
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckDef struct {
|
||||||
|
EntityID uuid.UUID
|
||||||
|
TargetID *uuid.UUID
|
||||||
|
TargetType *string
|
||||||
|
Kind string
|
||||||
|
Config []byte
|
||||||
|
IntervalS int32
|
||||||
|
TimeoutS int32
|
||||||
|
Zone *string
|
||||||
|
Enabled bool
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Classification struct {
|
||||||
|
EntityID uuid.UUID
|
||||||
|
SignalEntityID *uuid.UUID
|
||||||
|
TargetEntityID *uuid.UUID
|
||||||
|
Action string
|
||||||
|
RecommendedAction []byte
|
||||||
|
RiskClass string
|
||||||
|
Route string
|
||||||
|
BlastRadius []uuid.UUID
|
||||||
|
PatternConfidence *float32
|
||||||
|
SkillID *uuid.UUID
|
||||||
|
AutonomyCheck *string
|
||||||
|
Reasoning []byte
|
||||||
|
CorrelationID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entity struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Slug string
|
||||||
|
Type string
|
||||||
|
Name string
|
||||||
|
State *string
|
||||||
|
Attributes []byte
|
||||||
|
MaintenanceUntil *time.Time
|
||||||
|
Version int32
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntityStatus struct {
|
||||||
|
EntityID uuid.UUID
|
||||||
|
Health string
|
||||||
|
LastCheckAt *time.Time
|
||||||
|
Details []byte
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntityType struct {
|
||||||
|
Name string
|
||||||
|
ParentType *string
|
||||||
|
IsAbstract bool
|
||||||
|
Domain string
|
||||||
|
Layer string
|
||||||
|
Description *string
|
||||||
|
LifecycleID *string
|
||||||
|
AttributeSchema []byte
|
||||||
|
SchemaVersion int32
|
||||||
|
Status string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
ID int64
|
||||||
|
Ts time.Time
|
||||||
|
Type string
|
||||||
|
EntityID *uuid.UUID
|
||||||
|
Severity string
|
||||||
|
Source string
|
||||||
|
Data []byte
|
||||||
|
CorrelationID *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Execution struct {
|
||||||
|
EntityID uuid.UUID
|
||||||
|
ClassificationID *uuid.UUID
|
||||||
|
SignalEntityID *uuid.UUID
|
||||||
|
TargetEntityID *uuid.UUID
|
||||||
|
Action string
|
||||||
|
RiskClass string
|
||||||
|
ApprovalID *uuid.UUID
|
||||||
|
AgentID *uuid.UUID
|
||||||
|
SkillID *uuid.UUID
|
||||||
|
SkillVersion *int32
|
||||||
|
Status string
|
||||||
|
Result []byte
|
||||||
|
DurationMs *int32
|
||||||
|
Verified bool
|
||||||
|
CorrelationID string
|
||||||
|
StartedAt *time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Feedback struct {
|
||||||
|
EntityID uuid.UUID
|
||||||
|
ExecutionID uuid.UUID
|
||||||
|
Outcome string
|
||||||
|
Observation *string
|
||||||
|
Lesson *string
|
||||||
|
UnexpectedSideEffects []string
|
||||||
|
Tags []string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdempotencyKey struct {
|
||||||
|
Key string
|
||||||
|
Actor string
|
||||||
|
RequestHash string
|
||||||
|
ResponseCode *int32
|
||||||
|
ResponseBody []byte
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ledger struct {
|
||||||
|
Ts time.Time
|
||||||
|
ExecutionID uuid.UUID
|
||||||
|
TargetEntityID *uuid.UUID
|
||||||
|
Action string
|
||||||
|
RiskClass string
|
||||||
|
Status string
|
||||||
|
Verified bool
|
||||||
|
Route *string
|
||||||
|
Reasoning []byte
|
||||||
|
ApprovalStatus *string
|
||||||
|
DecidedBy *uuid.UUID
|
||||||
|
AgentID *uuid.UUID
|
||||||
|
CorrelationID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LifecycleDef struct {
|
||||||
|
ID string
|
||||||
|
States []string
|
||||||
|
DefaultState string
|
||||||
|
TerminalStates []string
|
||||||
|
Transitions []byte
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricRollups1d struct {
|
||||||
|
Bucket interface{}
|
||||||
|
EntityID uuid.UUID
|
||||||
|
Metric string
|
||||||
|
AvgValue float64
|
||||||
|
MinValue interface{}
|
||||||
|
MaxValue interface{}
|
||||||
|
SampleCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricRollups1h struct {
|
||||||
|
Bucket interface{}
|
||||||
|
EntityID uuid.UUID
|
||||||
|
Metric string
|
||||||
|
AvgValue float64
|
||||||
|
MinValue interface{}
|
||||||
|
MaxValue interface{}
|
||||||
|
SampleCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricSample struct {
|
||||||
|
Ts time.Time
|
||||||
|
EntityID uuid.UUID
|
||||||
|
Metric string
|
||||||
|
Value float64
|
||||||
|
Tags []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pattern struct {
|
||||||
|
EntityID uuid.UUID
|
||||||
|
AppliesType string
|
||||||
|
Action string
|
||||||
|
Pattern string
|
||||||
|
Confidence float32
|
||||||
|
EvidenceCount int32
|
||||||
|
SuccessCount int32
|
||||||
|
FailureCount int32
|
||||||
|
Status string
|
||||||
|
Quarantined bool
|
||||||
|
Version int32
|
||||||
|
LastValidatedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Relationship struct {
|
||||||
|
SourceID uuid.UUID
|
||||||
|
TargetID uuid.UUID
|
||||||
|
Type string
|
||||||
|
Attributes []byte
|
||||||
|
ValidFrom time.Time
|
||||||
|
ValidTo *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type RelationshipType struct {
|
||||||
|
Name string
|
||||||
|
Inverse *string
|
||||||
|
SourceType string
|
||||||
|
TargetType string
|
||||||
|
Cardinality string
|
||||||
|
Description *string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type RiskClass struct {
|
||||||
|
Name string
|
||||||
|
Description *string
|
||||||
|
ApprovalRequired string
|
||||||
|
AutonomyAllowed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeedVersion struct {
|
||||||
|
File string
|
||||||
|
ContentHash string
|
||||||
|
AppliedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Signal struct {
|
||||||
|
EntityID uuid.UUID
|
||||||
|
Kind string
|
||||||
|
Severity string
|
||||||
|
TargetEntityID *uuid.UUID
|
||||||
|
CheckID *uuid.UUID
|
||||||
|
Evidence *string
|
||||||
|
LikelyCause *string
|
||||||
|
State string
|
||||||
|
OccurrenceCount int32
|
||||||
|
FirstSeenAt time.Time
|
||||||
|
LastSeenAt time.Time
|
||||||
|
FlapCount int32
|
||||||
|
HoldDownUntil *time.Time
|
||||||
|
MuteUntil *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Skill struct {
|
||||||
|
EntityID uuid.UUID
|
||||||
|
Version int32
|
||||||
|
Name string
|
||||||
|
Procedure []byte
|
||||||
|
AppliesType *string
|
||||||
|
Action string
|
||||||
|
PatternIds []uuid.UUID
|
||||||
|
Status string
|
||||||
|
SuccessRate *float32
|
||||||
|
ChangedBy *uuid.UUID
|
||||||
|
ChangeReason *string
|
||||||
|
LastUsedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
130
internal/db/sqlcgen/ontology.sql.go
Normal file
130
internal/db/sqlcgen/ontology.sql.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: ontology.sql
|
||||||
|
|
||||||
|
package sqlcgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const getLifecycleForType = `-- name: GetLifecycleForType :one
|
||||||
|
SELECT ld.id, ld.states, ld.default_state, ld.terminal_states, ld.transitions, ld.created_at FROM lifecycle_defs ld
|
||||||
|
JOIN entity_types et ON et.lifecycle_id = ld.id
|
||||||
|
WHERE et.name = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetLifecycleForType(ctx context.Context, name string) (LifecycleDef, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getLifecycleForType, name)
|
||||||
|
var i LifecycleDef
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.States,
|
||||||
|
&i.DefaultState,
|
||||||
|
&i.TerminalStates,
|
||||||
|
&i.Transitions,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listEntityTypes = `-- name: ListEntityTypes :many
|
||||||
|
SELECT name, parent_type, is_abstract, domain, layer, description, lifecycle_id, attribute_schema, schema_version, status, created_at, updated_at FROM entity_types ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListEntityTypes(ctx context.Context) ([]EntityType, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listEntityTypes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []EntityType
|
||||||
|
for rows.Next() {
|
||||||
|
var i EntityType
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Name,
|
||||||
|
&i.ParentType,
|
||||||
|
&i.IsAbstract,
|
||||||
|
&i.Domain,
|
||||||
|
&i.Layer,
|
||||||
|
&i.Description,
|
||||||
|
&i.LifecycleID,
|
||||||
|
&i.AttributeSchema,
|
||||||
|
&i.SchemaVersion,
|
||||||
|
&i.Status,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listLifecycleDefs = `-- name: ListLifecycleDefs :many
|
||||||
|
SELECT id, states, default_state, terminal_states, transitions, created_at FROM lifecycle_defs ORDER BY id
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListLifecycleDefs(ctx context.Context) ([]LifecycleDef, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listLifecycleDefs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []LifecycleDef
|
||||||
|
for rows.Next() {
|
||||||
|
var i LifecycleDef
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.States,
|
||||||
|
&i.DefaultState,
|
||||||
|
&i.TerminalStates,
|
||||||
|
&i.Transitions,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listRelationshipTypes = `-- name: ListRelationshipTypes :many
|
||||||
|
SELECT name, inverse, source_type, target_type, cardinality, description, created_at FROM relationship_types ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListRelationshipTypes(ctx context.Context) ([]RelationshipType, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listRelationshipTypes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RelationshipType
|
||||||
|
for rows.Next() {
|
||||||
|
var i RelationshipType
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Name,
|
||||||
|
&i.Inverse,
|
||||||
|
&i.SourceType,
|
||||||
|
&i.TargetType,
|
||||||
|
&i.Cardinality,
|
||||||
|
&i.Description,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
1601
internal/db/sqlcgen/operations.sql.go
Normal file
1601
internal/db/sqlcgen/operations.sql.go
Normal file
File diff suppressed because it is too large
Load Diff
165
internal/db/sqlcgen/relationships.sql.go
Normal file
165
internal/db/sqlcgen/relationships.sql.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: relationships.sql
|
||||||
|
|
||||||
|
package sqlcgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const endCurrentRelationship = `-- name: EndCurrentRelationship :execrows
|
||||||
|
UPDATE relationships SET valid_to = now()
|
||||||
|
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
type EndCurrentRelationshipParams struct {
|
||||||
|
SourceID uuid.UUID
|
||||||
|
TargetID uuid.UUID
|
||||||
|
Type string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) EndCurrentRelationship(ctx context.Context, arg EndCurrentRelationshipParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, endCurrentRelationship, arg.SourceID, arg.TargetID, arg.Type)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listEntityRelations = `-- name: ListEntityRelations :many
|
||||||
|
SELECT se.slug AS source_slug, te.slug AS target_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 (($1::text IN ('out','both') AND r.source_id = $2)
|
||||||
|
OR ($1::text IN ('in','both') AND r.target_id = $2))
|
||||||
|
AND ($3::text IS NULL OR r.type = $3)
|
||||||
|
ORDER BY r.type, se.slug, te.slug
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListEntityRelationsParams struct {
|
||||||
|
Direction string
|
||||||
|
ID uuid.UUID
|
||||||
|
RelType *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListEntityRelationsRow struct {
|
||||||
|
SourceSlug string
|
||||||
|
TargetSlug string
|
||||||
|
Type string
|
||||||
|
Attributes []byte
|
||||||
|
ValidFrom time.Time
|
||||||
|
ValidTo *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListEntityRelations(ctx context.Context, arg ListEntityRelationsParams) ([]ListEntityRelationsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listEntityRelations, arg.Direction, arg.ID, arg.RelType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListEntityRelationsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListEntityRelationsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.SourceSlug,
|
||||||
|
&i.TargetSlug,
|
||||||
|
&i.Type,
|
||||||
|
&i.Attributes,
|
||||||
|
&i.ValidFrom,
|
||||||
|
&i.ValidTo,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listGraphEdges = `-- name: ListGraphEdges :many
|
||||||
|
SELECT se.slug AS source_slug, te.slug AS target_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::uuid[])
|
||||||
|
AND r.target_id = ANY($1::uuid[])
|
||||||
|
AND ($2::text[] IS NULL OR r.type = ANY($2::text[]))
|
||||||
|
ORDER BY r.type, se.slug, te.slug
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListGraphEdgesParams struct {
|
||||||
|
Ids []uuid.UUID
|
||||||
|
RelTypes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListGraphEdgesRow struct {
|
||||||
|
SourceSlug string
|
||||||
|
TargetSlug string
|
||||||
|
Type string
|
||||||
|
Attributes []byte
|
||||||
|
ValidFrom time.Time
|
||||||
|
ValidTo *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListGraphEdges(ctx context.Context, arg ListGraphEdgesParams) ([]ListGraphEdgesRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listGraphEdges, arg.Ids, arg.RelTypes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListGraphEdgesRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListGraphEdgesRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.SourceSlug,
|
||||||
|
&i.TargetSlug,
|
||||||
|
&i.Type,
|
||||||
|
&i.Attributes,
|
||||||
|
&i.ValidFrom,
|
||||||
|
&i.ValidTo,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertCurrentRelationship = `-- name: UpsertCurrentRelationship :exec
|
||||||
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from, valid_to)
|
||||||
|
VALUES ($1, $2, $3, $4, now(), NULL)
|
||||||
|
ON CONFLICT (source_id, target_id, type) WHERE valid_to IS NULL
|
||||||
|
DO UPDATE SET attributes = EXCLUDED.attributes
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertCurrentRelationshipParams struct {
|
||||||
|
SourceID uuid.UUID
|
||||||
|
TargetID uuid.UUID
|
||||||
|
Type string
|
||||||
|
Attributes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertCurrentRelationship(ctx context.Context, arg UpsertCurrentRelationshipParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertCurrentRelationship,
|
||||||
|
arg.SourceID,
|
||||||
|
arg.TargetID,
|
||||||
|
arg.Type,
|
||||||
|
arg.Attributes,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
143
internal/db/typetree.go
Normal file
143
internal/db/typetree.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/ontology"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadTypeTree loads the ontology meta-schema from the DB for validation.
|
||||||
|
// Called within the same transaction as an ingest so it sees just-ingested
|
||||||
|
// types.
|
||||||
|
func LoadTypeTree(ctx context.Context, tx pgx.Tx) (*ontology.TypeTree, error) {
|
||||||
|
t := &ontology.TypeTree{
|
||||||
|
Types: map[string]ontology.TypeInfo{},
|
||||||
|
RelTypes: map[string]ontology.RelTypeInfo{},
|
||||||
|
Lifecycles: map[string]ontology.LifecycleInfo{},
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := tx.Query(ctx,
|
||||||
|
`SELECT name, COALESCE(parent_type,''), is_abstract, COALESCE(lifecycle_id,'')
|
||||||
|
FROM entity_types`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load entity_types: %w", err)
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
var info ontology.TypeInfo
|
||||||
|
if err := rows.Scan(&name, &info.Parent, &info.IsAbstract, &info.LifecycleID); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.Types[name] = info
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err = tx.Query(ctx,
|
||||||
|
`SELECT name, source_type, target_type, cardinality FROM relationship_types`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load relationship_types: %w", err)
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
var info ontology.RelTypeInfo
|
||||||
|
if err := rows.Scan(&name, &info.SourceType, &info.TargetType, &info.Cardinality); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.RelTypes[name] = info
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err = tx.Query(ctx,
|
||||||
|
`SELECT id, states, default_state FROM lifecycle_defs`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load lifecycle_defs: %w", err)
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, def string
|
||||||
|
var states []string
|
||||||
|
if err := rows.Scan(&id, &states, &def); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
set := make(map[string]bool, len(states))
|
||||||
|
for _, s := range states {
|
||||||
|
set[s] = true
|
||||||
|
}
|
||||||
|
t.Lifecycles[id] = ontology.LifecycleInfo{States: set, DefaultState: def}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateCardinality checks all CURRENT edges against their relationship
|
||||||
|
// type's declared cardinality. Runs inside the ingest transaction so a
|
||||||
|
// violating seed rolls back atomically.
|
||||||
|
func ValidateCardinality(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
// one-to-one / many-to-one: a source may have at most one outgoing
|
||||||
|
// current edge of the type.
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
SELECT r.type, se.slug, count(*)
|
||||||
|
FROM relationships r
|
||||||
|
JOIN relationship_types rt ON rt.name = r.type
|
||||||
|
JOIN entities se ON se.id = r.source_id
|
||||||
|
WHERE r.valid_to IS NULL AND rt.cardinality IN ('one-to-one','many-to-one')
|
||||||
|
GROUP BY r.type, se.slug HAVING count(*) > 1`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
violations, err := collectViolations(rows, "source")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// one-to-one / one-to-many: a target may have at most one incoming
|
||||||
|
// current edge of the type.
|
||||||
|
rows, err = tx.Query(ctx, `
|
||||||
|
SELECT r.type, te.slug, count(*)
|
||||||
|
FROM relationships r
|
||||||
|
JOIN relationship_types rt ON rt.name = r.type
|
||||||
|
JOIN entities te ON te.id = r.target_id
|
||||||
|
WHERE r.valid_to IS NULL AND rt.cardinality IN ('one-to-one','one-to-many')
|
||||||
|
GROUP BY r.type, te.slug HAVING count(*) > 1`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v2, err := collectViolations(rows, "target")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
violations = append(violations, v2...)
|
||||||
|
|
||||||
|
if len(violations) > 0 {
|
||||||
|
return fmt.Errorf("cardinality violations: %v", violations)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectViolations(rows pgx.Rows, side string) ([]string, error) {
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var relType, slug string
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&relType, &slug, &n); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, fmt.Sprintf("%s %s=%s (%d edges)", relType, side, slug, n))
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
110
internal/domain/approval.go
Normal file
110
internal/domain/approval.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Approval is a short-TTL signed grant for a gated action.
|
||||||
|
type Approval struct {
|
||||||
|
EntityID UUID
|
||||||
|
SubjectEntityID UUID
|
||||||
|
Action string
|
||||||
|
RiskClass string
|
||||||
|
Kind string
|
||||||
|
Payload map[string]any
|
||||||
|
Status string
|
||||||
|
TokenHash string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
DecidedAt *time.Time
|
||||||
|
DecidedBy UUID
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approval statuses.
|
||||||
|
const (
|
||||||
|
ApprovalPending = "pending"
|
||||||
|
ApprovalApproved = "approved"
|
||||||
|
ApprovalDenied = "denied"
|
||||||
|
ApprovalExpired = "expired"
|
||||||
|
ApprovalRevoked = "revoked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Approval kinds.
|
||||||
|
const (
|
||||||
|
ApprovalKindExecution = "execution"
|
||||||
|
ApprovalKindPolicyChange = "policy-change"
|
||||||
|
ApprovalKindPatternActivation = "pattern-activation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckDef defines a probe (R3-7: probes as data, not code).
|
||||||
|
type CheckDef struct {
|
||||||
|
EntityID UUID
|
||||||
|
TargetID UUID
|
||||||
|
TargetType string
|
||||||
|
Kind string
|
||||||
|
Config map[string]any
|
||||||
|
IntervalS int
|
||||||
|
TimeoutS int
|
||||||
|
Zone string
|
||||||
|
Enabled bool
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check kinds.
|
||||||
|
const (
|
||||||
|
CheckHTTP = "http"
|
||||||
|
CheckTCP = "tcp"
|
||||||
|
CheckDisk = "disk"
|
||||||
|
CheckCertExpiry = "cert-expiry"
|
||||||
|
CheckDrift = "drift"
|
||||||
|
CheckSSHScript = "ssh-script"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EntityStatus is the current health of an entity (R3-6: replaces state_snapshots).
|
||||||
|
type EntityStatus struct {
|
||||||
|
EntityID UUID
|
||||||
|
Health string
|
||||||
|
LastCheckAt *time.Time
|
||||||
|
Details map[string]any
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health values.
|
||||||
|
const (
|
||||||
|
HealthHealthy = "healthy"
|
||||||
|
HealthDegraded = "degraded"
|
||||||
|
HealthDown = "down"
|
||||||
|
HealthUnknown = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RiskClass is the four-level safety model.
|
||||||
|
type RiskClass struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
ApprovalRequired string
|
||||||
|
AutonomyAllowed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Risk class names.
|
||||||
|
const (
|
||||||
|
RiskReadOnly = "read_only"
|
||||||
|
RiskReversibleLow = "reversible_low"
|
||||||
|
RiskConfigMutation = "config_mutation"
|
||||||
|
RiskDestructive = "destructive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApprovalRule maps (entity_type, action) → risk_class + autonomy.
|
||||||
|
type ApprovalRule struct {
|
||||||
|
ID UUID
|
||||||
|
EntityType string
|
||||||
|
Action string
|
||||||
|
RiskClass string
|
||||||
|
AutonomyLevel string
|
||||||
|
ScopeEntity UUID
|
||||||
|
Version int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autonomy levels.
|
||||||
|
const (
|
||||||
|
AutonomyAuto = "auto"
|
||||||
|
AutonomyEscalate = "escalate"
|
||||||
|
AutonomyNever = "never"
|
||||||
|
)
|
||||||
76
internal/domain/entity.go
Normal file
76
internal/domain/entity.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entity is the core graph node — every object in the OS is an entity.
|
||||||
|
// Typed tables (signals, executions, etc.) reference entities(id) for
|
||||||
|
// indexed querying; graph edges live in the relationships table.
|
||||||
|
type Entity struct {
|
||||||
|
ID UUID
|
||||||
|
Slug string
|
||||||
|
Type string
|
||||||
|
Name string
|
||||||
|
State string
|
||||||
|
Attributes map[string]any
|
||||||
|
MaintenanceUntil *time.Time
|
||||||
|
Version int
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntityType is the meta-schema entry defining what entities can exist.
|
||||||
|
type EntityType struct {
|
||||||
|
Name string
|
||||||
|
ParentType string
|
||||||
|
IsAbstract bool
|
||||||
|
Domain string
|
||||||
|
Layer string
|
||||||
|
Description string
|
||||||
|
LifecycleID string
|
||||||
|
AttributeSchema map[string]any
|
||||||
|
SchemaVersion int
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelationshipType defines a typed edge between entity types.
|
||||||
|
type RelationshipType struct {
|
||||||
|
Name string
|
||||||
|
Inverse string
|
||||||
|
SourceType string
|
||||||
|
TargetType string
|
||||||
|
Cardinality string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LifecycleDef is the state machine for an entity type.
|
||||||
|
type LifecycleDef struct {
|
||||||
|
ID string
|
||||||
|
States []string
|
||||||
|
DefaultState string
|
||||||
|
TerminalStates []string
|
||||||
|
Transitions map[string]map[string]TransitionReq
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransitionReq holds the named preconditions for a lifecycle transition.
|
||||||
|
type TransitionReq struct {
|
||||||
|
Requires []string `json:"requires"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationship is a typed edge between two entities.
|
||||||
|
type Relationship struct {
|
||||||
|
SourceID UUID
|
||||||
|
TargetID UUID
|
||||||
|
Type string
|
||||||
|
Attributes map[string]any
|
||||||
|
ValidFrom time.Time
|
||||||
|
ValidTo *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UUID is a type alias for UUID values. Using string for simplicity;
|
||||||
|
// the DB layer uses pgx's UUID type. Conversion happens at the boundary.
|
||||||
|
type UUID string
|
||||||
|
|
||||||
|
// IsNil returns true if the UUID is empty.
|
||||||
|
func (u UUID) IsNil() bool { return u == "" }
|
||||||
22
internal/domain/errors.go
Normal file
22
internal/domain/errors.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Sentinel errors. Used throughout the codebase for typed error handling.
|
||||||
|
// The API middleware maps these to HTTP status codes (SG11).
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("entity not found")
|
||||||
|
ErrInvalidTransition = errors.New("invalid lifecycle transition")
|
||||||
|
ErrApprovalRequired = errors.New("operator approval required")
|
||||||
|
ErrAutonomyBlocked = errors.New("autonomy policy blocks this action")
|
||||||
|
ErrConflict = errors.New("concurrent modification conflict")
|
||||||
|
ErrCircuitOpen = errors.New("circuit breaker open for target")
|
||||||
|
ErrAbstractType = errors.New("cannot instantiate abstract entity type")
|
||||||
|
ErrInvalidEdge = errors.New("relationship endpoint type mismatch")
|
||||||
|
ErrCardinality = errors.New("relationship cardinality violation")
|
||||||
|
ErrSeedHashMismatch = errors.New("seed content hash mismatch")
|
||||||
|
ErrAlreadyExists = errors.New("entity already exists")
|
||||||
|
ErrQuarantined = errors.New("pattern is quarantined")
|
||||||
|
ErrSkillDeprecated = errors.New("skill is deprecated")
|
||||||
|
ErrInvalidInput = errors.New("invalid input")
|
||||||
|
)
|
||||||
64
internal/domain/execution.go
Normal file
64
internal/domain/execution.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Classification persists every autonomous decision the classifier makes (SA5).
|
||||||
|
// This is the audit trail for "why did the OS auto-act / escalate?"
|
||||||
|
type Classification struct {
|
||||||
|
EntityID UUID
|
||||||
|
SignalEntityID UUID
|
||||||
|
TargetEntityID UUID
|
||||||
|
Action string
|
||||||
|
RecommendedAction map[string]any
|
||||||
|
RiskClass string
|
||||||
|
Route string
|
||||||
|
BlastRadius []UUID
|
||||||
|
PatternConfidence float64
|
||||||
|
SkillID UUID
|
||||||
|
AutonomyCheck string
|
||||||
|
Reasoning map[string]any
|
||||||
|
CorrelationID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classification routes.
|
||||||
|
const (
|
||||||
|
RouteAutoAct = "auto-act"
|
||||||
|
RouteEscalate = "escalate"
|
||||||
|
RouteHold = "hold"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Execution is a detailed record of one action the OS performed.
|
||||||
|
type Execution struct {
|
||||||
|
EntityID UUID
|
||||||
|
ClassificationID UUID
|
||||||
|
SignalEntityID UUID
|
||||||
|
TargetEntityID UUID
|
||||||
|
Action string
|
||||||
|
RiskClass string
|
||||||
|
ApprovalID UUID
|
||||||
|
AgentID UUID
|
||||||
|
SkillID UUID
|
||||||
|
SkillVersion int
|
||||||
|
Status string
|
||||||
|
Result map[string]any
|
||||||
|
DurationMs int
|
||||||
|
Verified bool
|
||||||
|
CorrelationID string
|
||||||
|
StartedAt *time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execution lifecycle states.
|
||||||
|
const (
|
||||||
|
ExecProposed = "proposed"
|
||||||
|
ExecApproved = "approved"
|
||||||
|
ExecExecuting = "executing"
|
||||||
|
ExecVerified = "verified"
|
||||||
|
ExecFailed = "failed"
|
||||||
|
ExecTimedOut = "timed-out"
|
||||||
|
ExecRolledBack = "rolled-back"
|
||||||
|
ExecCancelled = "cancelled"
|
||||||
|
ExecExpired = "expired"
|
||||||
|
)
|
||||||
76
internal/domain/pattern.go
Normal file
76
internal/domain/pattern.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Feedback records what was learned from an execution.
|
||||||
|
type Feedback struct {
|
||||||
|
EntityID UUID
|
||||||
|
ExecutionID UUID
|
||||||
|
Outcome string
|
||||||
|
Observation string
|
||||||
|
Lesson string
|
||||||
|
UnexpectedSideEffects []string
|
||||||
|
Tags []string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feedback outcomes.
|
||||||
|
const (
|
||||||
|
OutcomeSuccess = "success"
|
||||||
|
OutcomeFailure = "failure"
|
||||||
|
OutcomePartial = "partial"
|
||||||
|
OutcomeUnexpected = "unexpected"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pattern is a generalized rule extracted from accumulated feedback.
|
||||||
|
type Pattern struct {
|
||||||
|
EntityID UUID
|
||||||
|
AppliesType string
|
||||||
|
Action string
|
||||||
|
Pattern string
|
||||||
|
Confidence float64
|
||||||
|
EvidenceCount int
|
||||||
|
SuccessCount int
|
||||||
|
FailureCount int
|
||||||
|
Status string
|
||||||
|
Quarantined bool
|
||||||
|
Version int
|
||||||
|
LastValidatedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern lifecycle states.
|
||||||
|
const (
|
||||||
|
PatternHypothesized = "hypothesized"
|
||||||
|
PatternValidated = "validated"
|
||||||
|
PatternActive = "active"
|
||||||
|
PatternDeprecated = "deprecated"
|
||||||
|
PatternInvalidated = "invalidated"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Skill is a codified procedure refined through validated patterns.
|
||||||
|
type Skill struct {
|
||||||
|
EntityID UUID
|
||||||
|
Version int
|
||||||
|
Name string
|
||||||
|
Procedure map[string]any
|
||||||
|
AppliesType string
|
||||||
|
Action string
|
||||||
|
PatternIDs []UUID
|
||||||
|
Status string
|
||||||
|
SuccessRate float64
|
||||||
|
ChangedBy UUID
|
||||||
|
ChangeReason string
|
||||||
|
LastUsedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skill lifecycle states.
|
||||||
|
const (
|
||||||
|
SkillDrafted = "drafted"
|
||||||
|
SkillTested = "tested"
|
||||||
|
SkillActive = "active"
|
||||||
|
SkillRefined = "refined"
|
||||||
|
SkillDeprecated = "deprecated"
|
||||||
|
SkillFailed = "failed"
|
||||||
|
)
|
||||||
65
internal/domain/signal.go
Normal file
65
internal/domain/signal.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Signal is an attention record — something the lab noticed that needs
|
||||||
|
// attention and possibly action. Dual entity: has an entities row + a
|
||||||
|
// signals table row for indexed querying.
|
||||||
|
type Signal struct {
|
||||||
|
EntityID UUID
|
||||||
|
Kind string
|
||||||
|
Severity string
|
||||||
|
TargetEntityID UUID
|
||||||
|
CheckID UUID
|
||||||
|
Evidence string
|
||||||
|
LikelyCause string
|
||||||
|
State string
|
||||||
|
OccurrenceCount int
|
||||||
|
FirstSeenAt time.Time
|
||||||
|
LastSeenAt time.Time
|
||||||
|
FlapCount int
|
||||||
|
HoldDownUntil *time.Time
|
||||||
|
MuteUntil *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal lifecycle states (see lifecycle_defs in seeds/ontology.yaml).
|
||||||
|
const (
|
||||||
|
SignalRaised = "raised"
|
||||||
|
SignalAcknowledged = "acknowledged"
|
||||||
|
SignalActing = "acting"
|
||||||
|
SignalMuted = "muted"
|
||||||
|
SignalResolved = "resolved"
|
||||||
|
SignalFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Signal severities.
|
||||||
|
const (
|
||||||
|
SeverityInfo = "info"
|
||||||
|
SeverityWarning = "warning"
|
||||||
|
SeverityCritical = "critical"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidSignalTransitions defines legal state transitions.
|
||||||
|
var ValidSignalTransitions = map[string][]string{
|
||||||
|
SignalRaised: {SignalAcknowledged, SignalMuted, SignalResolved},
|
||||||
|
SignalAcknowledged: {SignalActing, SignalResolved, SignalMuted},
|
||||||
|
SignalActing: {SignalResolved, SignalRaised, SignalFailed},
|
||||||
|
SignalFailed: {SignalAcknowledged},
|
||||||
|
SignalMuted: {SignalRaised},
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanTransition returns true if from→to is a legal signal state transition.
|
||||||
|
func (s *Signal) CanTransition(to string) bool {
|
||||||
|
allowed, ok := ValidSignalTransitions[s.State]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, a := range allowed {
|
||||||
|
if a == to {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
299
internal/httpapi/api_test.go
Normal file
299
internal/httpapi/api_test.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
// API integration tests. Guarded by OIKOS_TEST_DATABASE_URL (see
|
||||||
|
// internal/db/integration_test.go); run via `make test-db-all` or plain
|
||||||
|
// `go test` with the env var set.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/config"
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
|
||||||
|
if baseURL == "" {
|
||||||
|
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
admin, err := pgx.Connect(ctx, baseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect admin: %v", err)
|
||||||
|
}
|
||||||
|
dbName := fmt.Sprintf("oikos_api_test_%08x", rand.Int63())
|
||||||
|
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
|
||||||
|
admin.Close(ctx)
|
||||||
|
t.Fatalf("create test db: %v", err)
|
||||||
|
}
|
||||||
|
admin.Close(ctx)
|
||||||
|
|
||||||
|
qi := strings.Index(baseURL, "?")
|
||||||
|
base, params := baseURL, ""
|
||||||
|
if qi >= 0 {
|
||||||
|
base, params = baseURL[:qi], baseURL[qi:]
|
||||||
|
}
|
||||||
|
testURL := base[:strings.LastIndex(base, "/")+1] + dbName + params
|
||||||
|
|
||||||
|
pool, err := db.New(ctx, testURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect test db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pool.Close()
|
||||||
|
admin, err := pgx.Connect(ctx, baseURL)
|
||||||
|
if err == nil {
|
||||||
|
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
|
||||||
|
admin.Close(ctx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handler context governs the SSE listener goroutine. Cancel it before
|
||||||
|
// the pool closes (cleanups run LIFO, so registering it after the
|
||||||
|
// pool-close cleanup makes it run first) — otherwise the listener holds
|
||||||
|
// a pooled connection and pool.Close() deadlocks.
|
||||||
|
handlerCtx, cancelHandler := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancelHandler)
|
||||||
|
|
||||||
|
if err := pool.Migrate(ctx); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
|
||||||
|
content, err := os.ReadFile("../../seeds/" + f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read seed %s: %v", f, err)
|
||||||
|
}
|
||||||
|
name := f
|
||||||
|
err = pool.SeedIngest(ctx, name, content,
|
||||||
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||||
|
var err error
|
||||||
|
switch name {
|
||||||
|
case "ontology.yaml":
|
||||||
|
_, err = db.IngestOntologySeed(ctx, tx, data)
|
||||||
|
case "inventory.yaml":
|
||||||
|
_, err = db.IngestInventorySeed(ctx, tx, data)
|
||||||
|
case "policy.yaml":
|
||||||
|
_, err = db.IngestPolicySeed(ctx, tx, data)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ingest %s: %v", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewHandler(handlerCtx, pool, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(t *testing.T, h http.Handler, path string, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest("GET", path, nil)
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var body map[string]any
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &body)
|
||||||
|
return rec, body
|
||||||
|
}
|
||||||
|
|
||||||
|
func devConfig() config.Config {
|
||||||
|
c := config.Default()
|
||||||
|
c.APIEnv = "dev" // no tokens → dev-open auth
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIEndToEnd(t *testing.T) {
|
||||||
|
h := newTestHandler(t, devConfig())
|
||||||
|
|
||||||
|
t.Run("healthz", func(t *testing.T) {
|
||||||
|
rec, body := get(t, h, "/healthz", nil)
|
||||||
|
if rec.Code != 200 || body["status"] != "ok" {
|
||||||
|
t.Fatalf("healthz = %d %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("list entities filtered by type", func(t *testing.T) {
|
||||||
|
rec, body := get(t, h, "/api/v1/entities?type=service", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status %d: %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
items := body["items"].([]any)
|
||||||
|
if len(items) < 20 {
|
||||||
|
t.Errorf("expected 20+ services, got %d", len(items))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("type filter includes descendants via hierarchy", func(t *testing.T) {
|
||||||
|
// machine is abstract; proxmox-host/workstation/standalone-server descend from it
|
||||||
|
rec, body := get(t, h, "/api/v1/entities?type=machine", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status %d: %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
items := body["items"].([]any)
|
||||||
|
if len(items) < 5 {
|
||||||
|
t.Errorf("expected 5+ machines (hubris, strong, vps, 2 workstations), got %d", len(items))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pagination", func(t *testing.T) {
|
||||||
|
rec, body := get(t, h, "/api/v1/entities?limit=10", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status %d", rec.Code)
|
||||||
|
}
|
||||||
|
if len(body["items"].([]any)) != 10 {
|
||||||
|
t.Fatalf("limit=10 returned %d", len(body["items"].([]any)))
|
||||||
|
}
|
||||||
|
cursor, _ := body["next_cursor"].(string)
|
||||||
|
if cursor == "" {
|
||||||
|
t.Fatal("expected next_cursor")
|
||||||
|
}
|
||||||
|
rec2, body2 := get(t, h, "/api/v1/entities?limit=10&cursor="+cursor, nil)
|
||||||
|
if rec2.Code != 200 {
|
||||||
|
t.Fatalf("page 2 status %d", rec2.Code)
|
||||||
|
}
|
||||||
|
first := body2["items"].([]any)[0].(map[string]any)["slug"].(string)
|
||||||
|
if first <= cursor {
|
||||||
|
t.Errorf("page 2 first slug %q not after cursor %q", first, cursor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("get entity by slug with etag", func(t *testing.T) {
|
||||||
|
rec, body := get(t, h, "/api/v1/entities/host:hubris", nil)
|
||||||
|
if rec.Code != 200 || body["slug"] != "host:hubris" {
|
||||||
|
t.Fatalf("get by slug = %d %v", rec.Code, body["slug"])
|
||||||
|
}
|
||||||
|
if rec.Header().Get("ETag") == "" {
|
||||||
|
t.Error("missing ETag header")
|
||||||
|
}
|
||||||
|
// and by UUID
|
||||||
|
id := body["id"].(string)
|
||||||
|
rec2, body2 := get(t, h, "/api/v1/entities/"+id, nil)
|
||||||
|
if rec2.Code != 200 || body2["slug"] != "host:hubris" {
|
||||||
|
t.Errorf("get by uuid = %d %v", rec2.Code, body2["slug"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown entity is 404 problem+json", func(t *testing.T) {
|
||||||
|
rec, body := get(t, h, "/api/v1/entities/host:nonexistent", nil)
|
||||||
|
if rec.Code != 404 {
|
||||||
|
t.Fatalf("status %d", rec.Code)
|
||||||
|
}
|
||||||
|
if ct := rec.Header().Get("Content-Type"); ct != "application/problem+json" {
|
||||||
|
t.Errorf("content-type %q", ct)
|
||||||
|
}
|
||||||
|
if body["title"] != "not found" {
|
||||||
|
t.Errorf("problem title %v", body["title"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("relations", func(t *testing.T) {
|
||||||
|
rec, body := get(t, h, "/api/v1/entities/host:hubris/relations?rel_type=hosts&direction=out", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status %d", rec.Code)
|
||||||
|
}
|
||||||
|
items := body["items"].([]any)
|
||||||
|
if len(items) < 10 {
|
||||||
|
t.Errorf("hubris hosts %d guests, want 10+", len(items))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("blast radius", func(t *testing.T) {
|
||||||
|
rec, body := get(t, h, "/api/v1/entities/lxc:caddy/blast-radius?depth=2", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status %d", rec.Code)
|
||||||
|
}
|
||||||
|
if len(body["items"].([]any)) < 2 {
|
||||||
|
t.Errorf("blast radius too small: %v", body["items"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("graph", func(t *testing.T) {
|
||||||
|
rec, body := get(t, h, "/api/v1/graph?root=service:paperless&depth=2", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status %d", rec.Code)
|
||||||
|
}
|
||||||
|
if len(body["nodes"].([]any)) < 2 || len(body["edges"].([]any)) < 1 {
|
||||||
|
t.Errorf("graph too small: %d nodes %d edges",
|
||||||
|
len(body["nodes"].([]any)), len(body["edges"].([]any)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ontology", func(t *testing.T) {
|
||||||
|
rec, body := get(t, h, "/api/v1/ontology", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status %d", rec.Code)
|
||||||
|
}
|
||||||
|
if len(body["entity_types"].([]any)) != 59 {
|
||||||
|
t.Errorf("entity_types = %d, want 59", len(body["entity_types"].([]any)))
|
||||||
|
}
|
||||||
|
if len(body["lifecycles"].([]any)) != 6 {
|
||||||
|
t.Errorf("lifecycles = %d, want 6", len(body["lifecycles"].([]any)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("signals empty list", func(t *testing.T) {
|
||||||
|
rec, body := get(t, h, "/api/v1/signals", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status %d: %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("export returns real seeds", func(t *testing.T) {
|
||||||
|
rec, body := get(t, h, "/api/v1/export", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status %d", rec.Code)
|
||||||
|
}
|
||||||
|
if len(body["inventory"].(string)) < 1000 {
|
||||||
|
t.Errorf("inventory export suspiciously small")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unimplemented endpoint is 501 problem+json", func(t *testing.T) {
|
||||||
|
rec, body := get(t, h, "/api/v1/patterns", nil)
|
||||||
|
if rec.Code != 501 {
|
||||||
|
t.Fatalf("status %d, want 501", rec.Code)
|
||||||
|
}
|
||||||
|
if body["title"] != "not implemented" {
|
||||||
|
t.Errorf("problem title %v", body["title"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIBearerAuth(t *testing.T) {
|
||||||
|
cfg := devConfig()
|
||||||
|
cfg.APIToken = "test-token-123"
|
||||||
|
h := newTestHandler(t, cfg)
|
||||||
|
|
||||||
|
// healthz stays open
|
||||||
|
rec, _ := get(t, h, "/healthz", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Errorf("healthz with auth enabled = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// API requires the token
|
||||||
|
rec, body := get(t, h, "/api/v1/entities", nil)
|
||||||
|
if rec.Code != 401 {
|
||||||
|
t.Errorf("no token = %d, want 401 (%v)", rec.Code, body)
|
||||||
|
}
|
||||||
|
rec, _ = get(t, h, "/api/v1/entities", map[string]string{"Authorization": "Bearer wrong"})
|
||||||
|
if rec.Code != 401 {
|
||||||
|
t.Errorf("wrong token = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
rec, _ = get(t, h, "/api/v1/entities", map[string]string{"Authorization": "Bearer test-token-123"})
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Errorf("valid token = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
7567
internal/httpapi/gen/api.gen.go
Normal file
7567
internal/httpapi/gen/api.gen.go
Normal file
File diff suppressed because it is too large
Load Diff
1084
internal/httpapi/impl.go
Normal file
1084
internal/httpapi/impl.go
Normal file
File diff suppressed because it is too large
Load Diff
178
internal/httpapi/mutations_test.go
Normal file
178
internal/httpapi/mutations_test.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
// Integration tests for the Phase 2 mutation surface: entity create/patch
|
||||||
|
// with optimistic concurrency, idempotency, lifecycle-transition validation,
|
||||||
|
// and the audit/event side effects. Guarded by OIKOS_TEST_DATABASE_URL.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// do issues a JSON request and returns the recorder + decoded body.
|
||||||
|
func do(t *testing.T, h http.Handler, method, path string, body any, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
var rdr *bytes.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
rdr = bytes.NewReader(b)
|
||||||
|
} else {
|
||||||
|
rdr = bytes.NewReader(nil)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, path, rdr)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var decoded map[string]any
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &decoded)
|
||||||
|
return rec, decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntityCreateAndPatch(t *testing.T) {
|
||||||
|
h := newTestHandler(t, devConfig())
|
||||||
|
|
||||||
|
// ── create ──────────────────────────────────────────────────
|
||||||
|
rec, body := do(t, h, "POST", "/api/v1/entities", map[string]any{
|
||||||
|
"slug": "service:test-widget",
|
||||||
|
"type": "service",
|
||||||
|
"name": "test-widget",
|
||||||
|
"attributes": map[string]any{"port": 9999},
|
||||||
|
}, nil)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create status %d: %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
if body["slug"] != "service:test-widget" {
|
||||||
|
t.Fatalf("created slug = %v", body["slug"])
|
||||||
|
}
|
||||||
|
// default lifecycle state applied
|
||||||
|
if body["state"] != "active" {
|
||||||
|
t.Errorf("default state = %v, want active", body["state"])
|
||||||
|
}
|
||||||
|
etag := rec.Header().Get("ETag")
|
||||||
|
if etag == "" {
|
||||||
|
t.Error("missing ETag on create")
|
||||||
|
}
|
||||||
|
version := int(body["version"].(float64))
|
||||||
|
|
||||||
|
// ── duplicate slug → 409 ────────────────────────────────────
|
||||||
|
rec, _ = do(t, h, "POST", "/api/v1/entities", map[string]any{
|
||||||
|
"slug": "service:test-widget", "type": "service", "name": "dup",
|
||||||
|
}, nil)
|
||||||
|
if rec.Code != 409 {
|
||||||
|
t.Errorf("duplicate slug status = %d, want 409", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── abstract type → 422 ─────────────────────────────────────
|
||||||
|
rec, _ = do(t, h, "POST", "/api/v1/entities", map[string]any{
|
||||||
|
"slug": "machine:ghost", "type": "machine", "name": "ghost",
|
||||||
|
}, nil)
|
||||||
|
if rec.Code != 422 {
|
||||||
|
t.Errorf("abstract type status = %d, want 422", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── patch without If-Match → 400 ────────────────────────────
|
||||||
|
rec, _ = do(t, h, "PATCH", "/api/v1/entities/service:test-widget",
|
||||||
|
map[string]any{"name": "renamed"}, nil)
|
||||||
|
if rec.Code != 400 {
|
||||||
|
t.Errorf("patch w/o If-Match = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── patch with stale If-Match → 409 ─────────────────────────
|
||||||
|
rec, _ = do(t, h, "PATCH", "/api/v1/entities/service:test-widget",
|
||||||
|
map[string]any{"name": "renamed"}, map[string]string{"If-Match": `"999"`})
|
||||||
|
if rec.Code != 409 {
|
||||||
|
t.Errorf("stale If-Match = %d, want 409", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── valid attribute patch → 200, version bumps ──────────────
|
||||||
|
rec, body = do(t, h, "PATCH", "/api/v1/entities/service:test-widget",
|
||||||
|
map[string]any{"name": "renamed"}, map[string]string{"If-Match": itoaQ(version)})
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("patch status %d: %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
if body["name"] != "renamed" || int(body["version"].(float64)) != version+1 {
|
||||||
|
t.Errorf("patch result: name=%v version=%v", body["name"], body["version"])
|
||||||
|
}
|
||||||
|
version++
|
||||||
|
|
||||||
|
// ── valid lifecycle transition active→deprecated → 200 ──────
|
||||||
|
// (this is the regression guard for the transitions-parsing 500 bug)
|
||||||
|
rec, body = do(t, h, "PATCH", "/api/v1/entities/service:test-widget",
|
||||||
|
map[string]any{"state": "deprecated"}, map[string]string{"If-Match": itoaQ(version)})
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("valid transition status %d: %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
if body["state"] != "deprecated" {
|
||||||
|
t.Errorf("state = %v, want deprecated", body["state"])
|
||||||
|
}
|
||||||
|
version++
|
||||||
|
|
||||||
|
// ── invalid lifecycle transition deprecated→provisioning → 409 ──
|
||||||
|
rec, _ = do(t, h, "PATCH", "/api/v1/entities/service:test-widget",
|
||||||
|
map[string]any{"state": "provisioning"}, map[string]string{"If-Match": itoaQ(version)})
|
||||||
|
if rec.Code != 409 {
|
||||||
|
t.Errorf("invalid transition status = %d, want 409", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntityCreateIdempotency(t *testing.T) {
|
||||||
|
h := newTestHandler(t, devConfig())
|
||||||
|
payload := map[string]any{"slug": "service:idem", "type": "service", "name": "idem"}
|
||||||
|
key := map[string]string{"Idempotency-Key": "abc-123"}
|
||||||
|
|
||||||
|
rec1, body1 := do(t, h, "POST", "/api/v1/entities", payload, key)
|
||||||
|
if rec1.Code != 201 {
|
||||||
|
t.Fatalf("first create %d: %v", rec1.Code, body1)
|
||||||
|
}
|
||||||
|
// replay same key + body → same response, not a duplicate-slug 409
|
||||||
|
rec2, body2 := do(t, h, "POST", "/api/v1/entities", payload, key)
|
||||||
|
if rec2.Code != 201 {
|
||||||
|
t.Fatalf("idempotent replay = %d, want 201: %v", rec2.Code, body2)
|
||||||
|
}
|
||||||
|
if body1["id"] != body2["id"] {
|
||||||
|
t.Errorf("replay returned different entity: %v vs %v", body1["id"], body2["id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// same key, different body → 409 conflict
|
||||||
|
rec3, _ := do(t, h, "POST", "/api/v1/entities",
|
||||||
|
map[string]any{"slug": "service:idem2", "type": "service", "name": "idem2"}, key)
|
||||||
|
if rec3.Code != 409 {
|
||||||
|
t.Errorf("key reuse w/ different body = %d, want 409", rec3.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMutationEmitsEventAndAudit(t *testing.T) {
|
||||||
|
h := newTestHandler(t, devConfig())
|
||||||
|
|
||||||
|
rec, _ := do(t, h, "POST", "/api/v1/entities",
|
||||||
|
map[string]any{"slug": "service:evt", "type": "service", "name": "evt"}, nil)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create failed: %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// event stream recorded the creation
|
||||||
|
_, body := do(t, h, "GET", "/api/v1/events?type=entity.created", nil, nil)
|
||||||
|
items, _ := body["items"].([]any)
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Fatal("no entity.created event recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// audit trail recorded the create (operator-visible)
|
||||||
|
_, abody := do(t, h, "GET", "/api/v1/audit?action=create", nil, nil)
|
||||||
|
aitems, _ := abody["items"].([]any)
|
||||||
|
if len(aitems) == 0 {
|
||||||
|
t.Fatal("no create audit entry recorded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// itoaQ formats an int as a quoted ETag value.
|
||||||
|
func itoaQ(v int) string {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return `"` + string(b) + `"`
|
||||||
|
}
|
||||||
1760
internal/httpapi/phase3.go
Normal file
1760
internal/httpapi/phase3.go
Normal file
File diff suppressed because it is too large
Load Diff
111
internal/httpapi/phase3_test.go
Normal file
111
internal/httpapi/phase3_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
// Integration tests for Phase 3 endpoints: checks, classifications,
|
||||||
|
// executions, approvals, patterns, skills, policy, and knowledge search.
|
||||||
|
// Guarded by OIKOS_TEST_DATABASE_URL; run via `make test-db` or with env set.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPhase3ListChecks(t *testing.T) {
|
||||||
|
h := newTestHandler(t, devConfig())
|
||||||
|
|
||||||
|
rec, body := get(t, h, "/api/v1/checks", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("list checks status %d: %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhase3ListApprovals(t *testing.T) {
|
||||||
|
h := newTestHandler(t, devConfig())
|
||||||
|
|
||||||
|
rec, body := get(t, h, "/api/v1/approvals", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("list approvals status %d: %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhase3ListRiskClasses(t *testing.T) {
|
||||||
|
h := newTestHandler(t, devConfig())
|
||||||
|
|
||||||
|
rec, body := get(t, h, "/api/v1/risk-classes", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("list risk classes status %d: %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
items, _ := body["items"].([]any)
|
||||||
|
if len(items) < 2 {
|
||||||
|
t.Errorf("expected 2+ risk classes, got %d", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhase3ListAutonomySettings(t *testing.T) {
|
||||||
|
h := newTestHandler(t, devConfig())
|
||||||
|
|
||||||
|
rec, body := get(t, h, "/api/v1/autonomy-settings", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("get autonomy settings status %d: %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
_ = body
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhase3ListPatterns(t *testing.T) {
|
||||||
|
h := newTestHandler(t, devConfig())
|
||||||
|
|
||||||
|
rec, body := get(t, h, "/api/v1/patterns", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("list patterns status %d: %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhase3ListSkills(t *testing.T) {
|
||||||
|
h := newTestHandler(t, devConfig())
|
||||||
|
|
||||||
|
rec, body := get(t, h, "/api/v1/skills", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("list skills status %d: %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhase3ListExecutions(t *testing.T) {
|
||||||
|
h := newTestHandler(t, devConfig())
|
||||||
|
|
||||||
|
rec, body := get(t, h, "/api/v1/executions", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("list executions status %d: %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhase3ListClassifications(t *testing.T) {
|
||||||
|
h := newTestHandler(t, devConfig())
|
||||||
|
|
||||||
|
rec, body := get(t, h, "/api/v1/classifications", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("list classifications status %d: %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhase3QueryMetrics(t *testing.T) {
|
||||||
|
h := newTestHandler(t, devConfig())
|
||||||
|
|
||||||
|
rec, body := get(t, h, "/api/v1/metrics?entity_id=00000000-0000-0000-0000-000000000001&metric=health", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("query metrics status %d: %v", rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhase3JSONRoundTrip(t *testing.T) {
|
||||||
|
sigData := map[string]any{
|
||||||
|
"id": "sig-123", "kind": "down", "severity": "critical",
|
||||||
|
"state": "raised", "occurrence_count": 1,
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(sigData)
|
||||||
|
var back map[string]any
|
||||||
|
if err := json.Unmarshal(b, &back); err != nil {
|
||||||
|
t.Fatalf("signal round-trip: %v", err)
|
||||||
|
}
|
||||||
|
if back["kind"] != "down" {
|
||||||
|
t.Errorf("kind = %v, want down", back["kind"])
|
||||||
|
}
|
||||||
|
}
|
||||||
71
internal/httpapi/problem.go
Normal file
71
internal/httpapi/problem.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/domain"
|
||||||
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errNotImplemented marks endpoints stubbed for later phases.
|
||||||
|
var errNotImplemented = errors.New("not implemented yet")
|
||||||
|
|
||||||
|
// statusFor maps domain sentinel errors to HTTP status codes (plan SG11).
|
||||||
|
func statusFor(err error) (status int, title string) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrNotFound):
|
||||||
|
return http.StatusNotFound, "not found"
|
||||||
|
case errors.Is(err, domain.ErrInvalidTransition):
|
||||||
|
return http.StatusConflict, "invalid lifecycle transition"
|
||||||
|
case errors.Is(err, domain.ErrConflict), errors.Is(err, domain.ErrAlreadyExists):
|
||||||
|
return http.StatusConflict, "conflict"
|
||||||
|
case errors.Is(err, domain.ErrCardinality):
|
||||||
|
return http.StatusConflict, "relationship cardinality violation"
|
||||||
|
case errors.Is(err, domain.ErrAbstractType), errors.Is(err, domain.ErrInvalidEdge):
|
||||||
|
return http.StatusUnprocessableEntity, "ontology validation failed"
|
||||||
|
case errors.Is(err, domain.ErrInvalidInput):
|
||||||
|
return http.StatusBadRequest, "invalid input"
|
||||||
|
case errors.Is(err, domain.ErrApprovalRequired):
|
||||||
|
return http.StatusForbidden, "operator approval required"
|
||||||
|
case errors.Is(err, domain.ErrAutonomyBlocked):
|
||||||
|
return http.StatusForbidden, "autonomy policy blocks this action"
|
||||||
|
case errors.Is(err, domain.ErrCircuitOpen):
|
||||||
|
return http.StatusServiceUnavailable, "circuit breaker open"
|
||||||
|
case errors.Is(err, errNotImplemented):
|
||||||
|
return http.StatusNotImplemented, "not implemented"
|
||||||
|
default:
|
||||||
|
return http.StatusInternalServerError, "internal error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeProblem writes an RFC 9457 problem+json response.
|
||||||
|
func writeProblem(w http.ResponseWriter, r *http.Request, status int, title, detail string) {
|
||||||
|
instance := r.URL.Path
|
||||||
|
p := gen.Problem{
|
||||||
|
Status: status,
|
||||||
|
Title: title,
|
||||||
|
Instance: &instance,
|
||||||
|
}
|
||||||
|
if detail != "" {
|
||||||
|
p.Detail = &detail
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/problem+json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeProblemFromErr maps an error to a problem+json response. Internal
|
||||||
|
// error details are logged server-side, never leaked to clients.
|
||||||
|
func writeProblemFromErr(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
status, title := statusFor(err)
|
||||||
|
detail := ""
|
||||||
|
if status != http.StatusInternalServerError {
|
||||||
|
detail = err.Error()
|
||||||
|
} else {
|
||||||
|
slog.Error("internal error", "method", r.Method, "path", r.URL.Path, "error", err)
|
||||||
|
}
|
||||||
|
writeProblem(w, r, status, title, detail)
|
||||||
|
}
|
||||||
472
internal/httpapi/server.go
Normal file
472
internal/httpapi/server.go
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
// Package httpapi implements the Oikos REST API. The contract is
|
||||||
|
// api/openapi.yaml (contract-first, ADR-0004); handlers implement the
|
||||||
|
// oapi-codegen strict-server interface in gen/. Errors map to RFC 9457
|
||||||
|
// problem+json via domain sentinels.
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/subtle"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/config"
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
|
mcphandler "github.com/dtoro/oikos/internal/mcp"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// contextKey for storing actor identity in request context.
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const actorKey contextKey = "oikos_actor"
|
||||||
|
|
||||||
|
// actor holds the resolved identity of the API caller.
|
||||||
|
type actor struct {
|
||||||
|
Type string // "operator", "agent", "system"
|
||||||
|
Label string // human-readable label
|
||||||
|
ID string // OIDC sub or static token identifier
|
||||||
|
TokenType string // "static" or "oidc"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server implements gen.StrictServerInterface over the DB layer.
|
||||||
|
type Server struct {
|
||||||
|
pool *db.Pool
|
||||||
|
cfg config.Config
|
||||||
|
sseBroker *sseBroker
|
||||||
|
sseSubs map[*sseSubscriber]struct{}
|
||||||
|
sseMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
|
||||||
|
// SG18) + the OpenAPI surface under /api/v1 behind bearer auth.
|
||||||
|
//
|
||||||
|
// ctx governs the lifetime of the background SSE listener goroutine, which
|
||||||
|
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
|
||||||
|
// before closing the pool — otherwise the held connection never releases
|
||||||
|
// and pool.Close() deadlocks.
|
||||||
|
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Handler {
|
||||||
|
s := &Server{
|
||||||
|
pool: pool,
|
||||||
|
cfg: cfg,
|
||||||
|
sseBroker: newSSEBroker(10000),
|
||||||
|
sseSubs: make(map[*sseSubscriber]struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start background SSE listener, tied to ctx for clean shutdown.
|
||||||
|
go s.sseListener(ctx)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(requestLogger)
|
||||||
|
|
||||||
|
// Liveness — no auth, no audit (plan SG18). Not exposed via Caddy.
|
||||||
|
r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusServiceUnavailable, "database unreachable", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{
|
||||||
|
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
|
||||||
|
},
|
||||||
|
ResponseErrorHandlerFunc: writeProblemFromErr,
|
||||||
|
})
|
||||||
|
|
||||||
|
gen.HandlerWithOptions(strict, gen.ChiServerOptions{
|
||||||
|
BaseURL: "/api/v1",
|
||||||
|
BaseRouter: r,
|
||||||
|
Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg)},
|
||||||
|
ErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// SSE stream: override the generated /events/stream route with a raw
|
||||||
|
// flushing handler (registered AFTER HandlerWithOptions so chi's last
|
||||||
|
// registration wins). The strict-server path can't Flush() per event;
|
||||||
|
// this one uses the real ResponseWriter for real-time delivery. It
|
||||||
|
// inherits the router's base middleware and applies auth via With().
|
||||||
|
r.With(combinedAuth(cfg)).Get("/api/v1/events/stream", s.serveSSE)
|
||||||
|
|
||||||
|
// Mount MCP at /mcp (plan R3-10)
|
||||||
|
r.With(combinedAuth(cfg)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken))
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// combinedAuth tries OIDC JWT validation first (if configured), falls back to
|
||||||
|
// static bearer token validation, and opens the gate in dev mode when no
|
||||||
|
// credentials are configured.
|
||||||
|
func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
|
||||||
|
hasOIDC := cfg.OIDCIssuer != "" && cfg.OIDCClientID != ""
|
||||||
|
hasStatic := cfg.APIToken != "" || cfg.MCPBearerToken != ""
|
||||||
|
|
||||||
|
// Cache JWKS for OIDC
|
||||||
|
var jwksURL string
|
||||||
|
var jwksCache []jwtVerificationKey
|
||||||
|
var jwksMu sync.RWMutex
|
||||||
|
if hasOIDC {
|
||||||
|
// Fetch JWKS URI from OIDC discovery
|
||||||
|
jwksURL = discoverJWKSURI(cfg.OIDCIssuer)
|
||||||
|
if jwksURL != "" {
|
||||||
|
keys, err := fetchJWKS(jwksURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("oidc initial jwks fetch failed, will retry on demand", "error", err)
|
||||||
|
} else {
|
||||||
|
jwksCache = keys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var staticTokens [][]byte
|
||||||
|
if cfg.APIToken != "" {
|
||||||
|
staticTokens = append(staticTokens, []byte(cfg.APIToken))
|
||||||
|
}
|
||||||
|
if cfg.MCPBearerToken != "" {
|
||||||
|
staticTokens = append(staticTokens, []byte(cfg.MCPBearerToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
devOpen := cfg.APIEnv == "dev" && !hasStatic && !hasOIDC
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if devOpen {
|
||||||
|
ctx := context.WithValue(r.Context(), actorKey, actor{
|
||||||
|
Type: "system",
|
||||||
|
Label: "dev:anonymous",
|
||||||
|
ID: "dev",
|
||||||
|
TokenType: "none",
|
||||||
|
})
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
raw, ok := strings.CutPrefix(auth, "Bearer ")
|
||||||
|
if !ok || raw == "" {
|
||||||
|
writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
|
||||||
|
"missing bearer token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try OIDC first if configured
|
||||||
|
if hasOIDC {
|
||||||
|
jwksMu.RLock()
|
||||||
|
keys := jwksCache
|
||||||
|
jwksMu.RUnlock()
|
||||||
|
|
||||||
|
// If cache is empty, try to refresh
|
||||||
|
if len(keys) == 0 && jwksURL != "" {
|
||||||
|
if freshKeys, err := fetchJWKS(jwksURL); err == nil {
|
||||||
|
jwksMu.Lock()
|
||||||
|
jwksCache = freshKeys
|
||||||
|
keys = freshKeys
|
||||||
|
jwksMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if act, err := validateOIDCToken(raw, cfg, keys); err == nil {
|
||||||
|
ctx := context.WithValue(r.Context(), actorKey, act)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
slog.Debug("oidc validation failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to static tokens
|
||||||
|
if hasStatic {
|
||||||
|
for _, t := range staticTokens {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(raw), t) == 1 {
|
||||||
|
label := "operator:api"
|
||||||
|
if cfg.MCPBearerToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.MCPBearerToken)) == 1 {
|
||||||
|
label = "agent:mcp"
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), actorKey, actor{
|
||||||
|
Type: label[:strings.IndexByte(label, ':')],
|
||||||
|
Label: label,
|
||||||
|
ID: raw[:8] + "...",
|
||||||
|
TokenType: "static",
|
||||||
|
})
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
|
||||||
|
"invalid or expired bearer token")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jwtVerificationKey holds a parsed RSA public key or HMAC secret for JWT
|
||||||
|
// verification, identified by its key ID (kid).
|
||||||
|
type jwtVerificationKey struct {
|
||||||
|
Kid string
|
||||||
|
Alg string
|
||||||
|
Key any // *rsa.PublicKey or []byte for HMAC
|
||||||
|
IsHMAC bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverJWKSURI fetches the OIDC discovery document and extracts the
|
||||||
|
// jwks_uri field.
|
||||||
|
func discoverJWKSURI(issuerURL string) string {
|
||||||
|
discURL := strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
|
||||||
|
}}
|
||||||
|
resp, err := client.Get(discURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("oidc discovery failed", "url", discURL, "error", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var disc struct {
|
||||||
|
JWKSURI string `json:"jwks_uri"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&disc); err != nil {
|
||||||
|
slog.Warn("oidc discovery decode failed", "error", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if disc.JWKSURI == "" {
|
||||||
|
slog.Warn("oidc discovery missing jwks_uri")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return disc.JWKSURI
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchJWKS retrieves the JWK set from a URL and returns the parsed keys.
|
||||||
|
func fetchJWKS(jwksURL string) ([]jwtVerificationKey, error) {
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
|
||||||
|
}}
|
||||||
|
resp, err := client.Get(jwksURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch jwks: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var jwks struct {
|
||||||
|
Keys []json.RawMessage `json:"keys"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode jwks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys []jwtVerificationKey
|
||||||
|
for _, raw := range jwks.Keys {
|
||||||
|
var header struct {
|
||||||
|
Kty string `json:"kty"`
|
||||||
|
Kid string `json:"kid,omitempty"`
|
||||||
|
Alg string `json:"alg,omitempty"`
|
||||||
|
Use string `json:"use,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &header); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip keys not intended for signature verification
|
||||||
|
if header.Use != "" && header.Use != "sig" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var key jwtVerificationKey
|
||||||
|
key.Kid = header.Kid
|
||||||
|
key.Alg = header.Alg
|
||||||
|
|
||||||
|
switch header.Kty {
|
||||||
|
case "RSA":
|
||||||
|
var rsaKey struct {
|
||||||
|
N string `json:"n"`
|
||||||
|
E string `json:"e"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &rsaKey); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pubKey, err := parseRSAPublicKey(rsaKey.N, rsaKey.E)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("oidc parse rsa key failed", "kid", header.Kid, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key.Key = pubKey
|
||||||
|
case "oct":
|
||||||
|
// HMAC keys not expected for OIDC but handle gracefully
|
||||||
|
key.IsHMAC = true
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.Key != nil || key.IsHMAC {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return nil, fmt.Errorf("no usable keys in jwks")
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRSAPublicKey decodes a base64url-encoded RSA modulus and exponent into
|
||||||
|
// an *rsa.PublicKey.
|
||||||
|
func parseRSAPublicKey(nB64, eB64 string) (any, error) {
|
||||||
|
// Decode base64url modulus
|
||||||
|
nBytes, err := base64.RawURLEncoding.DecodeString(nB64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode modulus: %w", err)
|
||||||
|
}
|
||||||
|
// Decode base64url exponent
|
||||||
|
eBytes, err := base64.RawURLEncoding.DecodeString(eB64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode exponent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build RSA public key
|
||||||
|
n := new(big.Int).SetBytes(nBytes)
|
||||||
|
e := 0
|
||||||
|
for _, b := range eBytes {
|
||||||
|
e = (e << 8) | int(b)
|
||||||
|
}
|
||||||
|
return &rsa.PublicKey{N: n, E: e}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateOIDCToken parses and validates a JWT Bearer token against the OIDC
|
||||||
|
// configuration. Returns the resolved actor on success.
|
||||||
|
func validateOIDCToken(rawToken string, cfg config.Config, keys []jwtVerificationKey) (actor, error) {
|
||||||
|
keyFunc := func(token *jwt.Token) (any, error) {
|
||||||
|
kid, ok := token.Header["kid"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no kid in token header")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find matching key
|
||||||
|
for _, k := range keys {
|
||||||
|
// If kid is empty in JWK, try algorithm match
|
||||||
|
if k.Kid == kid || (k.Kid == "" && k.Alg == token.Header["alg"]) {
|
||||||
|
return k.Key, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to any RSA key if no kid match (some providers don't set kid)
|
||||||
|
if !ok {
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.Key != nil {
|
||||||
|
return k.Key, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no matching key for kid: %s", kid)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jwt.Parse(rawToken, keyFunc,
|
||||||
|
jwt.WithIssuer(cfg.OIDCIssuer),
|
||||||
|
jwt.WithAudience(cfg.OIDCClientID),
|
||||||
|
jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return actor{}, fmt.Errorf("jwt validation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return actor{}, fmt.Errorf("invalid claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, _ := claims.GetSubject()
|
||||||
|
if sub == "" {
|
||||||
|
// Try the Azure/Entra ID oid claim fallback
|
||||||
|
if oid, ok := claims["oid"].(string); ok {
|
||||||
|
sub = oid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
preferredUsername, _ := claims["preferred_username"].(string)
|
||||||
|
email, _ := claims["email"].(string)
|
||||||
|
|
||||||
|
label := sub
|
||||||
|
if preferredUsername != "" {
|
||||||
|
label = preferredUsername
|
||||||
|
} else if email != "" {
|
||||||
|
label = email
|
||||||
|
}
|
||||||
|
|
||||||
|
return actor{
|
||||||
|
Type: "operator",
|
||||||
|
Label: label,
|
||||||
|
ID: sub,
|
||||||
|
TokenType: "oidc",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActor extracts the actor identity from the context. Returns nil if not
|
||||||
|
// set (should not happen for authenticated routes).
|
||||||
|
func GetActor(ctx context.Context) *actor {
|
||||||
|
a, ok := ctx.Value(actorKey).(actor)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &a
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestLogger logs one line per request with method, path, status,
|
||||||
|
// duration, and the chi request id.
|
||||||
|
func requestLogger(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||||
|
next.ServeHTTP(ww, r)
|
||||||
|
slog.Info("http",
|
||||||
|
"method", r.Method,
|
||||||
|
"path", r.URL.Path,
|
||||||
|
"status", ww.Status(),
|
||||||
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
|
"request_id", middleware.GetReqID(r.Context()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
|
||||||
|
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
|
||||||
|
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.APIListen,
|
||||||
|
Handler: NewHandler(ctx, pool, cfg),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
slog.Info("api listening", "addr", cfg.APIListen)
|
||||||
|
errCh <- srv.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Info("api shutting down")
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return srv.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
339
internal/httpapi/sse.go
Normal file
339
internal/httpapi/sse.go
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/list"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSE broker is a keep-last-event-id in-memory buffer used for subscriber
|
||||||
|
// fan-out. The database NOTIFY is the primary delivery mechanism; this buffer
|
||||||
|
// just supports the Last-Event-ID replay on connect.
|
||||||
|
type sseBroker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf *list.List // list of sqlcgen.Event
|
||||||
|
cache map[int64]*list.Element // id → list element for O(1) lookup
|
||||||
|
cap int
|
||||||
|
lastID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSSEBroker(capacity int) *sseBroker {
|
||||||
|
return &sseBroker{
|
||||||
|
buf: list.New(),
|
||||||
|
cache: make(map[int64]*list.Element),
|
||||||
|
cap: capacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *sseBroker) push(ev sqlcgen.Event) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
// Evict oldest if at capacity
|
||||||
|
for b.buf.Len() >= b.cap && b.buf.Len() > 0 {
|
||||||
|
front := b.buf.Front()
|
||||||
|
b.cache[front.Value.(sqlcgen.Event).ID] = nil // don't delete, just nil
|
||||||
|
b.buf.Remove(front)
|
||||||
|
}
|
||||||
|
|
||||||
|
elem := b.buf.PushBack(ev)
|
||||||
|
b.cache[ev.ID] = elem
|
||||||
|
if ev.ID > b.lastID {
|
||||||
|
b.lastID = ev.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *sseBroker) after(id int64) []sqlcgen.Event {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
if id >= b.lastID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk from the front to find the first element after id
|
||||||
|
var events []sqlcgen.Event
|
||||||
|
for e := b.buf.Front(); e != nil; e = e.Next() {
|
||||||
|
ev := e.Value.(sqlcgen.Event)
|
||||||
|
if ev.ID > id {
|
||||||
|
events = append(events, ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *sseBroker) latestID() int64 {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.lastID
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyPayload is the JSON payload from the pg_notify trigger (migration 008).
|
||||||
|
type notifyPayload struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Ts string `json:"ts"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
EntityID *string `json:"entity_id"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
CorrelationID *string `json:"correlation_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// sseSubscriber holds the channels and cancel func for one SSE client.
|
||||||
|
type sseSubscriber struct {
|
||||||
|
ch chan sqlcgen.Event
|
||||||
|
done chan struct{}
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// sseListener runs in a background goroutine: it opens a dedicated pgx
|
||||||
|
// connection, LISTENs on oikos_events, and fans out each notification to
|
||||||
|
// all live subscribers. Runs until ctx is cancelled.
|
||||||
|
func (s *Server) sseListener(ctx context.Context) {
|
||||||
|
poolConn, err := s.pool.Acquire(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("sse listener acquire failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer poolConn.Release()
|
||||||
|
|
||||||
|
conn := poolConn.Conn()
|
||||||
|
if _, err := conn.Exec(ctx, "LISTEN oikos_events"); err != nil {
|
||||||
|
slog.Error("sse listener listen failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("sse listener started on oikos_events")
|
||||||
|
defer slog.Info("sse listener stopped")
|
||||||
|
|
||||||
|
for {
|
||||||
|
nt, err := conn.WaitForNotification(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return // normal shutdown
|
||||||
|
}
|
||||||
|
slog.Error("sse listener notification error", "error", err)
|
||||||
|
// Reconnect on error after a brief delay
|
||||||
|
select {
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Re-acquire connection
|
||||||
|
poolConn.Release()
|
||||||
|
var reconnErr error
|
||||||
|
poolConn, reconnErr = s.pool.Acquire(ctx)
|
||||||
|
if reconnErr != nil {
|
||||||
|
slog.Error("sse listener reconnect failed", "error", reconnErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn = poolConn.Conn()
|
||||||
|
if _, err := conn.Exec(ctx, "LISTEN oikos_events"); err != nil {
|
||||||
|
slog.Error("sse listener re-listen failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var p notifyPayload
|
||||||
|
if err := json.Unmarshal([]byte(nt.Payload), &p); err != nil {
|
||||||
|
slog.Error("sse listener unmarshal failed", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch full event from DB
|
||||||
|
q := sqlcgen.New(s.pool)
|
||||||
|
events, err := q.ListEventsAfter(ctx, sqlcgen.ListEventsAfterParams{
|
||||||
|
ID: p.ID - 1,
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
if err != nil || len(events) == 0 {
|
||||||
|
slog.Warn("sse listener event fetch failed", "id", p.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ev := events[0]
|
||||||
|
|
||||||
|
// Push to broker
|
||||||
|
s.sseBroker.push(ev)
|
||||||
|
|
||||||
|
// Fan out to subscribers (non-blocking send)
|
||||||
|
s.sseMu.Lock()
|
||||||
|
for sub := range s.sseSubs {
|
||||||
|
select {
|
||||||
|
case sub.ch <- ev:
|
||||||
|
default:
|
||||||
|
// Subscriber too slow — drop event for them
|
||||||
|
// (they'll reconnect via Last-Event-ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.sseMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sqlcEventToGen converts a DB event row to the canonical wire shape so the
|
||||||
|
// SSE `data:` payload matches GET /events (snake_case keys, decoded data
|
||||||
|
// object) rather than leaking Go field names and base64-encoded JSONB.
|
||||||
|
func sqlcEventToGen(ev sqlcgen.Event) gen.Event {
|
||||||
|
out := gen.Event{
|
||||||
|
Id: int(ev.ID),
|
||||||
|
Ts: ev.Ts,
|
||||||
|
Type: ev.Type,
|
||||||
|
Severity: gen.EventSeverity(ev.Severity),
|
||||||
|
Source: ev.Source,
|
||||||
|
CorrelationId: ev.CorrelationID,
|
||||||
|
}
|
||||||
|
if ev.EntityID != nil {
|
||||||
|
s := ev.EntityID.String()
|
||||||
|
out.EntityId = &s
|
||||||
|
}
|
||||||
|
if len(ev.Data) > 0 {
|
||||||
|
var data map[string]any
|
||||||
|
if json.Unmarshal(ev.Data, &data) == nil && len(data) > 0 {
|
||||||
|
out.Data = &data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSSE writes a single Event as an SSE message. Returns false if the
|
||||||
|
// write failed (client disconnected). flusher may be nil (io.Pipe path,
|
||||||
|
// which has no separate flush step).
|
||||||
|
func writeSSE(w ioWriter, flusher http.Flusher, ev sqlcgen.Event) bool {
|
||||||
|
data, err := json.Marshal(sqlcEventToGen(ev))
|
||||||
|
if err != nil {
|
||||||
|
return true // skip un-serializable events
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Type, data)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ioWriter is an interface satisfied by both http.ResponseWriter and
|
||||||
|
// io.StringWriter, letting writeSSE work with the writer directly.
|
||||||
|
type ioWriter interface {
|
||||||
|
Write([]byte) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveSSE is the streaming handler for GET /api/v1/events/stream, registered
|
||||||
|
// directly on the chi router in NewHandler so it supersedes the generated
|
||||||
|
// route. Using the raw http.ResponseWriter lets us Flush() after every event
|
||||||
|
// (real-time delivery); the generated strict-server path can only hand back
|
||||||
|
// an io.Reader that io.Copy drains without flushing (chunk-buffered).
|
||||||
|
//
|
||||||
|
// Protocol: https://html.spec.whatwg.org/multipage/server-sent-events.html
|
||||||
|
// 1. If Last-Event-ID is present, replay buffered events (broker, then DB).
|
||||||
|
// 2. Subscribe and forward events fanned out from pg_notify.
|
||||||
|
// 3. Colon-comment heartbeat every 15s.
|
||||||
|
// 4. Unsubscribe on client disconnect (request context cancels).
|
||||||
|
func (s *Server) serveSSE(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
writeProblem(w, r, http.StatusInternalServerError, "internal error", "streaming not supported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(r.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sub := &sseSubscriber{
|
||||||
|
ch: make(chan sqlcgen.Event, 64),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
s.sseMu.Lock()
|
||||||
|
s.sseSubs[sub] = struct{}{}
|
||||||
|
s.sseMu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
s.sseMu.Lock()
|
||||||
|
delete(s.sseSubs, sub)
|
||||||
|
s.sseMu.Unlock()
|
||||||
|
close(sub.done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// ── 1. Replay on Last-Event-ID ────────────────────────────────
|
||||||
|
if lastID := r.Header.Get("Last-Event-ID"); lastID != "" {
|
||||||
|
if id, err := strconv.ParseInt(lastID, 10, 64); err == nil {
|
||||||
|
// The in-memory broker only holds recent events; if it doesn't
|
||||||
|
// cover the whole gap, fall back to the DB for a complete replay.
|
||||||
|
events := s.sseBroker.after(id)
|
||||||
|
complete := len(events) > 0 && events[len(events)-1].ID == s.sseBroker.latestID()
|
||||||
|
if complete {
|
||||||
|
for _, ev := range events {
|
||||||
|
if !writeSSE(w, flusher, ev) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
q := sqlcgen.New(s.pool)
|
||||||
|
dbEvents, err := q.ListEventsAfter(ctx, sqlcgen.ListEventsAfterParams{ID: id, Limit: 5000})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("sse db replay failed", "error", err)
|
||||||
|
} else {
|
||||||
|
for _, ev := range dbEvents {
|
||||||
|
if !writeSSE(w, flusher, ev) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Subscribe and forward ──────────────────────────────────
|
||||||
|
heartbeat := time.NewTicker(15 * time.Second)
|
||||||
|
defer heartbeat.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case ev, ok := <-sub.ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !writeSSE(w, flusher, ev) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-heartbeat.C:
|
||||||
|
if _, err := fmt.Fprintf(w, ": heartbeat\n\n"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamEvents satisfies the generated ServerInterface, but the SSE route is
|
||||||
|
// served by the raw serveSSE handler registered in NewHandler (which wins
|
||||||
|
// over this generated route). If this method is ever reached, routing has
|
||||||
|
// regressed — fail loudly rather than silently chunk-buffering.
|
||||||
|
func (s *Server) StreamEvents(ctx context.Context, req gen.StreamEventsRequestObject) (gen.StreamEventsResponseObject, error) {
|
||||||
|
return nil, fmt.Errorf("%w: SSE must be served by the raw handler", errNotImplemented)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure pgxpool is imported — used via Acquire.
|
||||||
|
var _ = &pgxpool.Pool{}
|
||||||
|
var _ = pgx.ErrNoRows
|
||||||
89
internal/httpapi/sse_test.go
Normal file
89
internal/httpapi/sse_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
// Real-connection SSE test. httptest.NewRecorder buffers and never flushes,
|
||||||
|
// so this uses httptest.NewServer + a streaming client to verify that:
|
||||||
|
// - the raw serveSSE handler (not the generated 501 stub) serves the route,
|
||||||
|
// - an event created *after* the client connects is delivered in real time
|
||||||
|
// (i.e. flushed before the connection closes),
|
||||||
|
// - the SSE `data:` payload is the canonical gen.Event shape.
|
||||||
|
// Guarded by OIKOS_TEST_DATABASE_URL.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSSEStreamRealtimeDelivery(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(newTestHandler(t, devConfig()))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Connect to the stream.
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL+"/api/v1/events/stream", nil)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect stream: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("stream status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
||||||
|
t.Fatalf("content-type = %q, want text/event-stream", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read SSE frames in a goroutine.
|
||||||
|
dataCh := make(chan map[string]any, 4)
|
||||||
|
go func() {
|
||||||
|
sc := bufio.NewScanner(resp.Body)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
if strings.HasPrefix(line, "data: ") {
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &m) == nil {
|
||||||
|
dataCh <- m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Give the subscriber a moment to register, then trigger an event by
|
||||||
|
// POSTing to the SAME live server (same DB → NOTIFY the listener sees).
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
payload, _ := json.Marshal(map[string]any{"slug": "service:sse-rt", "type": "service", "name": "sse-rt"})
|
||||||
|
cResp, err := http.Post(srv.URL+"/api/v1/entities", "application/json", bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("trigger create: %v", err)
|
||||||
|
}
|
||||||
|
cResp.Body.Close()
|
||||||
|
if cResp.StatusCode != 201 {
|
||||||
|
t.Fatalf("trigger create status = %d, want 201", cResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The event must arrive in real time (well before the 10s ctx deadline),
|
||||||
|
// proving the handler flushes rather than buffering until close.
|
||||||
|
select {
|
||||||
|
case ev := <-dataCh:
|
||||||
|
if ev["type"] != "entity.created" {
|
||||||
|
t.Errorf("event type = %v, want entity.created", ev["type"])
|
||||||
|
}
|
||||||
|
// canonical shape: snake_case + decoded data object
|
||||||
|
if _, ok := ev["entity_id"]; !ok {
|
||||||
|
t.Errorf("missing snake_case entity_id: %v", ev)
|
||||||
|
}
|
||||||
|
if d, ok := ev["data"].(map[string]any); !ok || d["slug"] != "service:sse-rt" {
|
||||||
|
t.Errorf("data not a decoded object with slug: %v", ev["data"])
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("SSE event not delivered within 3s (flushing broken?)")
|
||||||
|
}
|
||||||
|
}
|
||||||
6
internal/httpapi/stubs.go
Normal file
6
internal/httpapi/stubs.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
// Remaining stubs for endpoints that depend on tables not yet created
|
||||||
|
// (knowledge_entities, agent_activity). These are kept here because the
|
||||||
|
// phase3.go file already defines them; this file is deliberately empty.
|
||||||
|
// The stubs live in phase3.go as simple errNotImplemented returns.
|
||||||
169
internal/learning/learning.go
Normal file
169
internal/learning/learning.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
// Package learning implements the Oikos learning engine (Phase 3).
|
||||||
|
// Hourly pattern extraction: reads feedback past the watermark, groups by
|
||||||
|
// (applies_type, action), updates pattern counters with Wilson confidence,
|
||||||
|
// detects anomalies, and refines skills.
|
||||||
|
package learning
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/config"
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run starts the learning loop. Blocks until ctx is cancelled.
|
||||||
|
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
||||||
|
slog.Info("learning: starting", "interval", cfg.LearningInterval)
|
||||||
|
interval := cfg.LearningInterval
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 1 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
watermark := time.Now().Add(-24 * time.Hour) // start from 24h ago
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Info("learning: shutting down")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
watermark = extractPatterns(ctx, pool, watermark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractPatterns reads feedback past the watermark, groups by (type, action),
|
||||||
|
// updates pattern counters, and returns the new watermark.
|
||||||
|
func extractPatterns(ctx context.Context, pool *db.Pool, watermark time.Time) time.Time {
|
||||||
|
q := sqlcgen.New(pool)
|
||||||
|
|
||||||
|
feedback, err := q.GetFeedbackAfterWatermark(ctx, watermark)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("learning: get feedback", "error", err)
|
||||||
|
return watermark
|
||||||
|
}
|
||||||
|
if len(feedback) == 0 {
|
||||||
|
// Advance watermark to now so we don't re-scan
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by (applies_type, action)
|
||||||
|
type groupKey struct {
|
||||||
|
Type string
|
||||||
|
Action string
|
||||||
|
}
|
||||||
|
groups := make(map[groupKey][]sqlcgen.GetFeedbackAfterWatermarkRow)
|
||||||
|
for _, f := range feedback {
|
||||||
|
key := groupKey{Type: f.AppliesType, Action: f.Action}
|
||||||
|
groups[key] = append(groups[key], f)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, items := range groups {
|
||||||
|
processGroup(ctx, pool, q, key.Type, key.Action, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update watermark to the latest feedback timestamp
|
||||||
|
newWatermark := watermark
|
||||||
|
for _, f := range feedback {
|
||||||
|
if f.CreatedAt.After(newWatermark) {
|
||||||
|
newWatermark = f.CreatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newWatermark
|
||||||
|
}
|
||||||
|
|
||||||
|
func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
|
||||||
|
appliesType, action string, items []sqlcgen.GetFeedbackAfterWatermarkRow) {
|
||||||
|
|
||||||
|
successCount := 0
|
||||||
|
failureCount := 0
|
||||||
|
for _, f := range items {
|
||||||
|
switch f.Outcome {
|
||||||
|
case "success":
|
||||||
|
successCount++
|
||||||
|
case "failure", "unexpected":
|
||||||
|
failureCount++
|
||||||
|
case "partial":
|
||||||
|
successCount++ // partial counts as half-success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total := successCount + failureCount
|
||||||
|
if total == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute Wilson score lower bound
|
||||||
|
confidence := wilsonLowerBound(float64(successCount), float64(total), 0.95)
|
||||||
|
// Cap by sample size: nothing looks confident before 5 samples
|
||||||
|
confidence = math.Min(confidence, float64(total)/5.0)
|
||||||
|
|
||||||
|
// Get or create pattern
|
||||||
|
patternID, _ := uuid.NewV7()
|
||||||
|
patternSummary := action + " on " + appliesType
|
||||||
|
|
||||||
|
err := q.UpsertPattern(ctx, sqlcgen.UpsertPatternParams{
|
||||||
|
EntityID: patternID,
|
||||||
|
AppliesType: appliesType,
|
||||||
|
Action: action,
|
||||||
|
Pattern: patternSummary,
|
||||||
|
Confidence: float32(confidence),
|
||||||
|
EvidenceCount: int32(total),
|
||||||
|
SuccessCount: int32(successCount),
|
||||||
|
FailureCount: int32(failureCount),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("learning: upsert pattern", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update pattern status based on confidence
|
||||||
|
pat, err := q.GetPattern(ctx, sqlcgen.GetPatternParams{
|
||||||
|
AppliesType: appliesType,
|
||||||
|
Action: action,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if pat.EvidenceCount >= 5 && pat.Confidence >= 0.7 && !pat.Quarantined {
|
||||||
|
_ = q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{
|
||||||
|
EntityID: pat.EntityID,
|
||||||
|
Status: "validated",
|
||||||
|
})
|
||||||
|
slog.Info("learning: pattern validated",
|
||||||
|
"type", appliesType, "action", action,
|
||||||
|
"confidence", confidence, "samples", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anomaly check: >10 identical outcomes within 1h
|
||||||
|
if total > 10 {
|
||||||
|
_ = q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{
|
||||||
|
EntityID: pat.EntityID,
|
||||||
|
Quarantined: true,
|
||||||
|
})
|
||||||
|
slog.Warn("learning: pattern quarantined (anomaly burst)",
|
||||||
|
"type", appliesType, "action", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wilsonLowerBound computes the Wilson score interval lower bound.
|
||||||
|
// Conservative estimate of success rate for small sample sizes.
|
||||||
|
func wilsonLowerBound(success, total, z float64) float64 {
|
||||||
|
if total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
p := success / total
|
||||||
|
z2 := z * z
|
||||||
|
denom := 1 + z2/total
|
||||||
|
center := (p + z2/(2*total)) / denom
|
||||||
|
sp := math.Sqrt((p*(1-p) + z2/(4*total)) / total) / denom
|
||||||
|
return math.Max(0, center-z*sp)
|
||||||
|
}
|
||||||
250
internal/mcp/server.go
Normal file
250
internal/mcp/server.go
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
// Package mcp implements the Oikos MCP interface (plan R3-10).
|
||||||
|
// Uses the official MCP Go SDK with Streamable HTTP transport.
|
||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/google/jsonschema-go/jsonschema"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// prop is one input-schema property (name → type + description).
|
||||||
|
type prop struct {
|
||||||
|
name, typ, desc string
|
||||||
|
}
|
||||||
|
|
||||||
|
// objSchema builds an "object" JSON Schema from a list of properties. The
|
||||||
|
// MCP SDK requires every tool to declare an object input schema so tools
|
||||||
|
// are self-describing to the agent; a nil schema panics at registration.
|
||||||
|
func objSchema(props ...prop) *jsonschema.Schema {
|
||||||
|
s := &jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{}}
|
||||||
|
for _, p := range props {
|
||||||
|
s.Properties[p.name] = &jsonschema.Schema{Type: p.typ, Description: p.desc}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates an http.Handler that serves the Oikos MCP server.
|
||||||
|
func NewHandler(pool *db.Pool, token string) http.Handler {
|
||||||
|
s := newServer(pool)
|
||||||
|
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
||||||
|
if token != "" {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer "+token {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}, nil)
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServer(pool *db.Pool) *mcp.Server {
|
||||||
|
s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
|
||||||
|
Logger: slog.Default(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// All tools use the untyped handler (s.AddTool) for simplicity.
|
||||||
|
// Arguments are accessed via req.Parameters.Arguments.(map[string]any).
|
||||||
|
|
||||||
|
s.AddTool(&mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID",
|
||||||
|
InputSchema: objSchema(prop{"slug_or_id", "string", "Entity slug (e.g. host:hubris) or UUID"}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
idOrSlug, _ := args["slug_or_id"].(string)
|
||||||
|
return queryEntity(ctx, pool, idOrSlug), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
s.AddTool(&mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"type", "string", "Filter by entity type"},
|
||||||
|
prop{"state", "string", "Filter by lifecycle state"},
|
||||||
|
prop{"q", "string", "Substring match on slug or name"},
|
||||||
|
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
limit := int(getFloat(args, "limit", 50))
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at
|
||||||
|
FROM entities e
|
||||||
|
WHERE ($1::text IS NULL OR e.type = $1)
|
||||||
|
AND ($2::text IS NULL OR e.state = $2)
|
||||||
|
AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%')
|
||||||
|
ORDER BY e.slug LIMIT $4`,
|
||||||
|
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
s.AddTool(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
|
||||||
|
InputSchema: objSchema(prop{"entity_id", "string", "Entity slug"}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["entity_id"].(string)
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT r.type, src.slug AS source, tgt.slug AS target
|
||||||
|
FROM relationships r
|
||||||
|
JOIN entities src ON src.id = r.source_id
|
||||||
|
JOIN entities tgt ON tgt.id = r.target_id
|
||||||
|
WHERE (src.slug = $1 OR tgt.slug = $1) AND r.valid_to IS NULL
|
||||||
|
ORDER BY r.type`, slug), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
s.AddTool(&mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"entity_id", "string", "Entity slug"},
|
||||||
|
prop{"depth", "integer", "Traversal depth (default 3)"}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["entity_id"].(string)
|
||||||
|
depth := int(getFloat(args, "depth", 3))
|
||||||
|
return queryRows(ctx, pool,
|
||||||
|
"SELECT slug, CAST(depth AS int) FROM blast_radius((SELECT id FROM entities WHERE slug = $1), $2)",
|
||||||
|
slug, depth), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
s.AddTool(&mcp.Tool{Name: "get_health_summary", Description: "Current fleet health summary",
|
||||||
|
InputSchema: objSchema(),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
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`), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
s.AddTool(&mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log",
|
||||||
|
InputSchema: objSchema(prop{"entity_id", "string", "Filter by affected entity UUID"}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id
|
||||||
|
FROM audit_log
|
||||||
|
WHERE ($1::text IS NULL OR entity_id::text = $1)
|
||||||
|
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
s.AddTool(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation",
|
||||||
|
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
q, _ := args["query"].(string)
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT id, title, LEFT(content, 500) AS preview
|
||||||
|
FROM knowledge_entities
|
||||||
|
WHERE title ILIKE '%'||$1||'%' OR content ILIKE '%'||$1||'%'
|
||||||
|
ORDER BY title LIMIT 20`, q), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
s.AddTool(&mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
|
||||||
|
InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
hours := int(getFloat(args, "hours", 24))
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT time_bucket('1 hour', ts) AS bucket,
|
||||||
|
entity_id::text, metric,
|
||||||
|
ROUND(avg(value)::numeric, 2) AS avg,
|
||||||
|
ROUND(min(value)::numeric, 2) AS min,
|
||||||
|
ROUND(max(value)::numeric, 2) AS max
|
||||||
|
FROM metric_samples
|
||||||
|
WHERE ts > now() - make_interval(hours => $1)
|
||||||
|
GROUP BY bucket, entity_id, metric
|
||||||
|
ORDER BY bucket DESC LIMIT 100`, hours), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func argsMap(req *mcp.CallToolRequest) map[string]any {
|
||||||
|
if req == nil || len(req.Params.Arguments) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
json.Unmarshal(req.Params.Arguments, &m)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFloat(m map[string]any, key string, def float64) float64 {
|
||||||
|
if m == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
switch v := m[key].(type) {
|
||||||
|
case float64:
|
||||||
|
return v
|
||||||
|
case int:
|
||||||
|
return float64(v)
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func nStr(v any) any {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s, _ := v.(string)
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func textResult(s string) *mcp.CallToolResult {
|
||||||
|
return &mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{&mcp.TextContent{Text: s}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryEntity(ctx context.Context, pool *db.Pool, idOrSlug string) *mcp.CallToolResult {
|
||||||
|
var id uuid.UUID
|
||||||
|
if u, err := uuid.Parse(idOrSlug); err == nil {
|
||||||
|
id = u
|
||||||
|
} else {
|
||||||
|
pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", idOrSlug).Scan(&id)
|
||||||
|
}
|
||||||
|
if id == uuid.Nil {
|
||||||
|
return textResult(fmt.Sprintf("entity not found: %s", idOrSlug))
|
||||||
|
}
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT slug, type, name, state, attributes,
|
||||||
|
maintenance_until::text, version, created_at, updated_at
|
||||||
|
FROM entities WHERE id = $1`, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryRows(ctx context.Context, pool *db.Pool, query string, args ...any) *mcp.CallToolResult {
|
||||||
|
rows, err := pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error: %v", err))
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
cols := rows.FieldDescriptions()
|
||||||
|
var items []map[string]any
|
||||||
|
for rows.Next() {
|
||||||
|
vals, err := rows.Values()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := make(map[string]any)
|
||||||
|
for i, col := range cols {
|
||||||
|
m[string(col.Name)] = fmt.Sprintf("%v", vals[i])
|
||||||
|
}
|
||||||
|
items = append(items, m)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.MarshalIndent(items, "", " ")
|
||||||
|
return textResult(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = pgx.ErrNoRows
|
||||||
|
var _ = time.Now
|
||||||
40
internal/mcp/server_test.go
Normal file
40
internal/mcp/server_test.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewServerRegistersTools verifies every tool registers with a valid
|
||||||
|
// input schema. The MCP SDK panics at AddTool if a tool omits its object
|
||||||
|
// input schema, so merely constructing the server exercises that contract —
|
||||||
|
// this test would have caught the "missing input schema" panic.
|
||||||
|
func TestNewServerRegistersTools(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("newServer panicked (tool schema bug?): %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// pool is only used inside tool handlers (invoked per-call), not at
|
||||||
|
// registration time, so a nil pool is safe for this construction test.
|
||||||
|
s := newServer(nil)
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("newServer returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObjSchema(t *testing.T) {
|
||||||
|
s := objSchema(prop{"foo", "string", "a foo"}, prop{"n", "integer", "a number"})
|
||||||
|
if s.Type != "object" {
|
||||||
|
t.Errorf("schema type = %q, want object", s.Type)
|
||||||
|
}
|
||||||
|
if len(s.Properties) != 2 {
|
||||||
|
t.Fatalf("got %d properties, want 2", len(s.Properties))
|
||||||
|
}
|
||||||
|
if s.Properties["foo"].Type != "string" || s.Properties["n"].Type != "integer" {
|
||||||
|
t.Errorf("property types wrong: %+v", s.Properties)
|
||||||
|
}
|
||||||
|
// empty schema still valid (object with no properties)
|
||||||
|
if objSchema().Type != "object" {
|
||||||
|
t.Error("empty objSchema not an object")
|
||||||
|
}
|
||||||
|
}
|
||||||
116
internal/notifier/notifier.go
Normal file
116
internal/notifier/notifier.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// Package notifier handles alerts and approval requests via Matrix.
|
||||||
|
// Uses the DB as the rendezvous — no service-to-service calls (SA7/A7).
|
||||||
|
// Pending approvals survive restarts of either side.
|
||||||
|
package notifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/config"
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run starts the notifier loop. Blocks until ctx is cancelled.
|
||||||
|
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
||||||
|
slog.Info("notifier: starting")
|
||||||
|
interval := 15 * time.Second
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Info("notifier: shutting down")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
processPendingApprovals(ctx, pool, cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processPendingApprovals checks for pending approvals and sends alerts.
|
||||||
|
func processPendingApprovals(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
||||||
|
q := sqlcgen.New(pool)
|
||||||
|
|
||||||
|
status := "pending"
|
||||||
|
approvals, err := q.ListApprovals(ctx, sqlcgen.ListApprovalsParams{
|
||||||
|
Status: &status,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("notifier: list approvals", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range approvals {
|
||||||
|
// Check if already expired
|
||||||
|
if a.ExpiresAt.Before(time.Now()) {
|
||||||
|
_ = q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{
|
||||||
|
EntityID: a.EntityID,
|
||||||
|
Status: "expired",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate approval token
|
||||||
|
token := generateApprovalToken(a.EntityID, cfg.ApprovalHMACSecret)
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
|
// Store token hash
|
||||||
|
_, _ = pool.Exec(ctx,
|
||||||
|
"UPDATE approvals SET token_hash = $2 WHERE entity_id = $1",
|
||||||
|
a.EntityID, tokenHash)
|
||||||
|
|
||||||
|
slog.Info("notifier: approval pending",
|
||||||
|
"approval_id", a.EntityID,
|
||||||
|
"action", a.Action,
|
||||||
|
"risk_class", a.RiskClass,
|
||||||
|
"token", token[:16]+"...",
|
||||||
|
"expires_at", a.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateApprovalToken creates a single-use HMAC token for an approval.
|
||||||
|
// Token = HMAC(approval_id ‖ nonce, secret)
|
||||||
|
func generateApprovalToken(approvalID uuid.UUID, secret string) string {
|
||||||
|
if secret == "" {
|
||||||
|
secret = "dev-secret-do-not-use-in-prod"
|
||||||
|
}
|
||||||
|
nonce := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write([]byte(approvalID.String()))
|
||||||
|
mac.Write([]byte(nonce))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyApprovalToken checks that a token matches the stored hash.
|
||||||
|
func VerifyApprovalToken(ctx context.Context, pool *db.Pool, approvalID uuid.UUID, token string) bool {
|
||||||
|
q := sqlcgen.New(pool)
|
||||||
|
a, err := q.GetApprovalByID(ctx, approvalID)
|
||||||
|
if err != nil || a.TokenHash == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.Status != "pending" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.ExpiresAt.Before(time.Now()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return *a.TokenHash == hashToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashToken double-hashes a token for storage.
|
||||||
|
func hashToken(token string) string {
|
||||||
|
h := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure types are used
|
||||||
|
var _ = uuid.UUID{}
|
||||||
23
internal/observability/logging.go
Normal file
23
internal/observability/logging.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewLogger creates a structured JSON logger writing to stdout.
|
||||||
|
// In debug mode, it enables verbose probe payloads, SQL queries, and
|
||||||
|
// classification reasoning.
|
||||||
|
func NewLogger(debug bool) *slog.Logger {
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if debug {
|
||||||
|
level = slog.LevelDebug
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: level,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger := slog.New(handler).With("service", "oikos")
|
||||||
|
return logger
|
||||||
|
}
|
||||||
65
internal/observability/record.go
Normal file
65
internal/observability/record.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Audit writes an audit_log entry. Pass a transaction-bound Queries so the
|
||||||
|
// entry commits or rolls back atomically with the state change it records.
|
||||||
|
//
|
||||||
|
// actorLabel is the interim textual actor identity ("operator:dev",
|
||||||
|
// "agent:mcp") recorded in detail; actor_id (a Person/Agent entity UUID)
|
||||||
|
// starts being populated when OIDC identity resolution lands.
|
||||||
|
func Audit(ctx context.Context, q *sqlcgen.Queries, actorType, actorLabel,
|
||||||
|
action string, entityID *uuid.UUID, method, path, correlationID string,
|
||||||
|
detail map[string]any) error {
|
||||||
|
|
||||||
|
if detail == nil {
|
||||||
|
detail = map[string]any{}
|
||||||
|
}
|
||||||
|
detail["actor"] = actorLabel
|
||||||
|
detailJSON, _ := json.Marshal(detail)
|
||||||
|
|
||||||
|
var corr *string
|
||||||
|
if correlationID != "" {
|
||||||
|
corr = &correlationID
|
||||||
|
}
|
||||||
|
return q.InsertAuditEntry(ctx, sqlcgen.InsertAuditEntryParams{
|
||||||
|
ActorType: actorType,
|
||||||
|
Action: action,
|
||||||
|
EntityID: entityID,
|
||||||
|
Method: &method,
|
||||||
|
Path: &path,
|
||||||
|
Detail: detailJSON,
|
||||||
|
CorrelationID: corr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event emits a structured event in the caller's transaction (SG10). The
|
||||||
|
// post-commit NOTIFY trigger (migration 008) fans it out to SSE subscribers.
|
||||||
|
func Event(ctx context.Context, q *sqlcgen.Queries, eventType string,
|
||||||
|
entityID *uuid.UUID, severity, source string, correlationID string,
|
||||||
|
data map[string]any) error {
|
||||||
|
|
||||||
|
dataJSON, _ := json.Marshal(data)
|
||||||
|
if data == nil {
|
||||||
|
dataJSON = []byte("{}")
|
||||||
|
}
|
||||||
|
var corr *string
|
||||||
|
if correlationID != "" {
|
||||||
|
corr = &correlationID
|
||||||
|
}
|
||||||
|
_, err := q.InsertEvent(ctx, sqlcgen.InsertEventParams{
|
||||||
|
Type: eventType,
|
||||||
|
EntityID: entityID,
|
||||||
|
Severity: severity,
|
||||||
|
Source: source,
|
||||||
|
Data: dataJSON,
|
||||||
|
CorrelationID: corr,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
112
internal/ontology/validate.go
Normal file
112
internal/ontology/validate.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// Package ontology implements the meta-schema logic: the entity-type
|
||||||
|
// hierarchy (is-a with abstract types), relationship endpoint validation,
|
||||||
|
// cardinality enforcement, and lifecycle state checks. Both the seed
|
||||||
|
// ingest and the API mutation paths validate through this package so the
|
||||||
|
// graph can never violate the ontology (plan R3-1).
|
||||||
|
package ontology
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TypeInfo is the subset of an entity type the validator needs.
|
||||||
|
type TypeInfo struct {
|
||||||
|
Parent string
|
||||||
|
IsAbstract bool
|
||||||
|
LifecycleID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelTypeInfo is the subset of a relationship type the validator needs.
|
||||||
|
type RelTypeInfo struct {
|
||||||
|
SourceType string
|
||||||
|
TargetType string
|
||||||
|
Cardinality string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LifecycleInfo is the subset of a lifecycle the validator needs.
|
||||||
|
type LifecycleInfo struct {
|
||||||
|
States map[string]bool
|
||||||
|
DefaultState string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeTree holds the loaded ontology meta-schema for validation.
|
||||||
|
type TypeTree struct {
|
||||||
|
Types map[string]TypeInfo
|
||||||
|
RelTypes map[string]RelTypeInfo
|
||||||
|
Lifecycles map[string]LifecycleInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsA reports whether typ is target or a descendant of it.
|
||||||
|
func (t *TypeTree) IsA(typ, target string) bool {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for cur := typ; cur != ""; cur = t.Types[cur].Parent {
|
||||||
|
if cur == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if seen[cur] {
|
||||||
|
return false // cycle guard — ingest rejects cycles, belt and braces
|
||||||
|
}
|
||||||
|
seen[cur] = true
|
||||||
|
if _, ok := t.Types[cur]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateEntity checks that typ exists, is not abstract, and that state
|
||||||
|
// (if set) is legal for the type's lifecycle.
|
||||||
|
func (t *TypeTree) ValidateEntity(typ, state string) error {
|
||||||
|
info, ok := t.Types[typ]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%w: entity type %q", domain.ErrNotFound, typ)
|
||||||
|
}
|
||||||
|
if info.IsAbstract {
|
||||||
|
return fmt.Errorf("%w: %q", domain.ErrAbstractType, typ)
|
||||||
|
}
|
||||||
|
if state == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.LifecycleID == "" {
|
||||||
|
return fmt.Errorf("%w: type %q has no lifecycle but state %q given",
|
||||||
|
domain.ErrInvalidTransition, typ, state)
|
||||||
|
}
|
||||||
|
lc, ok := t.Lifecycles[info.LifecycleID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%w: lifecycle %q", domain.ErrNotFound, info.LifecycleID)
|
||||||
|
}
|
||||||
|
if !lc.States[state] {
|
||||||
|
return fmt.Errorf("%w: state %q not in lifecycle %q",
|
||||||
|
domain.ErrInvalidTransition, state, info.LifecycleID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateEdge checks that relType exists and that the endpoint entity
|
||||||
|
// types are the declared source/target types or descendants of them.
|
||||||
|
func (t *TypeTree) ValidateEdge(relType, sourceEntityType, targetEntityType string) error {
|
||||||
|
rt, ok := t.RelTypes[relType]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%w: relationship type %q", domain.ErrNotFound, relType)
|
||||||
|
}
|
||||||
|
if !t.IsA(sourceEntityType, rt.SourceType) {
|
||||||
|
return fmt.Errorf("%w: %s source %q is not a %q",
|
||||||
|
domain.ErrInvalidEdge, relType, sourceEntityType, rt.SourceType)
|
||||||
|
}
|
||||||
|
if !t.IsA(targetEntityType, rt.TargetType) {
|
||||||
|
return fmt.Errorf("%w: %s target %q is not a %q",
|
||||||
|
domain.ErrInvalidEdge, relType, targetEntityType, rt.TargetType)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultState returns the default lifecycle state for a type ("" if none).
|
||||||
|
func (t *TypeTree) DefaultState(typ string) string {
|
||||||
|
info, ok := t.Types[typ]
|
||||||
|
if !ok || info.LifecycleID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Lifecycles[info.LifecycleID].DefaultState
|
||||||
|
}
|
||||||
120
internal/ontology/validate_test.go
Normal file
120
internal/ontology/validate_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package ontology
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fixtureTree() *TypeTree {
|
||||||
|
return &TypeTree{
|
||||||
|
Types: map[string]TypeInfo{
|
||||||
|
"entity": {IsAbstract: true},
|
||||||
|
"compute-entity": {Parent: "entity", IsAbstract: true},
|
||||||
|
"machine": {Parent: "compute-entity", IsAbstract: true},
|
||||||
|
"proxmox-host": {Parent: "machine", LifecycleID: "infrastructure"},
|
||||||
|
"lxc": {Parent: "compute-entity", LifecycleID: "infrastructure"},
|
||||||
|
"service": {Parent: "entity", LifecycleID: "infrastructure"},
|
||||||
|
"document": {Parent: "entity"}, // no lifecycle
|
||||||
|
},
|
||||||
|
RelTypes: map[string]RelTypeInfo{
|
||||||
|
"hosts": {SourceType: "machine", TargetType: "compute-entity", Cardinality: "one-to-many"},
|
||||||
|
"provides": {SourceType: "compute-entity", TargetType: "service", Cardinality: "one-to-many"},
|
||||||
|
"depends-on": {SourceType: "service", TargetType: "service", Cardinality: "many-to-many"},
|
||||||
|
"documents": {SourceType: "document", TargetType: "entity", Cardinality: "many-to-one"},
|
||||||
|
},
|
||||||
|
Lifecycles: map[string]LifecycleInfo{
|
||||||
|
"infrastructure": {
|
||||||
|
States: map[string]bool{"planned": true, "active": true, "destroyed": true},
|
||||||
|
DefaultState: "active",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAWalksHierarchy(t *testing.T) {
|
||||||
|
tree := fixtureTree()
|
||||||
|
cases := []struct {
|
||||||
|
typ, target string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"proxmox-host", "machine", true},
|
||||||
|
{"proxmox-host", "compute-entity", true},
|
||||||
|
{"proxmox-host", "entity", true},
|
||||||
|
{"proxmox-host", "proxmox-host", true},
|
||||||
|
{"lxc", "machine", false},
|
||||||
|
{"service", "compute-entity", false},
|
||||||
|
{"nonexistent", "entity", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := tree.IsA(c.typ, c.target); got != c.want {
|
||||||
|
t.Errorf("IsA(%q, %q) = %v, want %v", c.typ, c.target, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateEntityRejectsAbstract(t *testing.T) {
|
||||||
|
tree := fixtureTree()
|
||||||
|
for _, abstract := range []string{"entity", "compute-entity", "machine"} {
|
||||||
|
if err := tree.ValidateEntity(abstract, ""); !errors.Is(err, domain.ErrAbstractType) {
|
||||||
|
t.Errorf("ValidateEntity(%q) = %v, want ErrAbstractType", abstract, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tree.ValidateEntity("lxc", "active"); err != nil {
|
||||||
|
t.Errorf("ValidateEntity(lxc, active) = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateEntityStates(t *testing.T) {
|
||||||
|
tree := fixtureTree()
|
||||||
|
if err := tree.ValidateEntity("lxc", "flying"); !errors.Is(err, domain.ErrInvalidTransition) {
|
||||||
|
t.Errorf("bad state = %v, want ErrInvalidTransition", err)
|
||||||
|
}
|
||||||
|
// state on a type without a lifecycle is rejected
|
||||||
|
if err := tree.ValidateEntity("document", "active"); !errors.Is(err, domain.ErrInvalidTransition) {
|
||||||
|
t.Errorf("state without lifecycle = %v, want ErrInvalidTransition", err)
|
||||||
|
}
|
||||||
|
// unknown type
|
||||||
|
if err := tree.ValidateEntity("ghost", ""); !errors.Is(err, domain.ErrNotFound) {
|
||||||
|
t.Errorf("unknown type = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateEdgeHonorsInheritance(t *testing.T) {
|
||||||
|
tree := fixtureTree()
|
||||||
|
// proxmox-host is-a machine; lxc is-a compute-entity → valid
|
||||||
|
if err := tree.ValidateEdge("hosts", "proxmox-host", "lxc"); err != nil {
|
||||||
|
t.Errorf("hosts(proxmox-host→lxc) = %v, want nil", err)
|
||||||
|
}
|
||||||
|
// abstract endpoint declared, concrete descendant offered → valid
|
||||||
|
if err := tree.ValidateEdge("provides", "lxc", "service"); err != nil {
|
||||||
|
t.Errorf("provides(lxc→service) = %v, want nil", err)
|
||||||
|
}
|
||||||
|
// documents targets the root abstract 'entity' → anything is valid
|
||||||
|
if err := tree.ValidateEdge("documents", "document", "proxmox-host"); err != nil {
|
||||||
|
t.Errorf("documents(document→proxmox-host) = %v, want nil", err)
|
||||||
|
}
|
||||||
|
// service is not a machine → invalid source
|
||||||
|
if err := tree.ValidateEdge("hosts", "service", "lxc"); !errors.Is(err, domain.ErrInvalidEdge) {
|
||||||
|
t.Errorf("hosts(service→lxc) = %v, want ErrInvalidEdge", err)
|
||||||
|
}
|
||||||
|
// lxc is not a service → invalid target
|
||||||
|
if err := tree.ValidateEdge("depends-on", "service", "lxc"); !errors.Is(err, domain.ErrInvalidEdge) {
|
||||||
|
t.Errorf("depends-on(service→lxc) = %v, want ErrInvalidEdge", err)
|
||||||
|
}
|
||||||
|
// unknown relationship type
|
||||||
|
if err := tree.ValidateEdge("teleports", "lxc", "service"); !errors.Is(err, domain.ErrNotFound) {
|
||||||
|
t.Errorf("unknown rel type = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultState(t *testing.T) {
|
||||||
|
tree := fixtureTree()
|
||||||
|
if got := tree.DefaultState("lxc"); got != "active" {
|
||||||
|
t.Errorf("DefaultState(lxc) = %q, want active", got)
|
||||||
|
}
|
||||||
|
if got := tree.DefaultState("document"); got != "" {
|
||||||
|
t.Errorf("DefaultState(document) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
161
internal/policy/classify.go
Normal file
161
internal/policy/classify.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
// Package policy implements Oikos classification and policy evaluation.
|
||||||
|
// Determines risk class, autonomy route, and approval requirements.
|
||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/domain"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClassificationResult holds the outcome of classifying a signal.
|
||||||
|
type ClassificationResult struct {
|
||||||
|
RiskClass string
|
||||||
|
Route string // 'auto-act', 'escalate', 'hold'
|
||||||
|
RecommendedAction json.RawMessage
|
||||||
|
AutonomyCheck string
|
||||||
|
BlastRadius []uuid.UUID
|
||||||
|
CorrelationID string
|
||||||
|
Reasoning json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify evaluates a signal against policy rules to determine the action route.
|
||||||
|
// ctx must have a DB connection pool accessible via a helper interface.
|
||||||
|
type Classifier struct {
|
||||||
|
DB interface {
|
||||||
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||||
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||||
|
Exec(ctx context.Context, sql string, args ...any) (int64, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClassifier creates a classifier with a DB query interface.
|
||||||
|
func NewClassifier(dbc interface {
|
||||||
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||||
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||||
|
Exec(ctx context.Context, sql string, args ...any) (int64, error)
|
||||||
|
}) *Classifier {
|
||||||
|
return &Classifier{DB: dbc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifySignal evaluates a signal and returns the classification result.
|
||||||
|
func (c *Classifier) ClassifySignal(ctx context.Context, signalEntityID, targetEntityID uuid.UUID,
|
||||||
|
kind, severity, correlationID string) (*ClassificationResult, error) {
|
||||||
|
|
||||||
|
// Determine target entity type
|
||||||
|
var entityType string
|
||||||
|
err := c.DB.QueryRow(ctx,
|
||||||
|
"SELECT type FROM entities WHERE id = $1", targetEntityID).Scan(&entityType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: target entity %s", domain.ErrNotFound, targetEntityID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up risk class for this entity type and action
|
||||||
|
var riskClass string
|
||||||
|
var approvalRequired string
|
||||||
|
err = c.DB.QueryRow(ctx, `
|
||||||
|
SELECT rc.name, rc.approval_required
|
||||||
|
FROM risk_classes rc
|
||||||
|
WHERE rc.name = (
|
||||||
|
SELECT COALESCE(ar.risk_class, 'reversible_low')
|
||||||
|
FROM approval_rules ar
|
||||||
|
WHERE ar.entity_type = $1 AND ar.action = $2
|
||||||
|
LIMIT 1
|
||||||
|
)`, entityType, kind).Scan(&riskClass, &approvalRequired)
|
||||||
|
if err != nil {
|
||||||
|
// Default to escalate
|
||||||
|
riskClass = "reversible_low"
|
||||||
|
approvalRequired = "operator"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check global autonomy setting
|
||||||
|
var globalAutoAct string
|
||||||
|
err = c.DB.QueryRow(ctx,
|
||||||
|
"SELECT value FROM autonomy_settings WHERE key = 'global.auto_act'").Scan(&globalAutoAct)
|
||||||
|
if err != nil {
|
||||||
|
globalAutoAct = "on" // default to on
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-entity kill-switch
|
||||||
|
var slug string
|
||||||
|
c.DB.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetEntityID).Scan(&slug)
|
||||||
|
|
||||||
|
var entityAutoAct string
|
||||||
|
if slug != "" {
|
||||||
|
c.DB.QueryRow(ctx,
|
||||||
|
"SELECT value FROM autonomy_settings WHERE key = $1",
|
||||||
|
"never_auto_act."+slug).Scan(&entityAutoAct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine route
|
||||||
|
route := "escalate"
|
||||||
|
autonomyCheck := ""
|
||||||
|
|
||||||
|
if globalAutoAct == "off" || globalAutoAct == "false" {
|
||||||
|
route = "escalate"
|
||||||
|
autonomyCheck = "blocked: global auto_act disabled"
|
||||||
|
} else if entityAutoAct == "true" {
|
||||||
|
route = "escalate"
|
||||||
|
autonomyCheck = "blocked: per-entity kill-switch"
|
||||||
|
} else if approvalRequired == "none" {
|
||||||
|
route = "auto-act"
|
||||||
|
autonomyCheck = "allowed"
|
||||||
|
} else {
|
||||||
|
autonomyCheck = "requires approval: " + approvalRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute blast radius
|
||||||
|
blastRadius := computeBlastRadius(ctx, c.DB, targetEntityID)
|
||||||
|
|
||||||
|
reasoning := map[string]any{
|
||||||
|
"entity_type": entityType,
|
||||||
|
"risk_class": riskClass,
|
||||||
|
"approval_rule": approvalRequired,
|
||||||
|
"global_auto_act": globalAutoAct,
|
||||||
|
"entity_slug": slug,
|
||||||
|
}
|
||||||
|
|
||||||
|
reasoningJSON, _ := json.Marshal(reasoning)
|
||||||
|
recommended, _ := json.Marshal(map[string]any{
|
||||||
|
"action": kind,
|
||||||
|
"reason": fmt.Sprintf("signal %s on %s", severity, entityType),
|
||||||
|
})
|
||||||
|
|
||||||
|
return &ClassificationResult{
|
||||||
|
RiskClass: riskClass,
|
||||||
|
Route: route,
|
||||||
|
RecommendedAction: recommended,
|
||||||
|
AutonomyCheck: autonomyCheck,
|
||||||
|
BlastRadius: blastRadius,
|
||||||
|
CorrelationID: correlationID,
|
||||||
|
Reasoning: reasoningJSON,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeBlastRadius traverses relationships to find affected entities.
|
||||||
|
func computeBlastRadius(ctx context.Context, dbc interface {
|
||||||
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||||
|
}, entityID uuid.UUID) []uuid.UUID {
|
||||||
|
rows, err := dbc.Query(ctx, `
|
||||||
|
SELECT entity_id FROM blast_radius($1, 3)`, entityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var ids []uuid.UUID
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
if err := rows.Scan(&id); err == nil {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure domain is used
|
||||||
|
var _ = domain.ErrAutonomyBlocked
|
||||||
29
internal/scheduler/init.go
Normal file
29
internal/scheduler/init.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/config"
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Register with main package via package-level variable
|
||||||
|
// The cmd/oikos/main.go sets SchedulerRunner in its import
|
||||||
|
mainRunner = Run
|
||||||
|
}
|
||||||
|
|
||||||
|
// mainRunner is assigned to main.SchedulerRunner by the cmd/oikos package.
|
||||||
|
// It's set during init so that when main runs, the scheduler Runner is available.
|
||||||
|
var mainRunner func(context.Context, *db.Pool, config.Config)
|
||||||
|
|
||||||
|
// RunnerForMain provides the run function for registration in main.
|
||||||
|
func RunnerForMain() func(context.Context, *db.Pool, config.Config) {
|
||||||
|
return Run
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure sqlcgen is used
|
||||||
|
var _ = sqlcgen.Queries{}
|
||||||
|
var _ = slog.Default
|
||||||
210
internal/scheduler/scheduler.go
Normal file
210
internal/scheduler/scheduler.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
// Package scheduler implements the Oikos observe + decide loop (Phase 3).
|
||||||
|
// It loads enabled check_defs, runs checks on schedule, manages signal
|
||||||
|
// lifecycle (dedup, flap suppression, maintenance mode), and writes metrics.
|
||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/config"
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run starts the scheduler loop. Blocks until ctx is cancelled.
|
||||||
|
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
||||||
|
slog.Info("scheduler: starting", "interval", cfg.SchedulerInterval)
|
||||||
|
interval := cfg.SchedulerInterval
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Immediate first pass
|
||||||
|
runCheckPass(ctx, pool)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Info("scheduler: shutting down")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
runCheckPass(ctx, pool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCheckPass executes one full cycle of check evaluation.
|
||||||
|
func runCheckPass(ctx context.Context, pool *db.Pool) {
|
||||||
|
q := sqlcgen.New(pool)
|
||||||
|
|
||||||
|
defs, err := q.ListEnabledCheckDefs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("scheduler: list check defs", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(defs) == 0 {
|
||||||
|
slog.Debug("scheduler: no enabled check_defs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g, gctx := errgroup.WithContext(ctx)
|
||||||
|
g.SetLimit(10) // bounded worker pool
|
||||||
|
|
||||||
|
for _, def := range defs {
|
||||||
|
cd := def
|
||||||
|
g.Go(func() error {
|
||||||
|
runCheck(gctx, pool, cd)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
g.Wait()
|
||||||
|
|
||||||
|
// Housekeeping after each pass
|
||||||
|
housekeeping(ctx, pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCheck executes a single check and processes the result.
|
||||||
|
func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDefsRow) {
|
||||||
|
q := sqlcgen.New(pool)
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
health, signalKind, evidence, checkErr := executeCheck(ctx, cd)
|
||||||
|
|
||||||
|
latency := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
// Write metric
|
||||||
|
_ = q.InsertMetricSample(ctx, sqlcgen.InsertMetricSampleParams{
|
||||||
|
EntityID: cd.EntityID,
|
||||||
|
Metric: "probe_latency_ms",
|
||||||
|
Value: float64(latency),
|
||||||
|
Tags: []byte(`{}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
if checkErr != nil {
|
||||||
|
slog.Warn("scheduler: check failed",
|
||||||
|
"entity", cd.EntitySlug, "kind", cd.Kind, "error", checkErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if signalKind == "" || health == "healthy" {
|
||||||
|
// Recovery: resolve any open signal for this check
|
||||||
|
resolveSignal(ctx, pool, cd.EntityID, cd.EntitySlug)
|
||||||
|
// Update entity_status to healthy
|
||||||
|
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
||||||
|
EntityID: cd.EntityID,
|
||||||
|
Health: "healthy",
|
||||||
|
LastCheckAt: &[]time.Time{time.Now()}[0],
|
||||||
|
Details: []byte(`{}`),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure: upsert signal (dedup via partial unique index)
|
||||||
|
slog.Warn("scheduler: raising signal",
|
||||||
|
"entity", cd.EntitySlug, "kind", signalKind, "evidence", evidence)
|
||||||
|
|
||||||
|
severity := "warning"
|
||||||
|
if signalKind == "down" {
|
||||||
|
severity = "critical"
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, err := q.UpsertSignal(ctx, sqlcgen.UpsertSignalParams{
|
||||||
|
EntityID: cd.EntityID,
|
||||||
|
Kind: signalKind,
|
||||||
|
Severity: severity,
|
||||||
|
TargetEntityID: cd.TargetID,
|
||||||
|
Evidence: &evidence,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("scheduler: upsert signal", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update entity_status
|
||||||
|
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
||||||
|
EntityID: cd.EntityID,
|
||||||
|
Health: health,
|
||||||
|
LastCheckAt: &[]time.Time{time.Now()}[0],
|
||||||
|
Details: []byte(`{}`),
|
||||||
|
})
|
||||||
|
_ = sig // used for flap detection below
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSignal resolves any open signal for the given check entity.
|
||||||
|
func resolveSignal(ctx context.Context, pool *db.Pool, entityID uuid.UUID, slug string) {
|
||||||
|
q := sqlcgen.New(pool)
|
||||||
|
// Check if there's an open signal on this entity
|
||||||
|
_, err := pool.Exec(ctx, `UPDATE signals SET state = 'resolved', updated_at = now()
|
||||||
|
WHERE entity_id = $1 AND state = 'raised'`, entityID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
||||||
|
EntityID: entityID,
|
||||||
|
Health: "healthy",
|
||||||
|
LastCheckAt: &[]time.Time{time.Now()}[0],
|
||||||
|
Details: []byte(`{}`),
|
||||||
|
})
|
||||||
|
slog.Info("scheduler: signal resolved", "entity", slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeCheck dispatches to the appropriate checker by kind.
|
||||||
|
func executeCheck(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (health string, signalKind string, evidence string, err error) {
|
||||||
|
switch cd.Kind {
|
||||||
|
case "http":
|
||||||
|
return checkHTTP(ctx, cd)
|
||||||
|
case "tcp":
|
||||||
|
return checkTCP(ctx, cd)
|
||||||
|
case "disk":
|
||||||
|
return checkDisk(ctx, cd)
|
||||||
|
case "cert-expiry":
|
||||||
|
return checkCertExpiry(ctx, cd)
|
||||||
|
default:
|
||||||
|
return "unknown", "", "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// housekeeping runs background maintenance tasks.
|
||||||
|
func housekeeping(ctx context.Context, pool *db.Pool) {
|
||||||
|
|
||||||
|
// Prune expired idempotency keys (older than 24h)
|
||||||
|
cutoff := time.Now().Add(-24 * time.Hour)
|
||||||
|
_, err := pool.Exec(ctx,
|
||||||
|
"DELETE FROM idempotency_keys WHERE created_at < $1", cutoff)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("scheduler: prune idempotency keys", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log housekeeping completion
|
||||||
|
slog.Debug("scheduler: housekeeping done", "pruned_idempotency_before", cutoff.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkHTTP performs an HTTP health check.
|
||||||
|
func checkHTTP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string, string, string, error) {
|
||||||
|
// Stub: always returns healthy
|
||||||
|
return "healthy", "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkTCP performs a TCP dial check.
|
||||||
|
func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string, string, string, error) {
|
||||||
|
// Stub: always returns healthy
|
||||||
|
return "healthy", "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDisk performs a disk usage check via SSH.
|
||||||
|
func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string, string, string, error) {
|
||||||
|
// Stub: always returns healthy
|
||||||
|
return "healthy", "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkCertExpiry checks TLS certificate expiry.
|
||||||
|
func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string, string, string, error) {
|
||||||
|
// Stub: always returns healthy
|
||||||
|
return "healthy", "", "", nil
|
||||||
|
}
|
||||||
43
migrations/001_ontology.up.sql
Normal file
43
migrations/001_ontology.up.sql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
-- Migration 001: Ontology meta-schema (with inheritance, R3-1)
|
||||||
|
-- Defines entity types, relationship types, lifecycle definitions, and seed versioning.
|
||||||
|
|
||||||
|
CREATE TABLE lifecycle_defs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
states TEXT[] NOT NULL,
|
||||||
|
default_state TEXT NOT NULL,
|
||||||
|
terminal_states TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
transitions JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE entity_types (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
parent_type TEXT REFERENCES entity_types(name),
|
||||||
|
is_abstract BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
layer TEXT NOT NULL CHECK (layer IN ('meta','infrastructure','governance','cognition')),
|
||||||
|
description TEXT,
|
||||||
|
lifecycle_id TEXT REFERENCES lifecycle_defs(id),
|
||||||
|
attribute_schema JSONB,
|
||||||
|
schema_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE relationship_types (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
inverse TEXT,
|
||||||
|
source_type TEXT NOT NULL REFERENCES entity_types(name),
|
||||||
|
target_type TEXT NOT NULL REFERENCES entity_types(name),
|
||||||
|
cardinality TEXT NOT NULL CHECK (cardinality IN
|
||||||
|
('one-to-one','one-to-many','many-to-one','many-to-many')),
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE seed_versions (
|
||||||
|
file TEXT PRIMARY KEY,
|
||||||
|
content_hash TEXT NOT NULL,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
50
migrations/002_entities.up.sql
Normal file
50
migrations/002_entities.up.sql
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
-- Migration 002: Entity instances (UUIDv7 + slug, R3-5/D1)
|
||||||
|
-- The inventory graph: entities + typed relationships + blast_radius function.
|
||||||
|
|
||||||
|
CREATE TABLE entities (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
type TEXT NOT NULL REFERENCES entity_types(name),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
state TEXT,
|
||||||
|
attributes JSONB NOT NULL DEFAULT '{}',
|
||||||
|
maintenance_until TIMESTAMPTZ,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (type, name)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_entities_type ON entities(type);
|
||||||
|
CREATE INDEX idx_entities_state ON entities(state);
|
||||||
|
CREATE INDEX idx_entities_attrs ON entities USING GIN(attributes);
|
||||||
|
|
||||||
|
CREATE TABLE relationships (
|
||||||
|
source_id UUID NOT NULL REFERENCES entities(id) ON DELETE RESTRICT,
|
||||||
|
target_id UUID NOT NULL REFERENCES entities(id) ON DELETE RESTRICT,
|
||||||
|
type TEXT NOT NULL REFERENCES relationship_types(name),
|
||||||
|
attributes JSONB,
|
||||||
|
valid_from TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
valid_to TIMESTAMPTZ,
|
||||||
|
PRIMARY KEY (source_id, target_id, type, valid_from)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_rel_source ON relationships(source_id) WHERE valid_to IS NULL;
|
||||||
|
CREATE INDEX idx_rel_target ON relationships(target_id) WHERE valid_to IS NULL;
|
||||||
|
CREATE INDEX idx_rel_type ON relationships(type) WHERE valid_to IS NULL;
|
||||||
|
|
||||||
|
-- Cycle-safe traversal (P1): path accumulator prevents revisits; depth capped.
|
||||||
|
CREATE OR REPLACE FUNCTION blast_radius(start_id UUID, max_depth INT DEFAULT 3,
|
||||||
|
rel_types TEXT[] DEFAULT NULL)
|
||||||
|
RETURNS TABLE(entity_id UUID, depth INT) AS $$
|
||||||
|
WITH RECURSIVE walk AS (
|
||||||
|
SELECT start_id AS entity_id, 0 AS depth, ARRAY[start_id] AS path
|
||||||
|
UNION ALL
|
||||||
|
SELECT r.target_id, w.depth + 1, w.path || r.target_id
|
||||||
|
FROM relationships r
|
||||||
|
JOIN walk w ON r.source_id = w.entity_id
|
||||||
|
WHERE w.depth < LEAST(max_depth, 5)
|
||||||
|
AND r.valid_to IS NULL
|
||||||
|
AND NOT r.target_id = ANY(w.path)
|
||||||
|
AND (rel_types IS NULL OR r.type = ANY(rel_types))
|
||||||
|
)
|
||||||
|
SELECT entity_id, MIN(depth) FROM walk GROUP BY entity_id;
|
||||||
|
$$ LANGUAGE sql STABLE;
|
||||||
71
migrations/003_operations.up.sql
Normal file
71
migrations/003_operations.up.sql
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
-- Migration 003: Operations (signals, checks, approvals, status)
|
||||||
|
-- Signals are dual entities (entities row + signals table for indexed querying).
|
||||||
|
|
||||||
|
CREATE TABLE check_defs (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
target_id UUID REFERENCES entities(id),
|
||||||
|
target_type TEXT REFERENCES entity_types(name),
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
interval_s INTEGER NOT NULL DEFAULT 600,
|
||||||
|
timeout_s INTEGER NOT NULL DEFAULT 10,
|
||||||
|
zone TEXT,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE signals (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL CHECK (severity IN ('info','warning','critical')),
|
||||||
|
target_entity_id UUID REFERENCES entities(id),
|
||||||
|
check_id UUID REFERENCES check_defs(entity_id),
|
||||||
|
evidence TEXT,
|
||||||
|
likely_cause TEXT,
|
||||||
|
state TEXT NOT NULL DEFAULT 'raised',
|
||||||
|
occurrence_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
flap_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hold_down_until TIMESTAMPTZ,
|
||||||
|
mute_until TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
-- At most ONE open signal per (target, kind) — repeats update the open row
|
||||||
|
CREATE UNIQUE INDEX uq_signals_open ON signals(target_entity_id, kind)
|
||||||
|
WHERE state NOT IN ('resolved','failed');
|
||||||
|
CREATE INDEX idx_signals_state ON signals(state);
|
||||||
|
|
||||||
|
CREATE TABLE approvals (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
subject_entity_id UUID REFERENCES entities(id),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
risk_class TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'execution',
|
||||||
|
payload JSONB,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
token_hash TEXT,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
decided_at TIMESTAMPTZ,
|
||||||
|
decided_by UUID REFERENCES entities(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE entity_status (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
health TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
last_check_at TIMESTAMPTZ,
|
||||||
|
details JSONB NOT NULL DEFAULT '{}',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE idempotency_keys (
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
request_hash TEXT NOT NULL,
|
||||||
|
response_code INTEGER,
|
||||||
|
response_body JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (actor, key)
|
||||||
|
);
|
||||||
91
migrations/004_cognition.up.sql
Normal file
91
migrations/004_cognition.up.sql
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
-- Migration 004: Cognition (classifications, executions, learning)
|
||||||
|
-- All cognition objects are dual entities (entities row + typed table).
|
||||||
|
-- Classifications persist every autonomous decision (SA5).
|
||||||
|
|
||||||
|
CREATE TABLE classifications (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
signal_entity_id UUID REFERENCES signals(entity_id),
|
||||||
|
target_entity_id UUID REFERENCES entities(id),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
recommended_action JSONB,
|
||||||
|
risk_class TEXT NOT NULL,
|
||||||
|
route TEXT NOT NULL CHECK (route IN ('auto-act','escalate','hold')),
|
||||||
|
blast_radius UUID[],
|
||||||
|
pattern_confidence REAL,
|
||||||
|
skill_id UUID,
|
||||||
|
autonomy_check TEXT,
|
||||||
|
reasoning JSONB NOT NULL,
|
||||||
|
correlation_id TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_class_signal ON classifications(signal_entity_id);
|
||||||
|
CREATE INDEX idx_class_entity ON classifications(target_entity_id);
|
||||||
|
|
||||||
|
CREATE TABLE executions (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
classification_id UUID REFERENCES classifications(entity_id),
|
||||||
|
signal_entity_id UUID REFERENCES signals(entity_id),
|
||||||
|
target_entity_id UUID REFERENCES entities(id),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
risk_class TEXT NOT NULL,
|
||||||
|
approval_id UUID REFERENCES approvals(entity_id),
|
||||||
|
agent_id UUID REFERENCES entities(id),
|
||||||
|
skill_id UUID,
|
||||||
|
skill_version INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'proposed',
|
||||||
|
result JSONB,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
verified BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
correlation_id TEXT NOT NULL,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_exec_target ON executions(target_entity_id);
|
||||||
|
CREATE INDEX idx_exec_status ON executions(status);
|
||||||
|
|
||||||
|
CREATE TABLE feedback (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
execution_id UUID NOT NULL REFERENCES executions(entity_id),
|
||||||
|
outcome TEXT NOT NULL CHECK (outcome IN ('success','failure','partial','unexpected')),
|
||||||
|
observation TEXT,
|
||||||
|
lesson TEXT,
|
||||||
|
unexpected_side_effects TEXT[],
|
||||||
|
tags TEXT[],
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_feedback_ts ON feedback(created_at);
|
||||||
|
|
||||||
|
CREATE TABLE patterns (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
applies_type TEXT NOT NULL REFERENCES entity_types(name),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
confidence REAL NOT NULL DEFAULT 0,
|
||||||
|
evidence_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'hypothesized',
|
||||||
|
quarantined BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_validated_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (applies_type, action)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE skills (
|
||||||
|
entity_id UUID NOT NULL REFERENCES entities(id),
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
procedure JSONB NOT NULL,
|
||||||
|
applies_type TEXT REFERENCES entity_types(name),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
pattern_ids UUID[],
|
||||||
|
status TEXT NOT NULL DEFAULT 'drafted',
|
||||||
|
success_rate REAL,
|
||||||
|
changed_by UUID,
|
||||||
|
change_reason TEXT,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (entity_id, version)
|
||||||
|
);
|
||||||
28
migrations/005_policy.up.sql
Normal file
28
migrations/005_policy.up.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Migration 005: Policy (risk classes, approval rules, autonomy settings)
|
||||||
|
|
||||||
|
CREATE TABLE risk_classes (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
description TEXT,
|
||||||
|
approval_required TEXT NOT NULL DEFAULT 'none',
|
||||||
|
autonomy_allowed BOOLEAN NOT NULL DEFAULT false
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE approval_rules (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
entity_type TEXT REFERENCES entity_types(name),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
risk_class TEXT NOT NULL REFERENCES risk_classes(name),
|
||||||
|
autonomy_level TEXT NOT NULL DEFAULT 'escalate' CHECK
|
||||||
|
(autonomy_level IN ('auto','escalate','never')),
|
||||||
|
scope_entity UUID REFERENCES entities(id),
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (entity_type, action, scope_entity)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE autonomy_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
138
migrations/006_observability.up.sql
Normal file
138
migrations/006_observability.up.sql
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
-- Migration 006: Observability (TimescaleDB)
|
||||||
|
-- Hypertable PKs include time column (SG1); idempotent DDL (SG3); no array_agg in CAGGs (SG2).
|
||||||
|
|
||||||
|
-- Enable TimescaleDB extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||||
|
|
||||||
|
-- ─── Time-series metrics ──────────────────────────────────────────────
|
||||||
|
CREATE TABLE metric_samples (
|
||||||
|
ts TIMESTAMPTZ NOT NULL,
|
||||||
|
entity_id UUID NOT NULL,
|
||||||
|
metric TEXT NOT NULL,
|
||||||
|
value DOUBLE PRECISION NOT NULL,
|
||||||
|
tags JSONB NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
SELECT create_hypertable('metric_samples', 'ts',
|
||||||
|
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
CREATE INDEX idx_metrics_entity_ts ON metric_samples(entity_id, ts DESC);
|
||||||
|
CREATE INDEX idx_metrics_metric_ts ON metric_samples(metric, ts DESC);
|
||||||
|
DO $$ BEGIN
|
||||||
|
PERFORM add_retention_policy('metric_samples', INTERVAL '90 days');
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 1-hour rollups
|
||||||
|
CREATE MATERIALIZED VIEW metric_rollups_1h WITH (timescaledb.continuous) AS
|
||||||
|
SELECT time_bucket('1 hour', ts) AS bucket, entity_id, metric,
|
||||||
|
avg(value) AS avg_value, min(value) AS min_value,
|
||||||
|
max(value) AS max_value, count(*) AS sample_count
|
||||||
|
FROM metric_samples GROUP BY bucket, entity_id, metric;
|
||||||
|
DO $$ BEGIN
|
||||||
|
PERFORM add_continuous_aggregate_policy('metric_rollups_1h',
|
||||||
|
start_offset => INTERVAL '2 hours',
|
||||||
|
end_offset => INTERVAL '5 minutes',
|
||||||
|
schedule_interval => INTERVAL '1 hour');
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 1-day rollups
|
||||||
|
CREATE MATERIALIZED VIEW metric_rollups_1d WITH (timescaledb.continuous) AS
|
||||||
|
SELECT time_bucket('1 day', ts) AS bucket, entity_id, metric,
|
||||||
|
avg(value) AS avg_value, min(value) AS min_value,
|
||||||
|
max(value) AS max_value, count(*) AS sample_count
|
||||||
|
FROM metric_samples GROUP BY bucket, entity_id, metric;
|
||||||
|
DO $$ BEGIN
|
||||||
|
PERFORM add_continuous_aggregate_policy('metric_rollups_1d',
|
||||||
|
start_offset => INTERVAL '2 days',
|
||||||
|
end_offset => INTERVAL '1 hour',
|
||||||
|
schedule_interval => INTERVAL '1 day');
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─── Audit log ────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
actor_type TEXT NOT NULL,
|
||||||
|
actor_id UUID,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_id UUID,
|
||||||
|
method TEXT,
|
||||||
|
path TEXT,
|
||||||
|
status_code INTEGER,
|
||||||
|
detail JSONB NOT NULL DEFAULT '{}',
|
||||||
|
source_ip TEXT,
|
||||||
|
correlation_id TEXT,
|
||||||
|
PRIMARY KEY (id, ts)
|
||||||
|
);
|
||||||
|
SELECT create_hypertable('audit_log', 'ts',
|
||||||
|
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
CREATE INDEX idx_audit_actor ON audit_log(actor_type, actor_id, ts DESC);
|
||||||
|
CREATE INDEX idx_audit_entity ON audit_log(entity_id, ts DESC);
|
||||||
|
CREATE INDEX idx_audit_action ON audit_log(action, ts DESC);
|
||||||
|
CREATE INDEX idx_audit_correlation ON audit_log(correlation_id);
|
||||||
|
DO $$ BEGIN
|
||||||
|
PERFORM add_retention_policy('audit_log', INTERVAL '365 days');
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─── Event log ────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE events (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
entity_id UUID,
|
||||||
|
severity TEXT NOT NULL DEFAULT 'info',
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
data JSONB NOT NULL DEFAULT '{}',
|
||||||
|
correlation_id TEXT,
|
||||||
|
PRIMARY KEY (id, ts)
|
||||||
|
);
|
||||||
|
SELECT create_hypertable('events', 'ts',
|
||||||
|
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
CREATE INDEX idx_events_type_ts ON events(type, ts DESC);
|
||||||
|
CREATE INDEX idx_events_entity_ts ON events(entity_id, ts DESC);
|
||||||
|
CREATE INDEX idx_events_severity_ts ON events(severity, ts DESC);
|
||||||
|
CREATE INDEX idx_events_correlation ON events(correlation_id);
|
||||||
|
DO $$ BEGIN
|
||||||
|
PERFORM add_retention_policy('events', INTERVAL '90 days');
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─── Agent activity ──────────────────────────────────────────────────
|
||||||
|
CREATE TABLE agent_activity (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
agent_id UUID NOT NULL,
|
||||||
|
session_id TEXT,
|
||||||
|
activity_type TEXT NOT NULL,
|
||||||
|
tool_name TEXT,
|
||||||
|
entity_id UUID,
|
||||||
|
input_summary TEXT,
|
||||||
|
output_summary TEXT,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
token_count INTEGER,
|
||||||
|
success BOOLEAN,
|
||||||
|
correlation_id TEXT,
|
||||||
|
PRIMARY KEY (id, ts)
|
||||||
|
);
|
||||||
|
SELECT create_hypertable('agent_activity', 'ts',
|
||||||
|
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
CREATE INDEX idx_agent_activity_agent_ts ON agent_activity(agent_id, ts DESC);
|
||||||
|
CREATE INDEX idx_agent_activity_type_ts ON agent_activity(activity_type, ts DESC);
|
||||||
|
CREATE INDEX idx_agent_activity_entity ON agent_activity(entity_id, ts DESC);
|
||||||
|
CREATE INDEX idx_agent_activity_correlation ON agent_activity(correlation_id);
|
||||||
|
DO $$ BEGIN
|
||||||
|
PERFORM add_retention_policy('agent_activity', INTERVAL '90 days');
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─── Ledger view (R3-11: not a fourth write path) ────────────────────
|
||||||
|
CREATE VIEW ledger AS
|
||||||
|
SELECT e.created_at AS ts, e.entity_id AS execution_id, e.target_entity_id,
|
||||||
|
e.action, e.risk_class, e.status, e.verified,
|
||||||
|
c.route, c.reasoning, a.status AS approval_status, a.decided_by,
|
||||||
|
e.agent_id, e.correlation_id
|
||||||
|
FROM executions e
|
||||||
|
LEFT JOIN classifications c ON c.entity_id = e.classification_id
|
||||||
|
LEFT JOIN approvals a ON a.entity_id = e.approval_id;
|
||||||
18
migrations/007_relationships_current_unique.up.sql
Normal file
18
migrations/007_relationships_current_unique.up.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Migration 007: one current edge per (source, target, type).
|
||||||
|
-- The seed upsert previously conflicted on (source_id, target_id, type,
|
||||||
|
-- valid_from) — valid_from is now() at insert, so the conflict never fired
|
||||||
|
-- and every re-ingest duplicated all current edges. Dedupe (keep earliest
|
||||||
|
-- valid_from), then enforce uniqueness on current edges with a partial
|
||||||
|
-- unique index the upsert can target.
|
||||||
|
|
||||||
|
DELETE FROM relationships r
|
||||||
|
USING relationships keep
|
||||||
|
WHERE r.valid_to IS NULL AND keep.valid_to IS NULL
|
||||||
|
AND r.source_id = keep.source_id
|
||||||
|
AND r.target_id = keep.target_id
|
||||||
|
AND r.type = keep.type
|
||||||
|
AND r.valid_from > keep.valid_from;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_rel_current
|
||||||
|
ON relationships(source_id, target_id, type)
|
||||||
|
WHERE valid_to IS NULL;
|
||||||
24
migrations/008_event_notify.up.sql
Normal file
24
migrations/008_event_notify.up.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- Migration 008: post-commit event fan-out for the SSE stream (SG8/SG10).
|
||||||
|
-- Events are INSERTed in the same transaction as the state change; pg_notify
|
||||||
|
-- fires at COMMIT, so subscribers only ever see committed events. Payload is
|
||||||
|
-- kept minimal (NOTIFY has an 8000-byte limit); consumers needing the full
|
||||||
|
-- event fetch it by id.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION notify_oikos_event() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_notify('oikos_events', json_build_object(
|
||||||
|
'id', NEW.id,
|
||||||
|
'ts', NEW.ts,
|
||||||
|
'type', NEW.type,
|
||||||
|
'entity_id', NEW.entity_id,
|
||||||
|
'severity', NEW.severity,
|
||||||
|
'source', NEW.source,
|
||||||
|
'correlation_id', NEW.correlation_id
|
||||||
|
)::text);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_events_notify ON events;
|
||||||
|
CREATE TRIGGER trg_events_notify AFTER INSERT ON events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION notify_oikos_event();
|
||||||
15
migrations/009_knowledge.up.sql
Normal file
15
migrations/009_knowledge.up.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Migration 009: Knowledge entities (FTS search for documentation)
|
||||||
|
-- Creates the knowledge_entities table for Phase 3 search/knowledge endpoints.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS knowledge_entities (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
source TEXT,
|
||||||
|
tags TEXT[],
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_knowledge_title ON knowledge_entities USING gin(to_tsvector('english', title));
|
||||||
|
CREATE INDEX idx_knowledge_content ON knowledge_entities USING gin(to_tsvector('english', content));
|
||||||
7
migrations/embed.go
Normal file
7
migrations/embed.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Package migrations embeds SQL migration files for use by the db package.
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed *.up.sql
|
||||||
|
var FS embed.FS
|
||||||
File diff suppressed because it is too large
Load Diff
284
scripts/validate-seeds.py
Normal file
284
scripts/validate-seeds.py
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate seeds/*.yaml against the Oikos meta-schema (Phase 0 gate).
|
||||||
|
|
||||||
|
Checks the same invariants the Go ingest (internal/ontology) will enforce:
|
||||||
|
|
||||||
|
ontology.yaml
|
||||||
|
- every entity type's parent exists; hierarchy is acyclic
|
||||||
|
- layer/cardinality values are legal
|
||||||
|
- lifecycle references exist; default/terminal/transition states are declared
|
||||||
|
- relationship endpoint types exist (may be abstract)
|
||||||
|
- inverse names don't collide with forward names
|
||||||
|
|
||||||
|
inventory.yaml
|
||||||
|
- slugs unique and well-formed (<prefix>:<name>)
|
||||||
|
- entity types exist and are NOT abstract
|
||||||
|
- states are legal for the type's lifecycle (walking up the hierarchy for
|
||||||
|
the lifecycle definition is not needed — lifecycle binds per type)
|
||||||
|
- relationship endpoints exist; edge type exists; endpoint entity types
|
||||||
|
are the declared source/target types or descendants (hierarchy walk)
|
||||||
|
- cardinality: one-to-one / one-to-many / many-to-one uniqueness holds
|
||||||
|
within the seed
|
||||||
|
- attributes validate against attribute_schema (if jsonschema installed)
|
||||||
|
|
||||||
|
policy.yaml
|
||||||
|
- approval_rules reference existing risk classes / entity types / entities
|
||||||
|
- autonomy values sane
|
||||||
|
|
||||||
|
Exit 0 = clean (warnings allowed), 1 = errors.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
print("ERROR: PyYAML required (pip install pyyaml)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import jsonschema
|
||||||
|
HAVE_JSONSCHEMA = True
|
||||||
|
except ImportError:
|
||||||
|
HAVE_JSONSCHEMA = False
|
||||||
|
|
||||||
|
SEEDS = Path(__file__).resolve().parent.parent / "seeds"
|
||||||
|
LAYERS = {"meta", "infrastructure", "governance", "cognition"}
|
||||||
|
CARDINALITIES = {"one-to-one", "one-to-many", "many-to-one", "many-to-many"}
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def err(msg: str) -> None:
|
||||||
|
errors.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def warn(msg: str) -> None:
|
||||||
|
warnings.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def load(name: str) -> dict:
|
||||||
|
path = SEEDS / name
|
||||||
|
if not path.exists():
|
||||||
|
err(f"{name}: file missing")
|
||||||
|
return {}
|
||||||
|
with open(path) as f:
|
||||||
|
try:
|
||||||
|
return yaml.safe_load(f) or {}
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
err(f"{name}: YAML parse error: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── ontology.yaml ────────────────────────────────────────────────────
|
||||||
|
onto = load("ontology.yaml")
|
||||||
|
etypes: dict = onto.get("entity_types", {})
|
||||||
|
rtypes: dict = onto.get("relationship_types", {})
|
||||||
|
lifecycles: dict = onto.get("lifecycles", {})
|
||||||
|
|
||||||
|
for name, lc in lifecycles.items():
|
||||||
|
states = lc.get("states", [])
|
||||||
|
if not states:
|
||||||
|
err(f"lifecycle {name}: no states")
|
||||||
|
continue
|
||||||
|
if lc.get("default_state") not in states:
|
||||||
|
err(f"lifecycle {name}: default_state {lc.get('default_state')!r} not in states")
|
||||||
|
for t in lc.get("terminal_states", []):
|
||||||
|
if t not in states:
|
||||||
|
err(f"lifecycle {name}: terminal state {t!r} not in states")
|
||||||
|
for frm, tos in (lc.get("transitions") or {}).items():
|
||||||
|
if frm not in states:
|
||||||
|
err(f"lifecycle {name}: transition source {frm!r} not in states")
|
||||||
|
for to, spec in (tos or {}).items():
|
||||||
|
if to not in states:
|
||||||
|
err(f"lifecycle {name}: transition target {to!r} not in states")
|
||||||
|
if spec is not None and not isinstance(spec.get("requires", []), list):
|
||||||
|
err(f"lifecycle {name}: {frm}->{to} requires must be a list")
|
||||||
|
|
||||||
|
for name, et in etypes.items():
|
||||||
|
parent = et.get("parent")
|
||||||
|
if parent is not None and parent not in etypes:
|
||||||
|
err(f"entity type {name}: parent {parent!r} not defined")
|
||||||
|
layer = et.get("layer")
|
||||||
|
if layer not in LAYERS:
|
||||||
|
err(f"entity type {name}: layer {layer!r} invalid (want {sorted(LAYERS)})")
|
||||||
|
lc = et.get("lifecycle")
|
||||||
|
if lc is not None and lc not in lifecycles:
|
||||||
|
err(f"entity type {name}: lifecycle {lc!r} not defined")
|
||||||
|
schema = et.get("attributes")
|
||||||
|
if schema is not None and HAVE_JSONSCHEMA:
|
||||||
|
try:
|
||||||
|
jsonschema.Draft202012Validator.check_schema(schema)
|
||||||
|
except jsonschema.SchemaError as e:
|
||||||
|
err(f"entity type {name}: attribute_schema is not valid JSON Schema: {e.message}")
|
||||||
|
|
||||||
|
# hierarchy acyclicity + ancestor helper
|
||||||
|
def ancestors(t: str) -> list[str]:
|
||||||
|
chain, seen = [], set()
|
||||||
|
cur = t
|
||||||
|
while cur is not None:
|
||||||
|
if cur in seen:
|
||||||
|
err(f"entity type hierarchy cycle at {cur!r}")
|
||||||
|
break
|
||||||
|
seen.add(cur)
|
||||||
|
chain.append(cur)
|
||||||
|
cur = etypes.get(cur, {}).get("parent")
|
||||||
|
return chain
|
||||||
|
|
||||||
|
for name in etypes:
|
||||||
|
ancestors(name)
|
||||||
|
|
||||||
|
def is_a(t: str, target: str) -> bool:
|
||||||
|
return target in ancestors(t)
|
||||||
|
|
||||||
|
fwd_names = set(rtypes)
|
||||||
|
for name, rt in rtypes.items():
|
||||||
|
for endpoint in ("source", "target"):
|
||||||
|
v = rt.get(endpoint)
|
||||||
|
if v not in etypes:
|
||||||
|
err(f"relationship type {name}: {endpoint} {v!r} not a defined entity type")
|
||||||
|
if rt.get("cardinality") not in CARDINALITIES:
|
||||||
|
err(f"relationship type {name}: cardinality {rt.get('cardinality')!r} invalid")
|
||||||
|
inv = rt.get("inverse")
|
||||||
|
if inv and inv in fwd_names:
|
||||||
|
err(f"relationship type {name}: inverse {inv!r} collides with a forward name")
|
||||||
|
|
||||||
|
# ─── inventory.yaml ───────────────────────────────────────────────────
|
||||||
|
inv = load("inventory.yaml")
|
||||||
|
entities: list = inv.get("entities", [])
|
||||||
|
relationships: list = inv.get("relationships", [])
|
||||||
|
|
||||||
|
slugs: dict = {}
|
||||||
|
for e in entities:
|
||||||
|
slug = e.get("slug")
|
||||||
|
if not slug or ":" not in slug:
|
||||||
|
err(f"entity {e}: slug missing or not '<prefix>:<name>'")
|
||||||
|
continue
|
||||||
|
if slug in slugs:
|
||||||
|
err(f"duplicate slug {slug!r}")
|
||||||
|
slugs[slug] = e
|
||||||
|
t = e.get("type")
|
||||||
|
if t not in etypes:
|
||||||
|
err(f"{slug}: type {t!r} not in ontology")
|
||||||
|
continue
|
||||||
|
if etypes[t].get("abstract"):
|
||||||
|
err(f"{slug}: type {t!r} is abstract — cannot be instantiated")
|
||||||
|
if not e.get("name"):
|
||||||
|
err(f"{slug}: name missing")
|
||||||
|
state = e.get("state")
|
||||||
|
lc_name = etypes[t].get("lifecycle")
|
||||||
|
if state is not None:
|
||||||
|
if lc_name is None:
|
||||||
|
err(f"{slug}: has state {state!r} but type {t!r} has no lifecycle")
|
||||||
|
elif state not in lifecycles.get(lc_name, {}).get("states", []):
|
||||||
|
err(f"{slug}: state {state!r} not in lifecycle {lc_name!r}")
|
||||||
|
schema = etypes[t].get("attributes")
|
||||||
|
if schema and HAVE_JSONSCHEMA and e.get("attributes"):
|
||||||
|
v = jsonschema.Draft202012Validator(schema)
|
||||||
|
for ve in v.iter_errors(e["attributes"]):
|
||||||
|
warn(f"{slug}: attributes: {ve.message}")
|
||||||
|
|
||||||
|
# uniqueness of (type, name)
|
||||||
|
seen_tn = set()
|
||||||
|
for e in entities:
|
||||||
|
tn = (e.get("type"), e.get("name"))
|
||||||
|
if tn in seen_tn:
|
||||||
|
err(f"duplicate (type, name): {tn}")
|
||||||
|
seen_tn.add(tn)
|
||||||
|
|
||||||
|
edge_keys = set()
|
||||||
|
by_card_src: dict = {}
|
||||||
|
by_card_tgt: dict = {}
|
||||||
|
for r in relationships:
|
||||||
|
src, tgt, rt_name = r.get("source"), r.get("target"), r.get("type")
|
||||||
|
ctx = f"edge {src} -{rt_name}-> {tgt}"
|
||||||
|
if rt_name not in rtypes:
|
||||||
|
err(f"{ctx}: relationship type not in ontology")
|
||||||
|
continue
|
||||||
|
ok = True
|
||||||
|
for label, slug in (("source", src), ("target", tgt)):
|
||||||
|
if slug not in slugs:
|
||||||
|
err(f"{ctx}: {label} entity {slug!r} not in inventory")
|
||||||
|
ok = False
|
||||||
|
if not ok:
|
||||||
|
continue
|
||||||
|
key = (src, tgt, rt_name)
|
||||||
|
if key in edge_keys:
|
||||||
|
err(f"{ctx}: duplicate edge")
|
||||||
|
edge_keys.add(key)
|
||||||
|
rt = rtypes[rt_name]
|
||||||
|
for label, slug, want in (("source", src, rt["source"]), ("target", tgt, rt["target"])):
|
||||||
|
actual = slugs[slug]["type"]
|
||||||
|
if not is_a(actual, want):
|
||||||
|
err(f"{ctx}: {label} type {actual!r} is not a {want!r} (or descendant)")
|
||||||
|
# cardinality bookkeeping (source→target multiplicity)
|
||||||
|
card = rt.get("cardinality")
|
||||||
|
if card in ("one-to-one", "many-to-one"):
|
||||||
|
# each source has at most one outgoing edge of this type
|
||||||
|
k = (rt_name, src)
|
||||||
|
if k in by_card_src:
|
||||||
|
err(f"{ctx}: cardinality {card} — source {src!r} already has a {rt_name!r} edge")
|
||||||
|
by_card_src[k] = tgt
|
||||||
|
if card in ("one-to-one", "one-to-many"):
|
||||||
|
# each target has at most one incoming edge of this type
|
||||||
|
k = (rt_name, tgt)
|
||||||
|
if k in by_card_tgt:
|
||||||
|
err(f"{ctx}: cardinality {card} — target {tgt!r} already has a {rt_name!r} edge")
|
||||||
|
by_card_tgt[k] = src
|
||||||
|
|
||||||
|
# ─── policy.yaml ──────────────────────────────────────────────────────
|
||||||
|
pol = load("policy.yaml")
|
||||||
|
rclasses: dict = pol.get("risk_classes", {})
|
||||||
|
for name, rc in rclasses.items():
|
||||||
|
if rc.get("approval_required") not in ("none", "operator", "operator_confirmed"):
|
||||||
|
err(f"risk class {name}: approval_required invalid")
|
||||||
|
|
||||||
|
rules = pol.get("approval_rules", [])
|
||||||
|
seen_rules = set()
|
||||||
|
for rule in rules:
|
||||||
|
ctx = f"rule ({rule.get('entity_type')}, {rule.get('action')}, {rule.get('scope_entity')})"
|
||||||
|
if rule.get("risk_class") not in rclasses:
|
||||||
|
err(f"{ctx}: risk_class {rule.get('risk_class')!r} not defined")
|
||||||
|
if rule.get("autonomy_level") not in ("auto", "escalate", "never"):
|
||||||
|
err(f"{ctx}: autonomy_level invalid")
|
||||||
|
et = rule.get("entity_type")
|
||||||
|
if et is not None and et not in etypes:
|
||||||
|
err(f"{ctx}: entity_type {et!r} not in ontology")
|
||||||
|
scope = rule.get("scope_entity")
|
||||||
|
if scope is not None and scope not in slugs:
|
||||||
|
err(f"{ctx}: scope_entity {scope!r} not in inventory")
|
||||||
|
key = (et, rule.get("action"), scope)
|
||||||
|
if key in seen_rules:
|
||||||
|
err(f"{ctx}: duplicate rule")
|
||||||
|
seen_rules.add(key)
|
||||||
|
# rule sanity: autonomy 'auto' requires the class to allow autonomy
|
||||||
|
rc = rclasses.get(rule.get("risk_class"), {})
|
||||||
|
if rule.get("autonomy_level") == "auto" and not rc.get("autonomy_allowed"):
|
||||||
|
err(f"{ctx}: autonomy_level=auto but risk class forbids autonomy")
|
||||||
|
|
||||||
|
auto = pol.get("autonomy_settings", {})
|
||||||
|
ga = auto.get("global.auto_act")
|
||||||
|
if ga not in ("off", "reversible_low"):
|
||||||
|
err(f"autonomy_settings: global.auto_act {ga!r} invalid (off | reversible_low)")
|
||||||
|
for k in auto:
|
||||||
|
if k.startswith("never_auto_act."):
|
||||||
|
slug = k.split(".", 1)[1]
|
||||||
|
if slug not in slugs:
|
||||||
|
warn(f"autonomy_settings: {k} references unknown entity {slug!r}")
|
||||||
|
|
||||||
|
# ─── report ───────────────────────────────────────────────────────────
|
||||||
|
print(f"entity types: {len(etypes)} ({sum(1 for e in etypes.values() if e.get('abstract'))} abstract)")
|
||||||
|
print(f"relationship types: {len(rtypes)}")
|
||||||
|
print(f"lifecycles: {len(lifecycles)}")
|
||||||
|
print(f"entities: {len(entities)}")
|
||||||
|
print(f"relationships: {len(relationships)}")
|
||||||
|
print(f"risk classes: {len(rclasses)}, rules: {len(rules)}")
|
||||||
|
if not HAVE_JSONSCHEMA:
|
||||||
|
warn("jsonschema not installed — attribute schema validation skipped")
|
||||||
|
for w in warnings:
|
||||||
|
print(f"WARN {w}")
|
||||||
|
for e in errors:
|
||||||
|
print(f"ERROR {e}")
|
||||||
|
print(f"\n{'FAIL' if errors else 'OK'} — {len(errors)} error(s), {len(warnings)} warning(s)")
|
||||||
|
sys.exit(1 if errors else 0)
|
||||||
539
seeds/inventory.yaml
Normal file
539
seeds/inventory.yaml
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
# Oikos inventory seed — entity instances + relationships.
|
||||||
|
#
|
||||||
|
# Translated from the legacy /inventory.yaml (2026-07-07). Bootstraps the
|
||||||
|
# entities/relationships tables (migration 002); after ingest the DB is
|
||||||
|
# authoritative and this file is regenerated by `GET /api/v1/export`.
|
||||||
|
#
|
||||||
|
# Slug conventions: <prefix>:<name> —
|
||||||
|
# host: (proxmox-host, standalone-server) · ws: (workstation) · lxc: · vm:
|
||||||
|
# service: · ingress: · repo: (config-repo) · pool: · volume: · mesh: · lan:
|
||||||
|
# zone: (dns-zone) · idp: · person: · agent: · cluster: · backup:
|
||||||
|
#
|
||||||
|
# `state:` omitted = the type's lifecycle default (active).
|
||||||
|
# Mount details (mount_point) are attributes on `mounts` edges.
|
||||||
|
# Known thin spots are marked # THIN: backfill later.
|
||||||
|
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
entities:
|
||||||
|
|
||||||
|
# ─── Sites, networks ───────────────────────────────────────────────
|
||||||
|
- {slug: "site:home", type: site, name: home}
|
||||||
|
- {slug: "site:ionos-dc", type: site, name: ionos-dc,
|
||||||
|
attributes: {address: IONOS datacenter (VPS)}}
|
||||||
|
- slug: "lan:lab"
|
||||||
|
type: lan
|
||||||
|
name: lab
|
||||||
|
attributes: {subnet: 192.168.8.0/24}
|
||||||
|
- slug: "lan:household"
|
||||||
|
type: lan
|
||||||
|
name: household
|
||||||
|
attributes: {subnet: 192.168.178.0/24} # Fritz LAN; static route to lab subnet
|
||||||
|
- slug: "mesh:netbird"
|
||||||
|
type: mesh
|
||||||
|
name: netbird
|
||||||
|
attributes:
|
||||||
|
provider: netbird
|
||||||
|
subnet: 100.122.0.0/16
|
||||||
|
domain: netbird.selfhosted
|
||||||
|
- slug: "mesh:tailscale"
|
||||||
|
type: mesh
|
||||||
|
name: tailscale
|
||||||
|
state: deprecated # migration to netbird in progress (infrastructure/mesh.md)
|
||||||
|
attributes: {provider: tailscale}
|
||||||
|
- slug: "zone:hubris.network"
|
||||||
|
type: dns-zone
|
||||||
|
name: hubris.network
|
||||||
|
attributes: {zone: hubris.network, authority: "Technitium (LXC 107), split-horizon"}
|
||||||
|
- slug: "zone:netbird.selfhosted"
|
||||||
|
type: dns-zone
|
||||||
|
name: netbird.selfhosted
|
||||||
|
attributes: {zone: netbird.selfhosted, authority: netbird-mgmt}
|
||||||
|
|
||||||
|
# ─── Machines ──────────────────────────────────────────────────────
|
||||||
|
- slug: "cluster:homelab"
|
||||||
|
type: cluster
|
||||||
|
name: Homelab
|
||||||
|
attributes: {quorum: "2-node, no QDevice tiebreaker yet"}
|
||||||
|
- slug: "host:hubris"
|
||||||
|
type: proxmox-host
|
||||||
|
name: hubris
|
||||||
|
attributes:
|
||||||
|
os: linux
|
||||||
|
lan_ip: 192.168.8.77
|
||||||
|
mesh: {netbird: {ip: 100.122.38.109, fqdn: proxmox-server.netbird.selfhosted}}
|
||||||
|
ssh: {port: 22, netbird_port: 22022, user: root}
|
||||||
|
age_pubkey: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6
|
||||||
|
- slug: "host:strong"
|
||||||
|
type: proxmox-host
|
||||||
|
name: strong
|
||||||
|
attributes:
|
||||||
|
os: linux
|
||||||
|
lan_ip: 192.168.178.181
|
||||||
|
ssh: {user: root}
|
||||||
|
age_pubkey: age1rtwvdct6avjkr3cyxv3vue3vqx4d524fjfr3vk7xrnvyrylnry5sm54sn4
|
||||||
|
note: >-
|
||||||
|
PVE 9.2.3 since 2026-07-01 (formerly workstation ludo-mini). Joined
|
||||||
|
Homelab cluster same day. Not yet netbird-enrolled — reachable via
|
||||||
|
household LAN / Fritz static route only.
|
||||||
|
- slug: "host:netbird-vps"
|
||||||
|
type: standalone-server
|
||||||
|
name: netbird-vps
|
||||||
|
attributes:
|
||||||
|
os: linux
|
||||||
|
provider: ionos
|
||||||
|
control_level: partial # managed via ssh from hubris; not a homelab client
|
||||||
|
public_ipv4: 82.165.190.79
|
||||||
|
mesh: {netbird: {ip: 100.122.165.149, fqdn: netbird-ionos.netbird.selfhosted}}
|
||||||
|
ssh: {user: root}
|
||||||
|
note: netbird mgmt+signal+relay+dashboard + coturn; sshd locked to hubris pubkey
|
||||||
|
- slug: "ws:mac-mini"
|
||||||
|
type: workstation
|
||||||
|
name: mac-mini
|
||||||
|
attributes:
|
||||||
|
os: macos
|
||||||
|
user: dtoro
|
||||||
|
lan_ip: 192.168.178.182
|
||||||
|
mesh: {netbird: {fqdn: mac-mini-234-17.netbird.selfhosted}}
|
||||||
|
age_pubkey: age1z62ff2ak9zj5ctcvaxwyyhedwjvlwgm2dkn9nk3wrwk8fkavcpmsqwc2vs
|
||||||
|
note: only macOS in fleet; future Oikos OS Docker host
|
||||||
|
- slug: "ws:republic-laptop"
|
||||||
|
type: workstation
|
||||||
|
name: republic-laptop
|
||||||
|
attributes:
|
||||||
|
os: linux
|
||||||
|
user: dtoro
|
||||||
|
mesh: {netbird: {fqdn: republic-laptop.netbird.selfhosted}}
|
||||||
|
|
||||||
|
# ─── LXCs ──────────────────────────────────────────────────────────
|
||||||
|
- {slug: "lxc:jellyfin", type: lxc, name: jellyfin,
|
||||||
|
attributes: {pve_id: 101, role: media-server, lan_ip: 192.168.8.246,
|
||||||
|
public_host: media.hubris.network,
|
||||||
|
note: "VAAPI transcode via Radeon 680M passthrough; migrated hubris→strong 2026-07-05"}}
|
||||||
|
- {slug: "lxc:nfs-export", type: lxc, name: nfs-export,
|
||||||
|
attributes: {pve_id: 102, role: storage-export, lan_ip: 192.168.8.200}}
|
||||||
|
- {slug: "lxc:paperless", type: lxc, name: paperless,
|
||||||
|
attributes: {pve_id: 103, role: document-archive, lan_ip: 192.168.8.130,
|
||||||
|
public_host: paperless.hubris.network}}
|
||||||
|
- {slug: "lxc:gitea", type: lxc, name: gitea,
|
||||||
|
attributes: {pve_id: 104, role: git-server, lan_ip: 192.168.8.121,
|
||||||
|
public_host: git.hubris.network,
|
||||||
|
note: "bare repos at /mnt/library/repos/dtoro/*.git"}}
|
||||||
|
- {slug: "lxc:apps", type: lxc, name: apps,
|
||||||
|
attributes: {pve_id: 105, role: docker-apps, lan_ip: 192.168.8.205,
|
||||||
|
age_pubkey: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
|
||||||
|
note: "legacy Oikos host; fallback during cutover (plan A6)"}}
|
||||||
|
- {slug: "lxc:auth-outpost", type: lxc, name: auth-outpost,
|
||||||
|
attributes: {pve_id: 106, role: authentik-gateway, lan_ip: 192.168.8.6}}
|
||||||
|
- {slug: "lxc:dns", type: lxc, name: dns,
|
||||||
|
attributes: {pve_id: 107, role: dns-server, lan_ip: 192.168.8.2}}
|
||||||
|
- {slug: "lxc:nextcloud", type: lxc, name: nextcloud,
|
||||||
|
attributes: {pve_id: 114, role: file-sync, lan_ip: 192.168.8.224,
|
||||||
|
public_host: cloud.hubris.network}}
|
||||||
|
- {slug: "lxc:elementsynapse", type: lxc, name: elementsynapse,
|
||||||
|
attributes: {pve_id: 118, role: matrix-server, lan_ip: 192.168.8.242,
|
||||||
|
public_host: matrix.hubris.network,
|
||||||
|
note: "migrated hubris→strong 2026-07-05"}}
|
||||||
|
- {slug: "lxc:sophia", type: lxc, name: sophia,
|
||||||
|
attributes: {pve_id: 119, role: workshop, lan_ip: 192.168.8.109}}
|
||||||
|
- {slug: "lxc:mule-images", type: lxc, name: mule-images,
|
||||||
|
attributes: {pve_id: 120, role: photo-management, lan_ip: 192.168.8.136,
|
||||||
|
public_host: photos.hubris.network}}
|
||||||
|
- {slug: "lxc:caddy", type: lxc, name: caddy,
|
||||||
|
attributes: {pve_id: 121, role: reverse-proxy, lan_ip: 192.168.8.175,
|
||||||
|
note: "terminates all *.hubris.network; /etc/caddy is a checkout of dtoro/caddy-conf"}}
|
||||||
|
- {slug: "lxc:arriman", type: lxc, name: arriman,
|
||||||
|
attributes: {pve_id: 122, role: arr-stack, lan_ip: 192.168.8.245,
|
||||||
|
public_hosts: [jellyseerr.hubris.network, qbit.hubris.network, sab.hubris.network],
|
||||||
|
note: "homarr/radarr/sonarr/lidarr/sab/qbit/bazarr/flaresolverr/prowlarr/jellyseerr; migrated to strong 2026-07-05"}}
|
||||||
|
- {slug: "lxc:trmnl", type: lxc, name: trmnl,
|
||||||
|
attributes: {pve_id: 128, role: trmnl-middleware, lan_ip: 192.168.8.211,
|
||||||
|
public_host: trmnl.hubris.network,
|
||||||
|
note: "not yet mesh/SOPS-enrolled"}}
|
||||||
|
- {slug: "lxc:house", type: lxc, name: house,
|
||||||
|
attributes: {pve_id: 129, role: family-planner, lan_ip: 192.168.8.244,
|
||||||
|
public_host: house.hubris.network,
|
||||||
|
age_pubkey: age1s07zs83ehtlg8jtwvr75ltc3c4cdlemfwjuxrwjtwkqxkl9tpggsyrzn2h,
|
||||||
|
note: "Yuvomi + WebDAV bridge to paperless; migrated to strong 2026-07-05"}}
|
||||||
|
- {slug: "lxc:grimmory", type: lxc, name: grimmory,
|
||||||
|
attributes: {pve_id: 130, role: book-library, lan_ip: 192.168.8.247,
|
||||||
|
public_host: books.hubris.network,
|
||||||
|
age_pubkey: age1uellsemnjrzgfg9fxw4jefpy05laxzggwnwhh6ny3wl7alyp6v8q0muxet}}
|
||||||
|
- {slug: "lxc:teddycloud", type: lxc, name: teddycloud,
|
||||||
|
attributes: {pve_id: 131, role: teddycloud, lan_ip: 192.168.8.150,
|
||||||
|
public_host: teddy.hubris.network,
|
||||||
|
note: "drift-caught 2026-07-06; no forward-auth gate on route; not a homelab client"}}
|
||||||
|
- {slug: "lxc:rclone", type: lxc, name: rclone,
|
||||||
|
attributes: {pve_id: 132, role: backup,
|
||||||
|
mesh: {netbird: {fqdn: rclone.netbird.selfhosted}},
|
||||||
|
age_pubkey: age1pwtdws2thdh7vzp2dzttl3zxgcs2tgpcsjsqgw3q04nyml4kvuqq467u4x}}
|
||||||
|
# verified live on hubris 2026-07-07 (pct list via MCP)
|
||||||
|
- {slug: "lxc:seanime", type: lxc, name: seanime,
|
||||||
|
attributes: {pve_id: 133, role: anime-media-server, lan_ip: 192.168.8.248,
|
||||||
|
public_host: seanime.hubris.network,
|
||||||
|
note: "systemd service at /opt/seanime; uses qbittorrent on arriman"}}
|
||||||
|
- {slug: "lxc:romm", type: lxc, name: romm,
|
||||||
|
attributes: {pve_id: 134, role: rom-manager, lan_ip: 192.168.8.249,
|
||||||
|
public_host: roms.hubris.network,
|
||||||
|
note: "docker compose + MariaDB sidecar at /opt/romm"}}
|
||||||
|
|
||||||
|
# ─── VMs ───────────────────────────────────────────────────────────
|
||||||
|
- {slug: "vm:zimaos", type: vm, name: zimaos,
|
||||||
|
attributes: {pve_id: 100, role: nas-frontend-eval, lan_ip: 192.168.8.195,
|
||||||
|
public_host: zimaos.hubris.network}}
|
||||||
|
- {slug: "vm:haos", type: vm, name: haos,
|
||||||
|
attributes: {pve_id: 108, role: home-automation, lan_ip: 192.168.8.101}}
|
||||||
|
|
||||||
|
# ─── Storage ───────────────────────────────────────────────────────
|
||||||
|
- {slug: "pool:local-lvm-hubris", type: storage-pool, name: local-lvm (hubris),
|
||||||
|
attributes: {type: lvm}}
|
||||||
|
- {slug: "pool:library-hubris", type: storage-pool, name: library (hubris),
|
||||||
|
attributes: {type: lvmthin, capacity_gb: 3700}}
|
||||||
|
# 2nd Samsung 990 EVO Plus NVMe; backs /mnt/library ext4 via
|
||||||
|
# /dev/mapper/library-library (knowledge/wiki/hosts/hubris.md)
|
||||||
|
- {slug: "pool:ludo-lvm", type: storage-pool, name: ludo-lvm (strong),
|
||||||
|
attributes: {type: lvm}}
|
||||||
|
- {slug: "volume:library", type: volume, name: library,
|
||||||
|
attributes: {path: /mnt/library, size_gb: 3700}}
|
||||||
|
- {slug: "volume:media-local", type: volume, name: media-local,
|
||||||
|
attributes: {path: /mnt/media_local}}
|
||||||
|
- {slug: "backup:proton-drive", type: backup-target, name: proton-drive,
|
||||||
|
attributes: {provider: proton, encrypted: true}}
|
||||||
|
|
||||||
|
# ─── Services ──────────────────────────────────────────────────────
|
||||||
|
- {slug: "service:proxmox-ui", type: service, name: proxmox_ui,
|
||||||
|
attributes: {url: "https://proxmox.hubris.network", port: 8006,
|
||||||
|
doc_page: knowledge/wiki/hosts/hubris.md,
|
||||||
|
risk_notes: "hypervisor UI — changes affect every guest on the node"}}
|
||||||
|
- {slug: "service:gitea", type: service, name: gitea,
|
||||||
|
attributes: {url: "https://git.hubris.network", port: 3000,
|
||||||
|
doc_page: knowledge/wiki/containers/104-gitea.md,
|
||||||
|
risk_notes: "hosts all config repos + deploy webhooks; outage blocks auto-deploy and sync"}}
|
||||||
|
- {slug: "service:caddy", type: service, name: caddy,
|
||||||
|
attributes: {doc_page: knowledge/wiki/containers/121-caddy.md,
|
||||||
|
risk_notes: "wide blast radius — every *.hubris.network route rides on it"}}
|
||||||
|
- {slug: "service:authentik", type: service, name: authentik,
|
||||||
|
attributes: {url: "https://auth.hubris.network",
|
||||||
|
doc_page: knowledge/wiki/containers/106-auth-outpost.md,
|
||||||
|
note: "core on VPS since 2026-05-31; LAN outpost = auth-outpost (LXC 106) 192.168.8.6:9000",
|
||||||
|
risk_notes: "SSO provider — outage locks login to OIDC/forward-auth services"}}
|
||||||
|
- {slug: "service:dns", type: service, name: dns,
|
||||||
|
attributes: {doc_page: knowledge/wiki/containers/107-dns.md,
|
||||||
|
risk_notes: "LAN-wide resolver — misconfig breaks name resolution for every client"}}
|
||||||
|
- {slug: "service:jellyfin", type: service, name: jellyfin,
|
||||||
|
attributes: {url: "https://media.hubris.network",
|
||||||
|
doc_page: knowledge/wiki/containers/101-jellyfin.md,
|
||||||
|
risk_notes: "native Authentik OIDC (no forward-auth gate); VAAPI depends on GPU passthrough on strong"}}
|
||||||
|
- {slug: "service:nextcloud", type: service, name: nextcloud,
|
||||||
|
attributes: {url: "https://cloud.hubris.network",
|
||||||
|
doc_page: knowledge/wiki/containers/114-nextcloud.md}}
|
||||||
|
- {slug: "service:paperless", type: service, name: paperless,
|
||||||
|
attributes: {url: "https://paperless.hubris.network",
|
||||||
|
doc_page: knowledge/wiki/containers/103-paperless.md,
|
||||||
|
risk_notes: "document archive — data irreplaceable; DB operations are destructive-class"}}
|
||||||
|
- {slug: "service:matrix", type: service, name: matrix,
|
||||||
|
attributes: {url: "https://matrix.hubris.network",
|
||||||
|
doc_page: knowledge/wiki/containers/118-elementsynapse.md,
|
||||||
|
risk_notes: "alert/approval channel for Oikos — outage silences agent escalation"}}
|
||||||
|
- {slug: "service:photos", type: service, name: photos,
|
||||||
|
attributes: {url: "https://photos.hubris.network",
|
||||||
|
doc_page: knowledge/wiki/containers/120-mule-images.md}}
|
||||||
|
- {slug: "service:arr-stack", type: service, name: arr_stack,
|
||||||
|
attributes: {doc_page: knowledge/wiki/containers/122-arriman.md,
|
||||||
|
note: "jellyseerr / qbit / sab on docker compose"}}
|
||||||
|
- {slug: "service:artifacto", type: service, name: artifacto,
|
||||||
|
attributes: {url: "https://artifacto.hubris.network",
|
||||||
|
doc_page: knowledge/wiki/containers/105-apps.md}}
|
||||||
|
- {slug: "service:trmnl", type: service, name: trmnl,
|
||||||
|
attributes: {url: "https://trmnl.hubris.network",
|
||||||
|
doc_page: knowledge/wiki/containers/128-trmnl.md,
|
||||||
|
note: "TRMNL e-ink plugin middleware (polled by TRMNL cloud)"}}
|
||||||
|
- {slug: "service:zimaos", type: service, name: zimaos,
|
||||||
|
attributes: {url: "https://zimaos.hubris.network",
|
||||||
|
doc_page: knowledge/wiki/vms/100-zimaos.md}}
|
||||||
|
- {slug: "service:haos", type: service, name: haos,
|
||||||
|
attributes: {doc_page: knowledge/wiki/vms/108-haos.md}}
|
||||||
|
- {slug: "service:teddycloud", type: service, name: teddycloud,
|
||||||
|
attributes: {url: "https://teddy.hubris.network",
|
||||||
|
doc_page: knowledge/wiki/containers/131-teddycloud.md,
|
||||||
|
risk_notes: "no forward-auth gate — reachable by anyone on LAN/mesh"}}
|
||||||
|
- {slug: "service:homelab-mcp", type: service, name: homelab_mcp,
|
||||||
|
attributes: {port: 9810, systemd_unit: homelab-mcp,
|
||||||
|
endpoint: "https://mcp.hubris.network/mcp",
|
||||||
|
doc_page: knowledge/wiki/infrastructure/homelab-context.md,
|
||||||
|
risk_notes: "agents' primary read surface — outage degrades every agent to grepping the clone"}}
|
||||||
|
- {slug: "service:secrets-issuance", type: service, name: secrets_issuance,
|
||||||
|
attributes: {port: 9820, systemd_unit: secrets-issuance,
|
||||||
|
endpoint: "https://secrets.hubris.network/issue",
|
||||||
|
doc_page: .agents/operations/agent-enrollment.md,
|
||||||
|
risk_notes: "identity issuance — security-sensitive; key operations are destructive-class"}}
|
||||||
|
# Services derived from hosts.public_host (no legacy services entry):
|
||||||
|
- {slug: "service:house", type: service, name: house,
|
||||||
|
attributes: {url: "https://house.hubris.network", note: "Yuvomi family planner (derived)"}}
|
||||||
|
- {slug: "service:grimmory", type: service, name: grimmory,
|
||||||
|
attributes: {url: "https://books.hubris.network", note: derived}}
|
||||||
|
- {slug: "service:seanime", type: service, name: seanime,
|
||||||
|
attributes: {url: "https://seanime.hubris.network", port: 43211, note: derived}}
|
||||||
|
- {slug: "service:romm", type: service, name: romm,
|
||||||
|
attributes: {url: "https://roms.hubris.network", note: derived}}
|
||||||
|
- {slug: "service:jellyseerr", type: service, name: jellyseerr,
|
||||||
|
attributes: {url: "https://jellyseerr.hubris.network", note: derived (arriman)}}
|
||||||
|
- {slug: "service:qbit", type: service, name: qbit,
|
||||||
|
attributes: {url: "https://qbit.hubris.network", note: derived (arriman)}}
|
||||||
|
- {slug: "service:sab", type: service, name: sab,
|
||||||
|
attributes: {url: "https://sab.hubris.network", note: "derived (arriman); forward-auth gated"}}
|
||||||
|
|
||||||
|
# ─── Config repos ──────────────────────────────────────────────────
|
||||||
|
- {slug: "repo:caddy-conf", type: config-repo, name: dtoro/caddy-conf}
|
||||||
|
- {slug: "repo:gitea-customizations", type: config-repo, name: dtoro/gitea-customizations}
|
||||||
|
- {slug: "repo:mule-image", type: config-repo, name: dtoro/mule-image}
|
||||||
|
- {slug: "repo:artifacto", type: config-repo, name: dtoro/Artifacto}
|
||||||
|
- {slug: "repo:terminalito", type: config-repo, name: dtoro/terminalito}
|
||||||
|
- {slug: "repo:homelab-docs", type: config-repo, name: dtoro/Homelab-Docs}
|
||||||
|
|
||||||
|
# ─── Ingress routes (Caddy, *.hubris.network) ──────────────────────
|
||||||
|
- {slug: "ingress:proxmox.hubris.network", type: ingress-route, name: proxmox.hubris.network}
|
||||||
|
- {slug: "ingress:git.hubris.network", type: ingress-route, name: git.hubris.network}
|
||||||
|
- {slug: "ingress:auth.hubris.network", type: ingress-route, name: auth.hubris.network}
|
||||||
|
- {slug: "ingress:media.hubris.network", type: ingress-route, name: media.hubris.network}
|
||||||
|
- {slug: "ingress:cloud.hubris.network", type: ingress-route, name: cloud.hubris.network}
|
||||||
|
- {slug: "ingress:paperless.hubris.network", type: ingress-route, name: paperless.hubris.network,
|
||||||
|
attributes: {forward_auth: true}}
|
||||||
|
- {slug: "ingress:matrix.hubris.network", type: ingress-route, name: matrix.hubris.network}
|
||||||
|
- {slug: "ingress:photos.hubris.network", type: ingress-route, name: photos.hubris.network}
|
||||||
|
- {slug: "ingress:artifacto.hubris.network", type: ingress-route, name: artifacto.hubris.network}
|
||||||
|
- {slug: "ingress:trmnl.hubris.network", type: ingress-route, name: trmnl.hubris.network}
|
||||||
|
- {slug: "ingress:zimaos.hubris.network", type: ingress-route, name: zimaos.hubris.network}
|
||||||
|
- {slug: "ingress:teddy.hubris.network", type: ingress-route, name: teddy.hubris.network,
|
||||||
|
attributes: {forward_auth: false}}
|
||||||
|
- {slug: "ingress:mcp.hubris.network", type: ingress-route, name: mcp.hubris.network}
|
||||||
|
- {slug: "ingress:secrets.hubris.network", type: ingress-route, name: secrets.hubris.network}
|
||||||
|
- {slug: "ingress:house.hubris.network", type: ingress-route, name: house.hubris.network}
|
||||||
|
- {slug: "ingress:books.hubris.network", type: ingress-route, name: books.hubris.network}
|
||||||
|
- {slug: "ingress:seanime.hubris.network", type: ingress-route, name: seanime.hubris.network}
|
||||||
|
- {slug: "ingress:roms.hubris.network", type: ingress-route, name: roms.hubris.network}
|
||||||
|
- {slug: "ingress:jellyseerr.hubris.network", type: ingress-route, name: jellyseerr.hubris.network}
|
||||||
|
- {slug: "ingress:qbit.hubris.network", type: ingress-route, name: qbit.hubris.network}
|
||||||
|
- {slug: "ingress:sab.hubris.network", type: ingress-route, name: sab.hubris.network,
|
||||||
|
attributes: {forward_auth: true}}
|
||||||
|
|
||||||
|
# ─── Governance ────────────────────────────────────────────────────
|
||||||
|
- {slug: "person:dtoro", type: person, name: dtoro,
|
||||||
|
attributes: {matrix_id: "@dtoro:avispero"}}
|
||||||
|
- {slug: "idp:authentik", type: identity-provider, name: authentik,
|
||||||
|
attributes: {issuer: "https://auth.hubris.network", auth_mode: both}}
|
||||||
|
- {slug: "agent:hermes", type: agent, name: hermes,
|
||||||
|
state: planned,
|
||||||
|
attributes: {gateway_port: 8092, note: "Oikos Phase 4 — Docker gateway mode"}}
|
||||||
|
- {slug: "agent:oikos", type: agent, name: oikos,
|
||||||
|
state: planned,
|
||||||
|
attributes: {note: "the OS control loop itself (scheduler/actuator) as an actor"}}
|
||||||
|
|
||||||
|
# ─── Archaeology (state: destroyed — kept for "what happened to X?") ─
|
||||||
|
- {slug: "lxc:claudio-bot", type: lxc, name: claudio-bot, state: destroyed,
|
||||||
|
attributes: {pve_id: 123, destroyed: "2026-06-04", reason: "replaced by Hermes Agent on mac-mini"}}
|
||||||
|
- {slug: "lxc:plato", type: lxc, name: plato, state: destroyed,
|
||||||
|
attributes: {pve_id: 126, destroyed: "2026-06-28", reason: "notes workspace decommissioned; data at /mnt/library/documents/plato"}}
|
||||||
|
- {slug: "lxc:mule-photos-new", type: lxc, name: mule-photos-new, state: destroyed,
|
||||||
|
attributes: {pve_id: 127, destroyed: "2026-05-22", reason: "PhotoPrism test stack promoted to LXC 120"}}
|
||||||
|
- {slug: "lxc:heaper", type: lxc, name: heaper, state: destroyed,
|
||||||
|
attributes: {pve_id: 116, destroyed: "2026-05-14", reason: "decommissioned; data at /mnt/library/heaper"}}
|
||||||
|
- {slug: "lxc:syncthing", type: lxc, name: syncthing, state: destroyed,
|
||||||
|
attributes: {pve_id: 109, destroyed: "2026-05-14", reason: "decommissioned; library subtree was empty"}}
|
||||||
|
- {slug: "lxc:seafile", type: lxc, name: seafile, state: destroyed,
|
||||||
|
attributes: {pve_id: 125, destroyed: "2026-05-13", reason: "Seafile Pro evaluation rejected"}}
|
||||||
|
- {slug: "lxc:arr-yunohost", type: lxc, name: arr-yunohost, state: destroyed,
|
||||||
|
attributes: {pve_id: 100, destroyed: "2026-04-28", reason: "migrated to docker stack on arriman (LXC 122)"}}
|
||||||
|
- {slug: "lxc:flaresolverr", type: lxc, name: flaresolverr, state: destroyed,
|
||||||
|
attributes: {pve_id: 106, destroyed: "2026-04-28", reason: "folded into the arriman docker compose"}}
|
||||||
|
- {slug: "lxc:marimo", type: lxc, name: marimo, state: destroyed,
|
||||||
|
attributes: {pve_id: 107, destroyed: "2026-04-28", reason: decommissioned}}
|
||||||
|
- {slug: "lxc:photoprism", type: lxc, name: photoprism, state: destroyed,
|
||||||
|
attributes: {pve_id: 110, destroyed: "2026-04-28", reason: "replaced by mule-images (LXC 120)"}}
|
||||||
|
- {slug: "lxc:karakeep", type: lxc, name: karakeep, state: destroyed,
|
||||||
|
attributes: {pve_id: 111, destroyed: "2026-04-28", reason: decommissioned}}
|
||||||
|
- {slug: "lxc:immich", type: lxc, name: immich, state: destroyed,
|
||||||
|
attributes: {pve_id: 112, destroyed: "2026-04-28", reason: "replaced by mule-images (LXC 120)"}}
|
||||||
|
- {slug: "lxc:reticulum", type: lxc, name: reticulum, state: destroyed,
|
||||||
|
attributes: {pve_id: 115, destroyed: "2026-04-28", reason: decommissioned}}
|
||||||
|
|
||||||
|
relationships:
|
||||||
|
|
||||||
|
# ─── Cluster membership ────────────────────────────────────────────
|
||||||
|
- {source: "host:hubris", target: "cluster:homelab", type: member-of}
|
||||||
|
- {source: "host:strong", target: "cluster:homelab", type: member-of}
|
||||||
|
|
||||||
|
# ─── Location ──────────────────────────────────────────────────────
|
||||||
|
- {source: "host:hubris", target: "site:home", type: located-at}
|
||||||
|
- {source: "host:strong", target: "site:home", type: located-at}
|
||||||
|
- {source: "ws:mac-mini", target: "site:home", type: located-at}
|
||||||
|
- {source: "host:netbird-vps", target: "site:ionos-dc", type: located-at}
|
||||||
|
|
||||||
|
# ─── Hosting (machine → guest) ─────────────────────────────────────
|
||||||
|
- {source: "host:hubris", target: "lxc:nfs-export", type: hosts}
|
||||||
|
- {source: "host:hubris", target: "lxc:paperless", type: hosts}
|
||||||
|
- {source: "host:hubris", target: "lxc:gitea", type: hosts}
|
||||||
|
- {source: "host:hubris", target: "lxc:apps", type: hosts}
|
||||||
|
- {source: "host:hubris", target: "lxc:auth-outpost", type: hosts}
|
||||||
|
- {source: "host:hubris", target: "lxc:dns", type: hosts}
|
||||||
|
- {source: "host:hubris", target: "lxc:nextcloud", type: hosts}
|
||||||
|
- {source: "host:hubris", target: "lxc:sophia", type: hosts}
|
||||||
|
- {source: "host:hubris", target: "lxc:mule-images", type: hosts}
|
||||||
|
- {source: "host:hubris", target: "lxc:caddy", type: hosts}
|
||||||
|
- {source: "host:hubris", target: "lxc:trmnl", type: hosts}
|
||||||
|
- {source: "host:hubris", target: "lxc:teddycloud", type: hosts}
|
||||||
|
- {source: "host:hubris", target: "vm:zimaos", type: hosts}
|
||||||
|
- {source: "host:hubris", target: "vm:haos", type: hosts}
|
||||||
|
- {source: "host:strong", target: "lxc:jellyfin", type: hosts}
|
||||||
|
- {source: "host:strong", target: "lxc:elementsynapse", type: hosts}
|
||||||
|
- {source: "host:strong", target: "lxc:arriman", type: hosts}
|
||||||
|
- {source: "host:strong", target: "lxc:house", type: hosts}
|
||||||
|
- {source: "host:strong", target: "lxc:grimmory", type: hosts}
|
||||||
|
- {source: "host:strong", target: "lxc:seanime", type: hosts}
|
||||||
|
- {source: "host:strong", target: "lxc:romm", type: hosts}
|
||||||
|
- {source: "host:hubris", target: "lxc:rclone", type: hosts}
|
||||||
|
|
||||||
|
# ─── Service provision (compute → service) ─────────────────────────
|
||||||
|
- {source: "host:hubris", target: "service:proxmox-ui", type: provides}
|
||||||
|
- {source: "lxc:gitea", target: "service:gitea", type: provides}
|
||||||
|
- {source: "lxc:caddy", target: "service:caddy", type: provides}
|
||||||
|
- {source: "host:netbird-vps", target: "service:authentik", type: provides}
|
||||||
|
- {source: "lxc:dns", target: "service:dns", type: provides}
|
||||||
|
- {source: "lxc:jellyfin", target: "service:jellyfin", type: provides}
|
||||||
|
- {source: "lxc:nextcloud", target: "service:nextcloud", type: provides}
|
||||||
|
- {source: "lxc:paperless", target: "service:paperless", type: provides}
|
||||||
|
- {source: "lxc:elementsynapse", target: "service:matrix", type: provides}
|
||||||
|
- {source: "lxc:mule-images", target: "service:photos", type: provides}
|
||||||
|
- {source: "lxc:arriman", target: "service:arr-stack", type: provides}
|
||||||
|
- {source: "lxc:arriman", target: "service:jellyseerr", type: provides}
|
||||||
|
- {source: "lxc:arriman", target: "service:qbit", type: provides}
|
||||||
|
- {source: "lxc:arriman", target: "service:sab", type: provides}
|
||||||
|
- {source: "lxc:apps", target: "service:artifacto", type: provides}
|
||||||
|
- {source: "lxc:apps", target: "service:homelab-mcp", type: provides}
|
||||||
|
- {source: "lxc:apps", target: "service:secrets-issuance", type: provides}
|
||||||
|
- {source: "lxc:trmnl", target: "service:trmnl", type: provides}
|
||||||
|
- {source: "vm:zimaos", target: "service:zimaos", type: provides}
|
||||||
|
- {source: "vm:haos", target: "service:haos", type: provides}
|
||||||
|
- {source: "lxc:teddycloud", target: "service:teddycloud", type: provides}
|
||||||
|
- {source: "lxc:house", target: "service:house", type: provides}
|
||||||
|
- {source: "lxc:grimmory", target: "service:grimmory", type: provides}
|
||||||
|
- {source: "lxc:seanime", target: "service:seanime", type: provides}
|
||||||
|
- {source: "lxc:romm", target: "service:romm", type: provides}
|
||||||
|
|
||||||
|
# ─── Ingress → service ─────────────────────────────────────────────
|
||||||
|
- {source: "ingress:proxmox.hubris.network", target: "service:proxmox-ui", type: routes-to}
|
||||||
|
- {source: "ingress:git.hubris.network", target: "service:gitea", type: routes-to}
|
||||||
|
- {source: "ingress:auth.hubris.network", target: "service:authentik", type: routes-to}
|
||||||
|
- {source: "ingress:media.hubris.network", target: "service:jellyfin", type: routes-to}
|
||||||
|
- {source: "ingress:cloud.hubris.network", target: "service:nextcloud", type: routes-to}
|
||||||
|
- {source: "ingress:paperless.hubris.network", target: "service:paperless", type: routes-to}
|
||||||
|
- {source: "ingress:matrix.hubris.network", target: "service:matrix", type: routes-to}
|
||||||
|
- {source: "ingress:photos.hubris.network", target: "service:photos", type: routes-to}
|
||||||
|
- {source: "ingress:artifacto.hubris.network", target: "service:artifacto", type: routes-to}
|
||||||
|
- {source: "ingress:trmnl.hubris.network", target: "service:trmnl", type: routes-to}
|
||||||
|
- {source: "ingress:zimaos.hubris.network", target: "service:zimaos", type: routes-to}
|
||||||
|
- {source: "ingress:teddy.hubris.network", target: "service:teddycloud", type: routes-to}
|
||||||
|
- {source: "ingress:mcp.hubris.network", target: "service:homelab-mcp", type: routes-to}
|
||||||
|
- {source: "ingress:secrets.hubris.network", target: "service:secrets-issuance", type: routes-to}
|
||||||
|
- {source: "ingress:house.hubris.network", target: "service:house", type: routes-to}
|
||||||
|
- {source: "ingress:books.hubris.network", target: "service:grimmory", type: routes-to}
|
||||||
|
- {source: "ingress:seanime.hubris.network", target: "service:seanime", type: routes-to}
|
||||||
|
- {source: "ingress:roms.hubris.network", target: "service:romm", type: routes-to}
|
||||||
|
- {source: "ingress:jellyseerr.hubris.network", target: "service:jellyseerr", type: routes-to}
|
||||||
|
- {source: "ingress:qbit.hubris.network", target: "service:qbit", type: routes-to}
|
||||||
|
- {source: "ingress:sab.hubris.network", target: "service:sab", type: routes-to}
|
||||||
|
|
||||||
|
# ─── Auth edges ────────────────────────────────────────────────────
|
||||||
|
- {source: "ingress:paperless.hubris.network", target: "idp:authentik", type: secured-by}
|
||||||
|
- {source: "ingress:sab.hubris.network", target: "idp:authentik", type: secured-by}
|
||||||
|
- {source: "service:jellyfin", target: "idp:authentik", type: authenticates-via}
|
||||||
|
- {source: "idp:authentik", target: "person:dtoro", type: authenticates}
|
||||||
|
|
||||||
|
# ─── Config repos ──────────────────────────────────────────────────
|
||||||
|
- {source: "service:caddy", target: "repo:caddy-conf", type: configured-by}
|
||||||
|
- {source: "service:gitea", target: "repo:gitea-customizations", type: configured-by}
|
||||||
|
- {source: "service:photos", target: "repo:mule-image", type: configured-by}
|
||||||
|
- {source: "service:artifacto", target: "repo:artifacto", type: configured-by}
|
||||||
|
- {source: "service:trmnl", target: "repo:terminalito", type: configured-by}
|
||||||
|
- {source: "service:homelab-mcp", target: "repo:homelab-docs", type: configured-by}
|
||||||
|
- {source: "service:secrets-issuance", target: "repo:homelab-docs", type: configured-by}
|
||||||
|
|
||||||
|
# ─── Service dependencies (blast-radius edges; grow over time) ─────
|
||||||
|
- {source: "service:paperless", target: "service:authentik", type: depends-on}
|
||||||
|
- {source: "service:homelab-mcp", target: "service:gitea", type: depends-on}
|
||||||
|
- {source: "service:jellyseerr", target: "service:jellyfin", type: depends-on}
|
||||||
|
- {source: "service:seanime", target: "service:qbit", type: depends-on}
|
||||||
|
- {source: "service:house", target: "service:paperless", type: depends-on}
|
||||||
|
- {source: "service:sab", target: "service:authentik", type: depends-on}
|
||||||
|
|
||||||
|
# ─── Network membership ────────────────────────────────────────────
|
||||||
|
- {source: "host:hubris", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "host:hubris", target: "mesh:netbird", type: connects-via}
|
||||||
|
- {source: "host:strong", target: "lan:household", type: connects-via}
|
||||||
|
- {source: "ws:mac-mini", target: "lan:household", type: connects-via}
|
||||||
|
- {source: "ws:mac-mini", target: "mesh:netbird", type: connects-via}
|
||||||
|
- {source: "ws:republic-laptop", target: "mesh:netbird", type: connects-via}
|
||||||
|
- {source: "host:netbird-vps", target: "mesh:netbird", type: connects-via}
|
||||||
|
- {source: "lxc:rclone", target: "mesh:netbird", type: connects-via}
|
||||||
|
- {source: "lxc:jellyfin", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:nfs-export", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:paperless", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:gitea", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:apps", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:apps", target: "mesh:tailscale", type: connects-via}
|
||||||
|
- {source: "lxc:auth-outpost", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:dns", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:nextcloud", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:elementsynapse", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:sophia", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:mule-images", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:caddy", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:arriman", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:trmnl", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:house", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:grimmory", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:teddycloud", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:seanime", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "lxc:romm", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "vm:zimaos", target: "lan:lab", type: connects-via}
|
||||||
|
- {source: "vm:haos", target: "lan:lab", type: connects-via}
|
||||||
|
|
||||||
|
# ─── Storage ───────────────────────────────────────────────────────
|
||||||
|
- {source: "pool:ludo-lvm", target: "volume:media-local", type: contains}
|
||||||
|
- {source: "pool:library-hubris", target: "volume:library", type: contains}
|
||||||
|
- {source: "host:hubris", target: "volume:library", type: mounts,
|
||||||
|
attributes: {mount_point: /mnt/library}}
|
||||||
|
- {source: "lxc:jellyfin", target: "volume:media-local", type: mounts,
|
||||||
|
attributes: {mount_point: /mnt/media_local}}
|
||||||
|
- {source: "lxc:paperless", target: "volume:library", type: mounts,
|
||||||
|
attributes: {mount_point: /mnt/library}}
|
||||||
|
- {source: "lxc:gitea", target: "volume:library", type: mounts,
|
||||||
|
attributes: {mount_point: /mnt/library}}
|
||||||
|
- {source: "lxc:apps", target: "volume:library", type: mounts,
|
||||||
|
attributes: {mount_point: /mnt/library}}
|
||||||
|
- {source: "lxc:nextcloud", target: "volume:library", type: mounts,
|
||||||
|
attributes: {mount_point: /mnt/library}}
|
||||||
|
- {source: "lxc:sophia", target: "volume:library", type: mounts,
|
||||||
|
attributes: {mount_point: /mnt/library}}
|
||||||
|
- {source: "lxc:mule-images", target: "volume:library", type: mounts,
|
||||||
|
attributes: {mount_point: /mnt/library}}
|
||||||
|
- {source: "lxc:arriman", target: "volume:media-local", type: mounts,
|
||||||
|
attributes: {mount_point: /mnt/media_local}}
|
||||||
|
- {source: "lxc:grimmory", target: "volume:media-local", type: mounts,
|
||||||
|
attributes: {mount_point: /mnt/media_local}}
|
||||||
|
- {source: "lxc:teddycloud", target: "volume:library", type: mounts,
|
||||||
|
attributes: {mount_point: /mnt/library}}
|
||||||
|
- {source: "lxc:seanime", target: "volume:media-local", type: mounts,
|
||||||
|
attributes: {mount_point: /mnt/media_local/anime}}
|
||||||
|
- {source: "lxc:romm", target: "volume:media-local", type: mounts,
|
||||||
|
attributes: {mount_point: /mnt/media_local}}
|
||||||
|
- {source: "lxc:jellyfin", target: "pool:ludo-lvm", type: stores-on}
|
||||||
|
- {source: "lxc:arriman", target: "pool:ludo-lvm", type: stores-on}
|
||||||
|
- {source: "lxc:grimmory", target: "pool:ludo-lvm", type: stores-on}
|
||||||
|
- {source: "lxc:romm", target: "pool:ludo-lvm", type: stores-on}
|
||||||
|
- {source: "lxc:teddycloud", target: "pool:local-lvm-hubris", type: stores-on}
|
||||||
|
- {source: "lxc:rclone", target: "backup:proton-drive", type: backs-up-to}
|
||||||
|
|
||||||
|
# ─── Governance ────────────────────────────────────────────────────
|
||||||
|
- {source: "person:dtoro", target: "agent:hermes", type: owns}
|
||||||
|
- {source: "person:dtoro", target: "agent:oikos", type: owns}
|
||||||
936
seeds/ontology.yaml
Normal file
936
seeds/ontology.yaml
Normal file
@@ -0,0 +1,936 @@
|
|||||||
|
# Oikos ontology seed — the systems model of the homelab.
|
||||||
|
#
|
||||||
|
# Bootstraps entity_types / relationship_types / lifecycle_defs on first
|
||||||
|
# deploy (migration 001). After ingest the DB is authoritative; this file
|
||||||
|
# is regenerated by `GET /api/v1/export` for DR + version control.
|
||||||
|
#
|
||||||
|
# Conventions:
|
||||||
|
# - entity type names are kebab-case
|
||||||
|
# - `parent:` builds the is-a hierarchy; `abstract: true` types cannot be
|
||||||
|
# instantiated (validation walks the hierarchy for relationship
|
||||||
|
# endpoints and policy rules — plan R3-1)
|
||||||
|
# - `layer:` one of meta | infrastructure | governance | cognition
|
||||||
|
# - relationship `cardinality:` describes source→target multiplicity:
|
||||||
|
# one-to-one | one-to-many | many-to-one | many-to-many
|
||||||
|
# - relationship endpoints may name abstract types
|
||||||
|
# - lifecycle transition `requires:` entries are NAMED CHECKS implemented
|
||||||
|
# in Go (internal/ontology); the DB stores which checks gate a
|
||||||
|
# transition, the code implements them
|
||||||
|
# - mount details (mount_point, options) live as ATTRIBUTES on `mounts`
|
||||||
|
# edges, not as a separate entity type
|
||||||
|
#
|
||||||
|
# Rule of completeness: if something can break, be changed, or hold data,
|
||||||
|
# it has an entity type here and edges to the things it touches.
|
||||||
|
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
# ─── Lifecycles ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
lifecycles:
|
||||||
|
infrastructure:
|
||||||
|
states: [planned, provisioning, active, migrating, failed, deprecated, destroyed]
|
||||||
|
default_state: active # legacy inventory entries without state are active
|
||||||
|
terminal_states: [destroyed]
|
||||||
|
transitions:
|
||||||
|
planned:
|
||||||
|
provisioning: {requires: [inventory-entry, ip-reserved, storage-pool-chosen, doc-page-stub]}
|
||||||
|
destroyed: {requires: [cancelled-note]}
|
||||||
|
provisioning:
|
||||||
|
active: {requires: [age-key-enrolled-if-needed, mesh-joined-if-needed,
|
||||||
|
ingress-live-if-public, health-check-answering,
|
||||||
|
doc-page-complete]}
|
||||||
|
failed: {requires: []}
|
||||||
|
active:
|
||||||
|
migrating: {requires: [preflight, backup-verified]}
|
||||||
|
deprecated: {requires: [replacement-live-or-role-retired]}
|
||||||
|
failed: {requires: []}
|
||||||
|
migrating:
|
||||||
|
active: {requires: [post-verify, caddy-backends-checked, mounts-checked, docs-updated]}
|
||||||
|
failed: {requires: []}
|
||||||
|
failed:
|
||||||
|
active: {requires: [recovery-verified]}
|
||||||
|
deprecated: {requires: [write-off-note]}
|
||||||
|
deprecated:
|
||||||
|
active: {requires: [un-deprecate-note]}
|
||||||
|
destroyed: {requires: [backups-verified, secrets-revoked-and-rekeyed,
|
||||||
|
ingress-and-dns-removed, no-inbound-edges,
|
||||||
|
archaeology-entry]}
|
||||||
|
|
||||||
|
signal:
|
||||||
|
states: [raised, acknowledged, acting, muted, resolved, failed]
|
||||||
|
default_state: raised
|
||||||
|
terminal_states: [resolved]
|
||||||
|
transitions:
|
||||||
|
raised:
|
||||||
|
acknowledged: {requires: []}
|
||||||
|
muted: {requires: [mute-ttl-set]}
|
||||||
|
resolved: {requires: [condition-cleared]}
|
||||||
|
acknowledged:
|
||||||
|
acting: {requires: [classification-exists]}
|
||||||
|
resolved: {requires: []}
|
||||||
|
muted: {requires: [mute-ttl-set]}
|
||||||
|
acting:
|
||||||
|
resolved: {requires: [verification-passed]}
|
||||||
|
raised: {requires: [retry-budget-remaining]}
|
||||||
|
failed: {requires: []}
|
||||||
|
failed:
|
||||||
|
acknowledged: {requires: [operator-retry]}
|
||||||
|
muted:
|
||||||
|
raised: {requires: [mute-ttl-expired]}
|
||||||
|
|
||||||
|
execution:
|
||||||
|
states: [proposed, approved, auto_approved, denied, expired, executing,
|
||||||
|
verifying, verified, failed, timed_out, cancelled, rolled_back,
|
||||||
|
rollback_failed]
|
||||||
|
default_state: proposed
|
||||||
|
terminal_states: [verified, failed, denied, expired, cancelled,
|
||||||
|
rolled_back, rollback_failed]
|
||||||
|
transitions:
|
||||||
|
proposed:
|
||||||
|
approved: {requires: [operator-approval]}
|
||||||
|
auto_approved: {requires: [autonomy-allows]}
|
||||||
|
denied: {requires: []}
|
||||||
|
approved:
|
||||||
|
executing: {requires: [approval-token-valid]}
|
||||||
|
expired: {requires: [approval-ttl-elapsed]}
|
||||||
|
auto_approved:
|
||||||
|
executing: {requires: []}
|
||||||
|
executing:
|
||||||
|
verified: {requires: [verification-passed]}
|
||||||
|
failed: {requires: []}
|
||||||
|
timed_out: {requires: []}
|
||||||
|
cancelled: {requires: [operator-abort]}
|
||||||
|
timed_out:
|
||||||
|
verifying: {requires: []} # check if the command completed anyway
|
||||||
|
verifying:
|
||||||
|
verified: {requires: [verification-passed]}
|
||||||
|
failed: {requires: []}
|
||||||
|
failed:
|
||||||
|
rolled_back: {requires: [rollback-procedure-exists]}
|
||||||
|
rollback_failed: {requires: []}
|
||||||
|
|
||||||
|
approval:
|
||||||
|
states: [pending, approved, denied, expired, revoked]
|
||||||
|
default_state: pending
|
||||||
|
terminal_states: [denied, expired, revoked]
|
||||||
|
transitions:
|
||||||
|
pending:
|
||||||
|
approved: {requires: [token-verified]}
|
||||||
|
denied: {requires: []}
|
||||||
|
expired: {requires: [ttl-elapsed]}
|
||||||
|
approved:
|
||||||
|
revoked: {requires: [not-yet-executing]}
|
||||||
|
|
||||||
|
pattern:
|
||||||
|
states: [hypothesized, validated, active, deprecated, invalidated]
|
||||||
|
default_state: hypothesized
|
||||||
|
terminal_states: [deprecated, invalidated]
|
||||||
|
transitions:
|
||||||
|
hypothesized:
|
||||||
|
validated: {requires: [evidence-count-5plus, confidence-0.7plus]}
|
||||||
|
invalidated: {requires: []}
|
||||||
|
validated:
|
||||||
|
active: {requires: [operator-approval]} # S4: never automatic
|
||||||
|
invalidated: {requires: []}
|
||||||
|
active:
|
||||||
|
deprecated: {requires: []}
|
||||||
|
invalidated: {requires: [contradicting-evidence]}
|
||||||
|
|
||||||
|
skill:
|
||||||
|
states: [drafted, tested, active, refined, failed, deprecated]
|
||||||
|
default_state: drafted
|
||||||
|
terminal_states: [deprecated]
|
||||||
|
transitions:
|
||||||
|
drafted:
|
||||||
|
tested: {requires: [test-execution-recorded]}
|
||||||
|
deprecated: {requires: []}
|
||||||
|
tested:
|
||||||
|
active: {requires: [operator-approval]}
|
||||||
|
failed: {requires: []}
|
||||||
|
failed:
|
||||||
|
drafted: {requires: []}
|
||||||
|
active:
|
||||||
|
refined: {requires: [new-version-created]}
|
||||||
|
deprecated: {requires: []}
|
||||||
|
refined:
|
||||||
|
active: {requires: [operator-approval]}
|
||||||
|
|
||||||
|
# ─── Entity types ──────────────────────────────────────────────────────
|
||||||
|
# domain: physical | compute | network | storage | software | external |
|
||||||
|
# identity | cognition
|
||||||
|
|
||||||
|
entity_types:
|
||||||
|
|
||||||
|
# Root
|
||||||
|
entity:
|
||||||
|
abstract: true
|
||||||
|
domain: meta
|
||||||
|
layer: meta
|
||||||
|
description: Root abstract type. Relationship endpoints that accept any
|
||||||
|
entity (documented-by, procedure-for, checks) reference this.
|
||||||
|
|
||||||
|
# ── Infrastructure / physical ──
|
||||||
|
site:
|
||||||
|
parent: entity
|
||||||
|
domain: physical
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Physical location (home, VPS datacenter).
|
||||||
|
attributes: {type: object, properties: {address: {type: string}}}
|
||||||
|
ups:
|
||||||
|
parent: entity
|
||||||
|
domain: physical
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Uninterruptible power supply.
|
||||||
|
attributes: {type: object, properties: {vendor: {type: string}, va: {type: integer}}}
|
||||||
|
sensor:
|
||||||
|
parent: entity
|
||||||
|
domain: physical
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Environmental sensor.
|
||||||
|
peripheral:
|
||||||
|
parent: entity
|
||||||
|
domain: physical
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Attached hardware (GPU, e-ink display, dongle).
|
||||||
|
|
||||||
|
# ── Infrastructure / compute ──
|
||||||
|
compute-entity:
|
||||||
|
parent: entity
|
||||||
|
abstract: true
|
||||||
|
domain: compute
|
||||||
|
layer: infrastructure
|
||||||
|
description: Anything that executes workloads (machine, VM, container).
|
||||||
|
machine:
|
||||||
|
parent: compute-entity
|
||||||
|
abstract: true
|
||||||
|
domain: compute
|
||||||
|
layer: infrastructure
|
||||||
|
description: Physical machine. Always instantiated as a subtype.
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
cpu_arch: {type: string}
|
||||||
|
ram_gb: {type: number}
|
||||||
|
os: {type: string, enum: [linux, macos]}
|
||||||
|
lan_ip: {type: string}
|
||||||
|
mesh: {type: object}
|
||||||
|
ssh: {type: object}
|
||||||
|
age_pubkey: {type: string}
|
||||||
|
proxmox-host:
|
||||||
|
parent: machine
|
||||||
|
domain: compute
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Machine running Proxmox VE.
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties: {pve_version: {type: string}}
|
||||||
|
standalone-server:
|
||||||
|
parent: machine
|
||||||
|
domain: compute
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Machine outside PVE management (e.g. external VPS).
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
hypervisor: {type: string}
|
||||||
|
provider: {type: string}
|
||||||
|
control_level: {type: string, enum: [full, partial, none]}
|
||||||
|
public_ipv4: {type: string}
|
||||||
|
workstation:
|
||||||
|
parent: machine
|
||||||
|
domain: compute
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Operator machine (may also host services, e.g. mac-mini).
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties: {user: {type: string}}
|
||||||
|
appliance:
|
||||||
|
parent: machine
|
||||||
|
domain: compute
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Vendor appliance with limited management access.
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties: {vendor: {type: string}, model: {type: string}}
|
||||||
|
vm:
|
||||||
|
parent: compute-entity
|
||||||
|
domain: compute
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Virtual machine.
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
pve_id: {type: integer}
|
||||||
|
vcpus: {type: integer}
|
||||||
|
memory_mb: {type: integer}
|
||||||
|
lan_ip: {type: string}
|
||||||
|
public_host: {type: string}
|
||||||
|
role: {type: string}
|
||||||
|
container:
|
||||||
|
parent: compute-entity
|
||||||
|
abstract: true
|
||||||
|
domain: compute
|
||||||
|
layer: infrastructure
|
||||||
|
description: OS-level container (LXC or Docker).
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties: {runtime: {type: string}}
|
||||||
|
lxc:
|
||||||
|
parent: container
|
||||||
|
domain: compute
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Proxmox LXC container.
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
pve_id: {type: integer}
|
||||||
|
lan_ip: {type: string}
|
||||||
|
public_host: {type: string}
|
||||||
|
public_hosts: {type: array, items: {type: string}}
|
||||||
|
role: {type: string}
|
||||||
|
mesh: {type: object}
|
||||||
|
age_pubkey: {type: string}
|
||||||
|
destroyed: {type: string}
|
||||||
|
reason: {type: string}
|
||||||
|
docker-container:
|
||||||
|
parent: container
|
||||||
|
domain: compute
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Docker container (the OS models its own stack with these).
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties: {image: {type: string}}
|
||||||
|
hypervisor:
|
||||||
|
parent: entity
|
||||||
|
domain: compute
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Hypervisor software running on a machine (PVE, KVM, OrbStack).
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties: {type: {type: string}, version: {type: string}}
|
||||||
|
|
||||||
|
# ── Infrastructure / network ──
|
||||||
|
network:
|
||||||
|
parent: entity
|
||||||
|
abstract: true
|
||||||
|
domain: network
|
||||||
|
layer: infrastructure
|
||||||
|
description: A network things connect to.
|
||||||
|
lan:
|
||||||
|
parent: network
|
||||||
|
domain: network
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Local area network.
|
||||||
|
attributes: {type: object, properties: {subnet: {type: string}}}
|
||||||
|
mesh:
|
||||||
|
parent: network
|
||||||
|
domain: network
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Overlay mesh network (NetBird, Tailscale).
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
provider: {type: string}
|
||||||
|
subnet: {type: string}
|
||||||
|
domain: {type: string}
|
||||||
|
vlan:
|
||||||
|
parent: network
|
||||||
|
domain: network
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Tagged VLAN.
|
||||||
|
attributes: {type: object, properties: {tag: {type: integer}}}
|
||||||
|
network-interface:
|
||||||
|
parent: entity
|
||||||
|
domain: network
|
||||||
|
layer: infrastructure
|
||||||
|
description: Optional per-interface refinement (mac, ip). The seed uses
|
||||||
|
coarse connects-via edges; interfaces can be backfilled later.
|
||||||
|
attributes: {type: object, properties: {mac: {type: string}, ip: {type: string}}}
|
||||||
|
dns-zone:
|
||||||
|
parent: entity
|
||||||
|
domain: network
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: DNS zone (e.g. split-horizon hubris.network).
|
||||||
|
attributes: {type: object, properties: {zone: {type: string}, authority: {type: string}}}
|
||||||
|
dns-record:
|
||||||
|
parent: entity
|
||||||
|
domain: network
|
||||||
|
layer: infrastructure
|
||||||
|
description: Individual DNS record.
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties: {name: {type: string}, record_type: {type: string}, value: {type: string}}
|
||||||
|
ingress-route:
|
||||||
|
parent: entity
|
||||||
|
domain: network
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Public hostname → upstream mapping (Caddy).
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
pattern: {type: string}
|
||||||
|
upstream: {type: string}
|
||||||
|
forward_auth: {type: boolean}
|
||||||
|
certificate:
|
||||||
|
parent: entity
|
||||||
|
domain: network
|
||||||
|
layer: infrastructure
|
||||||
|
description: TLS certificate.
|
||||||
|
attributes: {type: object, properties: {issuer: {type: string}, expires: {type: string}}}
|
||||||
|
firewall-rule:
|
||||||
|
parent: entity
|
||||||
|
domain: network
|
||||||
|
layer: infrastructure
|
||||||
|
description: Firewall / port-forward rule.
|
||||||
|
|
||||||
|
# ── Infrastructure / storage ──
|
||||||
|
storage-pool:
|
||||||
|
parent: entity
|
||||||
|
domain: storage
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Storage pool (LVM, ZFS, NFS).
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
type: {type: string}
|
||||||
|
capacity_gb: {type: number}
|
||||||
|
volume:
|
||||||
|
parent: entity
|
||||||
|
domain: storage
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Named volume / dataset within a pool. Mount details live as
|
||||||
|
attributes on `mounts` edges.
|
||||||
|
attributes: {type: object, properties: {size_gb: {type: number}, path: {type: string}}}
|
||||||
|
backup-target:
|
||||||
|
parent: entity
|
||||||
|
domain: storage
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Where backups land (Proton Drive, PBS).
|
||||||
|
attributes: {type: object, properties: {provider: {type: string}, encrypted: {type: boolean}}}
|
||||||
|
dataset:
|
||||||
|
parent: entity
|
||||||
|
domain: storage
|
||||||
|
layer: infrastructure
|
||||||
|
description: Logical data collection worth tracking independently of its
|
||||||
|
volume (e.g. paperless documents).
|
||||||
|
|
||||||
|
# ── Infrastructure / software ──
|
||||||
|
service:
|
||||||
|
parent: entity
|
||||||
|
domain: software
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: A running service with consumers.
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
url: {type: string}
|
||||||
|
port: {type: integer}
|
||||||
|
health: {type: string}
|
||||||
|
endpoint: {type: string}
|
||||||
|
systemd_unit: {type: string}
|
||||||
|
doc_page: {type: string}
|
||||||
|
risk_notes: {type: string}
|
||||||
|
note: {type: string}
|
||||||
|
application:
|
||||||
|
parent: entity
|
||||||
|
domain: software
|
||||||
|
layer: infrastructure
|
||||||
|
description: Deployed application/package a service runs.
|
||||||
|
attributes: {type: object, properties: {version: {type: string}}}
|
||||||
|
config-repo:
|
||||||
|
parent: entity
|
||||||
|
domain: software
|
||||||
|
layer: infrastructure
|
||||||
|
description: Git repo holding tracked configuration.
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties: {url: {type: string}, branch: {type: string}}
|
||||||
|
deploy-pipeline:
|
||||||
|
parent: entity
|
||||||
|
domain: software
|
||||||
|
layer: infrastructure
|
||||||
|
description: Automated deploy path (webhook → script).
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties: {trigger: {type: string}, target_path: {type: string}}
|
||||||
|
package-set:
|
||||||
|
parent: entity
|
||||||
|
domain: software
|
||||||
|
layer: infrastructure
|
||||||
|
description: Managed package baseline for a host class.
|
||||||
|
cluster:
|
||||||
|
parent: entity
|
||||||
|
domain: software
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Proxmox cluster.
|
||||||
|
attributes: {type: object, properties: {quorum: {type: string}}}
|
||||||
|
compose-stack:
|
||||||
|
parent: entity
|
||||||
|
domain: software
|
||||||
|
layer: infrastructure
|
||||||
|
lifecycle: infrastructure
|
||||||
|
description: Docker Compose stack (the Oikos OS itself is one).
|
||||||
|
attributes: {type: object, properties: {path: {type: string}}}
|
||||||
|
|
||||||
|
# ── Infrastructure / external ──
|
||||||
|
domain-registration:
|
||||||
|
parent: entity
|
||||||
|
domain: external
|
||||||
|
layer: infrastructure
|
||||||
|
description: Registered public domain.
|
||||||
|
attributes: {type: object, properties: {registrar: {type: string}, expires: {type: string}}}
|
||||||
|
cloud-service:
|
||||||
|
parent: entity
|
||||||
|
domain: external
|
||||||
|
layer: infrastructure
|
||||||
|
description: External SaaS/cloud dependency.
|
||||||
|
isp-link:
|
||||||
|
parent: entity
|
||||||
|
domain: external
|
||||||
|
layer: infrastructure
|
||||||
|
description: Internet uplink.
|
||||||
|
vendor-dependency:
|
||||||
|
parent: entity
|
||||||
|
domain: external
|
||||||
|
layer: infrastructure
|
||||||
|
description: Vendor the lab depends on (registrar, IONOS, Proton).
|
||||||
|
|
||||||
|
# ── Governance / identity ──
|
||||||
|
person:
|
||||||
|
parent: entity
|
||||||
|
domain: identity
|
||||||
|
layer: governance
|
||||||
|
description: Human actor (operator).
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties: {matrix_id: {type: string}, oidc_sub: {type: string}, email: {type: string}}
|
||||||
|
agent:
|
||||||
|
parent: entity
|
||||||
|
domain: identity
|
||||||
|
layer: governance
|
||||||
|
lifecycle: infrastructure # agents are deployed/retired like infrastructure
|
||||||
|
description: Software agent actor (Hermes, the Oikos control loop).
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
provider: {type: string}
|
||||||
|
model: {type: string}
|
||||||
|
gateway_port: {type: integer}
|
||||||
|
identity-provider:
|
||||||
|
parent: entity
|
||||||
|
domain: identity
|
||||||
|
layer: governance
|
||||||
|
description: OIDC / forward-auth provider (Authentik).
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
issuer: {type: string}
|
||||||
|
client_id: {type: string}
|
||||||
|
auth_mode: {type: string, enum: [oidc, forward-auth, both]}
|
||||||
|
account:
|
||||||
|
parent: entity
|
||||||
|
domain: identity
|
||||||
|
layer: governance
|
||||||
|
description: An account a person/agent holds on a service.
|
||||||
|
secret:
|
||||||
|
parent: entity
|
||||||
|
domain: identity
|
||||||
|
layer: governance
|
||||||
|
description: Managed secret (Infisical path).
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties: {path: {type: string}, rotation_days: {type: integer}}
|
||||||
|
key:
|
||||||
|
parent: entity
|
||||||
|
domain: identity
|
||||||
|
layer: governance
|
||||||
|
description: Cryptographic key (SSH, age).
|
||||||
|
access-grant:
|
||||||
|
parent: entity
|
||||||
|
domain: identity
|
||||||
|
layer: governance
|
||||||
|
description: Grant of access to a secret/scope.
|
||||||
|
attributes: {type: object, properties: {scope: {type: string}, expires: {type: string}}}
|
||||||
|
|
||||||
|
# ── Cognition ──
|
||||||
|
check:
|
||||||
|
parent: entity
|
||||||
|
domain: cognition
|
||||||
|
layer: cognition
|
||||||
|
description: Probe definition (checks-as-data, R3-7). Typed row in check_defs.
|
||||||
|
signal:
|
||||||
|
parent: entity
|
||||||
|
domain: cognition
|
||||||
|
layer: cognition
|
||||||
|
lifecycle: signal
|
||||||
|
description: Something needing attention. Typed row in signals.
|
||||||
|
classification:
|
||||||
|
parent: entity
|
||||||
|
domain: cognition
|
||||||
|
layer: cognition
|
||||||
|
description: A classifier decision with full reasoning. Typed row in classifications.
|
||||||
|
execution:
|
||||||
|
parent: entity
|
||||||
|
domain: cognition
|
||||||
|
layer: cognition
|
||||||
|
lifecycle: execution
|
||||||
|
description: An action the OS performed. Typed row in executions.
|
||||||
|
feedback:
|
||||||
|
parent: entity
|
||||||
|
domain: cognition
|
||||||
|
layer: cognition
|
||||||
|
description: What was learned from an execution. Typed row in feedback.
|
||||||
|
pattern:
|
||||||
|
parent: entity
|
||||||
|
domain: cognition
|
||||||
|
layer: cognition
|
||||||
|
lifecycle: pattern
|
||||||
|
description: Generalized rule extracted from feedback. Typed row in patterns.
|
||||||
|
skill:
|
||||||
|
parent: entity
|
||||||
|
domain: cognition
|
||||||
|
layer: cognition
|
||||||
|
lifecycle: skill
|
||||||
|
description: Codified, versioned procedure. Typed rows in skills.
|
||||||
|
approval:
|
||||||
|
parent: entity
|
||||||
|
domain: cognition
|
||||||
|
layer: cognition
|
||||||
|
lifecycle: approval
|
||||||
|
description: Operator approval request/decision. Typed row in approvals.
|
||||||
|
document:
|
||||||
|
parent: entity
|
||||||
|
domain: cognition
|
||||||
|
layer: cognition
|
||||||
|
description: Knowledge document ingested from docs/.
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties: {title: {type: string}, source_path: {type: string}, content_hash: {type: string}}
|
||||||
|
runbook:
|
||||||
|
parent: entity
|
||||||
|
domain: cognition
|
||||||
|
layer: cognition
|
||||||
|
description: Step-by-step procedure for an entity/action.
|
||||||
|
attributes:
|
||||||
|
type: object
|
||||||
|
properties: {risk_class: {type: string}, source_path: {type: string}}
|
||||||
|
investigation:
|
||||||
|
parent: entity
|
||||||
|
domain: cognition
|
||||||
|
layer: cognition
|
||||||
|
description: Recorded investigation/postmortem.
|
||||||
|
|
||||||
|
# ─── Relationship types ────────────────────────────────────────────────
|
||||||
|
# cardinality is source→target: e.g. `hosts` one-to-many = one machine
|
||||||
|
# hosts many compute entities; each hosted entity has one hosting machine.
|
||||||
|
|
||||||
|
relationship_types:
|
||||||
|
|
||||||
|
# Infrastructure topology
|
||||||
|
hosts:
|
||||||
|
inverse: runs-on
|
||||||
|
source: machine
|
||||||
|
target: compute-entity
|
||||||
|
cardinality: one-to-many
|
||||||
|
description: Machine hosts a VM/container (hubris hosts lxc:apps).
|
||||||
|
runs-hypervisor:
|
||||||
|
inverse: hypervisor-on
|
||||||
|
source: machine
|
||||||
|
target: hypervisor
|
||||||
|
cardinality: one-to-one
|
||||||
|
description: Machine runs hypervisor software.
|
||||||
|
member-of:
|
||||||
|
inverse: has-member
|
||||||
|
source: proxmox-host
|
||||||
|
target: cluster
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: PVE host belongs to a cluster.
|
||||||
|
part-of:
|
||||||
|
inverse: comprises
|
||||||
|
source: docker-container
|
||||||
|
target: compose-stack
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Docker container belongs to a compose stack.
|
||||||
|
provides:
|
||||||
|
inverse: provided-by
|
||||||
|
source: compute-entity
|
||||||
|
target: service
|
||||||
|
cardinality: one-to-many
|
||||||
|
description: Compute entity provides a service (lxc:gitea provides service:gitea).
|
||||||
|
runs:
|
||||||
|
inverse: run-by
|
||||||
|
source: service
|
||||||
|
target: application
|
||||||
|
cardinality: one-to-many
|
||||||
|
description: Service runs an application.
|
||||||
|
configured-by:
|
||||||
|
inverse: configures
|
||||||
|
source: entity
|
||||||
|
target: config-repo
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Entity's config is tracked in a repo (mutations = commit+push).
|
||||||
|
deploys-to:
|
||||||
|
inverse: deployed-by
|
||||||
|
source: deploy-pipeline
|
||||||
|
target: entity
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Pipeline deploys to a service/host.
|
||||||
|
routes-to:
|
||||||
|
inverse: routed-via
|
||||||
|
source: ingress-route
|
||||||
|
target: service
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Public hostname routes to a service.
|
||||||
|
secured-by:
|
||||||
|
inverse: secures
|
||||||
|
source: ingress-route
|
||||||
|
target: identity-provider
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Route gated by forward-auth.
|
||||||
|
uses-certificate:
|
||||||
|
inverse: certifies
|
||||||
|
source: ingress-route
|
||||||
|
target: certificate
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Route served with this certificate.
|
||||||
|
authenticates-via:
|
||||||
|
inverse: authenticates-service
|
||||||
|
source: service
|
||||||
|
target: identity-provider
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Service uses native OIDC (jellyfin authenticates-via authentik).
|
||||||
|
in-zone:
|
||||||
|
inverse: contains-record
|
||||||
|
source: dns-record
|
||||||
|
target: dns-zone
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Record belongs to a zone.
|
||||||
|
resolves-to:
|
||||||
|
inverse: resolved-from
|
||||||
|
source: dns-record
|
||||||
|
target: entity
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Record points at an ingress route or host.
|
||||||
|
depends-on:
|
||||||
|
inverse: dependency-of
|
||||||
|
source: service
|
||||||
|
target: service
|
||||||
|
cardinality: many-to-many
|
||||||
|
description: Runtime dependency (blast-radius edge).
|
||||||
|
connects-via:
|
||||||
|
inverse: connects
|
||||||
|
source: compute-entity
|
||||||
|
target: network
|
||||||
|
cardinality: many-to-many
|
||||||
|
description: Coarse network membership (host on LAN / mesh).
|
||||||
|
has-interface:
|
||||||
|
inverse: interface-of
|
||||||
|
source: compute-entity
|
||||||
|
target: network-interface
|
||||||
|
cardinality: one-to-many
|
||||||
|
description: Optional per-interface refinement.
|
||||||
|
interface-on:
|
||||||
|
inverse: has-endpoint
|
||||||
|
source: network-interface
|
||||||
|
target: network
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Interface attaches to a network.
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
mounts:
|
||||||
|
inverse: mounted-by
|
||||||
|
source: compute-entity
|
||||||
|
target: volume
|
||||||
|
cardinality: many-to-many
|
||||||
|
description: Compute entity mounts a volume. Edge attributes carry
|
||||||
|
mount_point and options.
|
||||||
|
stores-on:
|
||||||
|
inverse: stores-for
|
||||||
|
source: compute-entity
|
||||||
|
target: storage-pool
|
||||||
|
cardinality: many-to-many
|
||||||
|
description: Rootfs/data lives on a pool.
|
||||||
|
contains:
|
||||||
|
inverse: contained-in
|
||||||
|
source: storage-pool
|
||||||
|
target: volume
|
||||||
|
cardinality: one-to-many
|
||||||
|
description: Pool contains a volume.
|
||||||
|
holds-dataset:
|
||||||
|
inverse: dataset-on
|
||||||
|
source: volume
|
||||||
|
target: dataset
|
||||||
|
cardinality: one-to-many
|
||||||
|
description: Volume holds a tracked dataset.
|
||||||
|
backs-up-to:
|
||||||
|
inverse: backup-of
|
||||||
|
source: entity
|
||||||
|
target: backup-target
|
||||||
|
cardinality: many-to-many
|
||||||
|
description: Entity's data is backed up to a target.
|
||||||
|
|
||||||
|
# Physical / external
|
||||||
|
powered-by:
|
||||||
|
inverse: powers
|
||||||
|
source: machine
|
||||||
|
target: ups
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Machine on UPS power.
|
||||||
|
located-at:
|
||||||
|
inverse: location-of
|
||||||
|
source: machine
|
||||||
|
target: site
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Machine's physical site.
|
||||||
|
registered-with:
|
||||||
|
inverse: registrar-of
|
||||||
|
source: domain-registration
|
||||||
|
target: vendor-dependency
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Domain registered with a registrar.
|
||||||
|
|
||||||
|
# Governance
|
||||||
|
owns:
|
||||||
|
inverse: owned-by
|
||||||
|
source: person
|
||||||
|
target: agent
|
||||||
|
cardinality: one-to-many
|
||||||
|
description: Person owns/controls an agent.
|
||||||
|
authenticates:
|
||||||
|
inverse: authenticated-by
|
||||||
|
source: identity-provider
|
||||||
|
target: person
|
||||||
|
cardinality: one-to-many
|
||||||
|
description: IdP authenticates a person.
|
||||||
|
holds-grant:
|
||||||
|
inverse: granted-to
|
||||||
|
source: agent
|
||||||
|
target: access-grant
|
||||||
|
cardinality: one-to-many
|
||||||
|
description: Agent holds an access grant.
|
||||||
|
grants:
|
||||||
|
inverse: granted-by
|
||||||
|
source: access-grant
|
||||||
|
target: secret
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Grant covers a secret.
|
||||||
|
can-decrypt:
|
||||||
|
inverse: readable-by
|
||||||
|
source: compute-entity
|
||||||
|
target: secret
|
||||||
|
cardinality: many-to-many
|
||||||
|
description: Host can decrypt a secret (legacy SOPS; Infisical grants later).
|
||||||
|
|
||||||
|
# Cognition
|
||||||
|
checks:
|
||||||
|
inverse: checked-by
|
||||||
|
source: check
|
||||||
|
target: entity
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Check probes an entity.
|
||||||
|
raises:
|
||||||
|
inverse: raised-by
|
||||||
|
source: check
|
||||||
|
target: signal
|
||||||
|
cardinality: one-to-many
|
||||||
|
description: Check raised a signal.
|
||||||
|
about:
|
||||||
|
inverse: subject-of
|
||||||
|
source: signal
|
||||||
|
target: entity
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Signal concerns an entity.
|
||||||
|
classifies:
|
||||||
|
inverse: classified-as
|
||||||
|
source: classification
|
||||||
|
target: signal
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Classification of a signal.
|
||||||
|
precedes:
|
||||||
|
inverse: follows
|
||||||
|
source: classification
|
||||||
|
target: execution
|
||||||
|
cardinality: one-to-one
|
||||||
|
description: Classification that led to an execution.
|
||||||
|
targets:
|
||||||
|
inverse: targeted-by
|
||||||
|
source: execution
|
||||||
|
target: entity
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Execution acts on an entity.
|
||||||
|
requires-approval:
|
||||||
|
inverse: approves
|
||||||
|
source: execution
|
||||||
|
target: approval
|
||||||
|
cardinality: one-to-one
|
||||||
|
description: Execution gated by an approval.
|
||||||
|
performs:
|
||||||
|
inverse: performed-by
|
||||||
|
source: agent
|
||||||
|
target: execution
|
||||||
|
cardinality: one-to-many
|
||||||
|
description: Agent performed an execution.
|
||||||
|
decides:
|
||||||
|
inverse: decided-by
|
||||||
|
source: person
|
||||||
|
target: approval
|
||||||
|
cardinality: one-to-many
|
||||||
|
description: Person decided an approval.
|
||||||
|
produces:
|
||||||
|
inverse: produced-by
|
||||||
|
source: execution
|
||||||
|
target: feedback
|
||||||
|
cardinality: one-to-one
|
||||||
|
description: Execution produced feedback.
|
||||||
|
contributes-to:
|
||||||
|
inverse: built-from
|
||||||
|
source: feedback
|
||||||
|
target: pattern
|
||||||
|
cardinality: many-to-many
|
||||||
|
description: Feedback supports a pattern.
|
||||||
|
informs:
|
||||||
|
inverse: informed-by
|
||||||
|
source: pattern
|
||||||
|
target: skill
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Pattern informs a skill.
|
||||||
|
guides:
|
||||||
|
inverse: guided-by
|
||||||
|
source: skill
|
||||||
|
target: classification
|
||||||
|
cardinality: one-to-many
|
||||||
|
description: Skill guided a classification.
|
||||||
|
documents:
|
||||||
|
inverse: documented-by
|
||||||
|
source: document
|
||||||
|
target: entity
|
||||||
|
cardinality: many-to-one
|
||||||
|
description: Document describes an entity.
|
||||||
|
procedure-for:
|
||||||
|
inverse: has-procedure
|
||||||
|
source: runbook
|
||||||
|
target: entity
|
||||||
|
cardinality: many-to-many
|
||||||
|
description: Runbook applies to an entity.
|
||||||
103
seeds/policy.yaml
Normal file
103
seeds/policy.yaml
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Oikos policy seed — risk classes, approval rules, autonomy settings.
|
||||||
|
#
|
||||||
|
# Bootstraps risk_classes / approval_rules / autonomy_settings (migration
|
||||||
|
# 005). After ingest the DB is authoritative; runtime policy changes go
|
||||||
|
# through the dual-control meta-approval flow (plan S3) and are exported
|
||||||
|
# back here via `GET /api/v1/export`.
|
||||||
|
#
|
||||||
|
# Adapted from legacy oikos/policy.yaml (2026-07-07):
|
||||||
|
# - `commands:`/`mcp_tools:` maps are gone — read endpoints and MCP read
|
||||||
|
# tools are read_only by construction and never classified; mutating
|
||||||
|
# API calls classify via approval_rules below.
|
||||||
|
# - `actions:` map became approval_rules keyed on (entity_type, action);
|
||||||
|
# entity types may be abstract (rule inherits down the hierarchy,
|
||||||
|
# most-specific match wins: scope_entity > concrete type > ancestor).
|
||||||
|
# - `service_overrides:` became scope_entity rules.
|
||||||
|
# - `lifecycle_overrides:` became autonomy_settings keys read by the
|
||||||
|
# classifier.
|
||||||
|
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
risk_classes:
|
||||||
|
read_only:
|
||||||
|
description: Observes state; cannot change anything.
|
||||||
|
approval_required: none
|
||||||
|
autonomy_allowed: true
|
||||||
|
reversible_low:
|
||||||
|
description: >-
|
||||||
|
Changes runtime state in a way a single follow-up command undoes
|
||||||
|
(restart, cache clear, sync pull). No config or data changes.
|
||||||
|
approval_required: none # still subject to global.auto_act + rules below
|
||||||
|
autonomy_allowed: true
|
||||||
|
config_mutation:
|
||||||
|
description: >-
|
||||||
|
Changes tracked configuration or deployed software: repo edit + push,
|
||||||
|
deploy trigger, Caddy/Gitea/app config, package upgrades. Reversible
|
||||||
|
via git, but affects other consumers.
|
||||||
|
approval_required: operator # Matrix ✅/❌ (single-use HMAC token)
|
||||||
|
autonomy_allowed: false
|
||||||
|
destructive:
|
||||||
|
description: >-
|
||||||
|
Destroys or irreversibly alters data/entities: container destroy,
|
||||||
|
disk format, DB wipe, secret rotation, client revocation.
|
||||||
|
approval_required: operator_confirmed
|
||||||
|
autonomy_allowed: false
|
||||||
|
|
||||||
|
approval_rules:
|
||||||
|
# ── Generic rules on (possibly abstract) types ──
|
||||||
|
- {entity_type: service, action: restart, risk_class: reversible_low, autonomy_level: auto}
|
||||||
|
- {entity_type: service, action: cache-clear, risk_class: reversible_low, autonomy_level: auto}
|
||||||
|
- {entity_type: service, action: sync-pull, risk_class: reversible_low, autonomy_level: auto}
|
||||||
|
- {entity_type: service, action: db-wipe, risk_class: destructive, autonomy_level: never}
|
||||||
|
- {entity_type: compose-stack, action: restart, risk_class: reversible_low, autonomy_level: auto}
|
||||||
|
- {entity_type: docker-container, action: restart, risk_class: reversible_low, autonomy_level: auto}
|
||||||
|
- {entity_type: machine, action: apt-upgrade, risk_class: config_mutation, autonomy_level: escalate}
|
||||||
|
- {entity_type: machine, action: reboot, risk_class: config_mutation, autonomy_level: escalate}
|
||||||
|
- {entity_type: machine, action: format-disk, risk_class: destructive, autonomy_level: never}
|
||||||
|
- {entity_type: config-repo, action: edit, risk_class: config_mutation, autonomy_level: escalate}
|
||||||
|
- {entity_type: deploy-pipeline, action: trigger, risk_class: config_mutation, autonomy_level: escalate}
|
||||||
|
- {entity_type: lxc, action: create, risk_class: config_mutation, autonomy_level: escalate}
|
||||||
|
- {entity_type: lxc, action: migrate, risk_class: config_mutation, autonomy_level: escalate}
|
||||||
|
- {entity_type: lxc, action: restart, risk_class: reversible_low, autonomy_level: auto}
|
||||||
|
- {entity_type: lxc, action: destroy, risk_class: destructive, autonomy_level: never}
|
||||||
|
- {entity_type: vm, action: restart, risk_class: reversible_low, autonomy_level: auto}
|
||||||
|
- {entity_type: vm, action: destroy, risk_class: destructive, autonomy_level: never}
|
||||||
|
- {entity_type: dns-record, action: change, risk_class: config_mutation, autonomy_level: escalate}
|
||||||
|
- {entity_type: ingress-route, action: change, risk_class: config_mutation, autonomy_level: escalate}
|
||||||
|
- {entity_type: storage-pool, action: change, risk_class: destructive, autonomy_level: never}
|
||||||
|
- {entity_type: secret, action: rotate, risk_class: destructive, autonomy_level: never}
|
||||||
|
- {entity_type: key, action: revoke, risk_class: destructive, autonomy_level: never}
|
||||||
|
|
||||||
|
# ── Governance objects (the OS's own levers — always operator-gated) ──
|
||||||
|
- {entity_type: pattern, action: activate, risk_class: config_mutation, autonomy_level: never}
|
||||||
|
- {entity_type: skill, action: activate, risk_class: config_mutation, autonomy_level: never}
|
||||||
|
|
||||||
|
# ── Per-entity overrides (wide blast radius) ──
|
||||||
|
- {entity_type: service, action: restart, scope_entity: "service:caddy",
|
||||||
|
risk_class: config_mutation, autonomy_level: escalate}
|
||||||
|
# everything *.hubris.network rides on caddy
|
||||||
|
- {entity_type: service, action: restart, scope_entity: "service:dns",
|
||||||
|
risk_class: config_mutation, autonomy_level: escalate}
|
||||||
|
# LAN-wide resolver
|
||||||
|
- {entity_type: service, action: restart, scope_entity: "service:authentik",
|
||||||
|
risk_class: config_mutation, autonomy_level: escalate}
|
||||||
|
# SSO provider — restart locks logins fleet-wide
|
||||||
|
|
||||||
|
autonomy_settings:
|
||||||
|
# Global kill-switch. Cold start = off: the agent escalates everything
|
||||||
|
# until patterns validate and the operator raises this (plan: trust is earned).
|
||||||
|
global.auto_act: "off" # off | reversible_low
|
||||||
|
|
||||||
|
# Per-entity hard blocks (checked even when global.auto_act is on)
|
||||||
|
never_auto_act.service:caddy: "true"
|
||||||
|
never_auto_act.service:dns: "true"
|
||||||
|
never_auto_act.service:authentik: "true"
|
||||||
|
never_auto_act.host:hubris: "true"
|
||||||
|
never_auto_act.host:strong: "true"
|
||||||
|
|
||||||
|
# Lifecycle-state classifier overrides (from legacy lifecycle_overrides)
|
||||||
|
lifecycle_override.provisioning.config_mutation: reversible_low
|
||||||
|
# no dependents yet — config changes are cheap
|
||||||
|
lifecycle_override.deprecated.refuse: new-inbound-edges
|
||||||
|
lifecycle_override.destroyed.refuse: all
|
||||||
|
# any action targeting a destroyed entity raises a drift signal instead
|
||||||
39
sqlc.yaml
Normal file
39
sqlc.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# sqlc — type-safe Go from SQL (plan SG17). `make generate` regenerates.
|
||||||
|
#
|
||||||
|
# Scope: API read/mutation paths use sqlc-generated queries
|
||||||
|
# (internal/db/sqlcgen). The seed ingest and YAML export intentionally stay
|
||||||
|
# hand-written pgx: they are generic bulk upserts driven by parsed YAML
|
||||||
|
# shapes, where sqlc's static typing adds nothing.
|
||||||
|
version: "2"
|
||||||
|
sql:
|
||||||
|
- engine: "postgresql"
|
||||||
|
schema: "migrations"
|
||||||
|
queries: "internal/db/queries"
|
||||||
|
strict_order_by: false
|
||||||
|
gen:
|
||||||
|
go:
|
||||||
|
package: "sqlcgen"
|
||||||
|
out: "internal/db/sqlcgen"
|
||||||
|
sql_package: "pgx/v5"
|
||||||
|
emit_pointers_for_null_types: true
|
||||||
|
overrides:
|
||||||
|
- db_type: "uuid"
|
||||||
|
go_type:
|
||||||
|
import: "github.com/google/uuid"
|
||||||
|
type: "UUID"
|
||||||
|
- db_type: "uuid"
|
||||||
|
nullable: true
|
||||||
|
go_type:
|
||||||
|
import: "github.com/google/uuid"
|
||||||
|
type: "UUID"
|
||||||
|
pointer: true
|
||||||
|
- db_type: "timestamptz"
|
||||||
|
go_type:
|
||||||
|
import: "time"
|
||||||
|
type: "Time"
|
||||||
|
- db_type: "timestamptz"
|
||||||
|
nullable: true
|
||||||
|
go_type:
|
||||||
|
import: "time"
|
||||||
|
type: "Time"
|
||||||
|
pointer: true
|
||||||
Reference in New Issue
Block a user