From 64f7d5401155d5134b16e2114f9245f3d5166dae Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 15 Aug 2026 22:56:56 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20ports=20package?= =?UTF-8?q?,=20secrets=20port=20move,=20postgres=20adapter=20move?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the hexagon's Phase 2 (plans/2026-08-15-hexagonal-architecture.md) must give the use-cases-to-be their contract surface: driven-port interfaces, test fakes, the secrets interface moved into core, and the postgres package inside the adapters tree — before the first vertical slice (Phase 3) can wire a composition root. Change: - internal/core/ports: full driven-port catalog per plan §3.3 — repositories as transaction-scoped aggregates whose inputs carry derived checks, audit, and events (§3.6), plus CommandExecutor, TargetResolver, Checker, Secrets, EventPublisher, Provisioner. Port-local payload types (Event, AuditEntry, CheckDef, KnowledgeEntry, ExecResult) keep signatures off infrastructure; TypeTree aliases internal/ontology (pure over domain) until checkdefaults is absorbed. ReadModels intentionally not declared yet — it materializes with the Phase 3 slice and grows as report handlers rewire. - secrets.Backend is now an alias of ports.Secrets; implementations (Infisical, SOPS, Manager) unchanged. mcp's local secretBackend subset is deleted; tool constructors take ports.Secrets. - internal/db → internal/adapters/postgres (mechanical import rewrite; package identifier stays db until the Phase 3 repository split). sqlc.yaml, Makefile, golangci exclusions, and docs follow the move; make generate-check verified. - internal/adapters/ssh: Executor implements ports.CommandExecutor over the actuator dial pool + RunStreaming (10-min default timeout carried over from the httpapi path). - internal/adapters/remote: Resolver implements ports.TargetResolver delegating to internal/remote (still pool-based; drops onto ports.EntityRepository when repositories land in Phase 3 — documented transitional import). - internal/core/ports/portstest: importable fakes — in-memory EntityRepo (with check-then-act SetState, side-effect recording), RecordingExecutor, FakeChecker, SpyPublisher; port-satisfaction guards; tests. Risk: ports are declared ahead of implementations — signatures firm up per phase as slices land (documented in the package doc); the remote→postgres transitional import is explicit and dissolves in Phase 3. Verification: go vet, make test (race, 19 packages), generate-check, golangci on core+adapters — 0 issues; full-repo baseline down 365→344. --- .agents/dev/CONTRIBUTING.md | 8 +- .golangci.yml | 2 +- CONTRIBUTING.md | 8 +- Makefile | 4 +- VERSION | 2 +- cmd/nomos/store.go | 2 +- cmd/nomos/store_test.go | 2 +- cmd/oikos/main.go | 2 +- internal/actuator/actuator.go | 4 +- internal/actuator/hostkeys_infisical.go | 2 +- internal/{db => adapters/postgres}/checks.go | 0 internal/adapters/postgres/doc.go | 6 + .../{db => adapters/postgres}/entity_cache.go | 7 + internal/{db => adapters/postgres}/export.go | 0 .../postgres}/integration_test.go | 0 .../{db => adapters/postgres}/lifecycle.go | 2 +- .../postgres}/lifecycle_test.go | 0 internal/{db => adapters/postgres}/pool.go | 12 +- .../postgres}/queries/entities.sql | 0 .../postgres}/queries/ontology.sql | 0 .../postgres}/queries/operations.sql | 0 .../postgres}/queries/relationships.sql | 0 internal/{db => adapters/postgres}/seed.go | 0 .../postgres}/splitsql_test.go | 0 .../{db => adapters/postgres}/sqlcgen/db.go | 0 .../postgres}/sqlcgen/entities.sql.go | 0 .../postgres}/sqlcgen/models.go | 0 .../postgres}/sqlcgen/ontology.sql.go | 0 .../postgres}/sqlcgen/operations.sql.go | 0 .../postgres}/sqlcgen/relationships.sql.go | 0 .../{db => adapters/postgres}/typetree.go | 0 internal/adapters/remote/resolver.go | 63 +++++ internal/adapters/ssh/executor.go | 114 +++++++++ internal/audit/audit.go | 2 +- internal/audit/audit_test.go | 2 +- internal/audit/testutil_test.go | 2 +- internal/core/ports/doc.go | 12 + internal/core/ports/entities.go | 83 ++++++ internal/core/ports/events.go | 45 ++++ internal/core/ports/execution.go | 74 ++++++ internal/core/ports/governance.go | 97 +++++++ internal/core/ports/knowledge.go | 54 ++++ internal/core/ports/observation.go | 61 +++++ internal/core/ports/portstest/fakes.go | 242 ++++++++++++++++++ internal/core/ports/portstest/fakes_test.go | 121 +++++++++ internal/core/ports/secrets.go | 14 + internal/execlog/execlog.go | 4 +- internal/execworker/init.go | 2 +- internal/execworker/worker.go | 4 +- internal/httpapi/actuator.go | 4 +- internal/httpapi/api_test.go | 2 +- internal/httpapi/approval_rules.go | 2 +- internal/httpapi/approvals.go | 2 +- internal/httpapi/autonomy.go | 2 +- internal/httpapi/checks.go | 2 +- internal/httpapi/client_lifecycle.go | 2 +- internal/httpapi/default_checks.go | 2 +- internal/httpapi/entities.go | 2 +- internal/httpapi/entity_mutations.go | 4 +- internal/httpapi/entity_types.go | 2 +- internal/httpapi/executions.go | 2 +- internal/httpapi/fleet_health.go | 2 +- internal/httpapi/impl.go | 2 +- internal/httpapi/ontology.go | 2 +- internal/httpapi/patterns.go | 2 +- internal/httpapi/relationships.go | 2 +- internal/httpapi/server.go | 2 +- internal/httpapi/skills.go | 2 +- internal/httpapi/sse.go | 2 +- internal/learning/learning.go | 4 +- internal/learning/learning_test.go | 2 +- internal/mcp/analysis_tools.go | 5 +- internal/mcp/create_entity_test.go | 2 +- internal/mcp/discover.go | 2 +- internal/mcp/entity_tools.go | 7 +- internal/mcp/knowledge_tools.go | 5 +- internal/mcp/ops_tools.go | 7 +- internal/mcp/server.go | 18 +- internal/mcp/tools.go | 5 +- internal/observability/record.go | 2 +- internal/remote/remote.go | 2 +- internal/remote/remote_test.go | 2 +- internal/remote/testutil_test.go | 2 +- internal/scheduler/backup.go | 2 +- internal/scheduler/backup_test.go | 2 +- internal/scheduler/coverage.go | 2 +- internal/scheduler/coverage_test.go | 2 +- internal/scheduler/init.go | 2 +- internal/scheduler/scheduler.go | 4 +- internal/secrets/backend.go | 13 +- plans/2026-08-15-hexagonal-architecture.md | 2 +- plans/index.md | 2 +- sqlc.yaml | 6 +- 93 files changed, 1102 insertions(+), 103 deletions(-) rename internal/{db => adapters/postgres}/checks.go (100%) create mode 100644 internal/adapters/postgres/doc.go rename internal/{db => adapters/postgres}/entity_cache.go (75%) rename internal/{db => adapters/postgres}/export.go (100%) rename internal/{db => adapters/postgres}/integration_test.go (100%) rename internal/{db => adapters/postgres}/lifecycle.go (99%) rename internal/{db => adapters/postgres}/lifecycle_test.go (100%) rename internal/{db => adapters/postgres}/pool.go (95%) rename internal/{db => adapters/postgres}/queries/entities.sql (100%) rename internal/{db => adapters/postgres}/queries/ontology.sql (100%) rename internal/{db => adapters/postgres}/queries/operations.sql (100%) rename internal/{db => adapters/postgres}/queries/relationships.sql (100%) rename internal/{db => adapters/postgres}/seed.go (100%) rename internal/{db => adapters/postgres}/splitsql_test.go (100%) rename internal/{db => adapters/postgres}/sqlcgen/db.go (100%) rename internal/{db => adapters/postgres}/sqlcgen/entities.sql.go (100%) rename internal/{db => adapters/postgres}/sqlcgen/models.go (100%) rename internal/{db => adapters/postgres}/sqlcgen/ontology.sql.go (100%) rename internal/{db => adapters/postgres}/sqlcgen/operations.sql.go (100%) rename internal/{db => adapters/postgres}/sqlcgen/relationships.sql.go (100%) rename internal/{db => adapters/postgres}/typetree.go (100%) create mode 100644 internal/adapters/remote/resolver.go create mode 100644 internal/adapters/ssh/executor.go create mode 100644 internal/core/ports/entities.go create mode 100644 internal/core/ports/events.go create mode 100644 internal/core/ports/execution.go create mode 100644 internal/core/ports/governance.go create mode 100644 internal/core/ports/knowledge.go create mode 100644 internal/core/ports/observation.go create mode 100644 internal/core/ports/portstest/fakes.go create mode 100644 internal/core/ports/portstest/fakes_test.go create mode 100644 internal/core/ports/secrets.go diff --git a/.agents/dev/CONTRIBUTING.md b/.agents/dev/CONTRIBUTING.md index 8105abf2..a3adf0ed 100644 --- a/.agents/dev/CONTRIBUTING.md +++ b/.agents/dev/CONTRIBUTING.md @@ -14,9 +14,9 @@ cmd/webhook/main.go Gitea deploy-webhook receiver (push-to-deploy on mac- internal/httpapi/ REST + MCP server. Chi router. OpenAPI-generated types from internal/httpapi/gen/api.gen.go. Strict server in impl.go. internal/mcp/ MCP tool implementations (get_entity, search_knowledge, etc.) -internal/db/ Connection pool (pool.go), seed ingestion (seed.go), DB→YAML +internal/adapters/postgres/ Connection pool (pool.go), seed ingestion (seed.go), DB→YAML export (export.go), type hierarchy (typetree.go) -internal/db/queries/ SQL query files → sqlc generates internal/db/sqlcgen/ +internal/adapters/postgres/queries/ SQL query files → sqlc generates sqlcgen/ (same dir) internal/scheduler/ Observe loop: probes, signals, check_defs internal/actuator/ SSH execution with circuit breaker + retry internal/learning/ Pattern extraction, anomaly detection @@ -91,8 +91,8 @@ current phase status). To add a new capability: ## SQL conventions -- Queries live in `internal/db/queries/*.sql` with `-- name: FuncName :exec` - annotations for sqlc. Generated code in `internal/db/sqlcgen/` — never +- Queries live in `internal/adapters/postgres/queries/*.sql` with `-- name: FuncName :exec` + annotations for sqlc. Generated code in `internal/adapters/postgres/sqlcgen/` — never hand-edit. Call via `sqlcgen.New(pool).QueryName(ctx, params)`. - **sqlc is the default** for all DB access. Raw `pool.Query/Exec` with inline SQL is a documented carve-out for cases sqlc can't express: `LISTEN`/`NOTIFY`, diff --git a/.golangci.yml b/.golangci.yml index 02dfa34f..19ffd3af 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -72,7 +72,7 @@ linters: path: internal/httpapi/gen/ - linters: - all - path: internal/db/sqlcgen/ + path: internal/adapters/postgres/sqlcgen/ paths: - third_party$ - builtin$ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23f69dc5..18f1bd70 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,9 @@ internal/ All Go packages here phase by phase httpapi/ REST + MCP server (OpenAPI-generated) mcp/ MCP tool implementations - db/ Connection pool, migrations, seeds, sqlc queries + db/ (moved) → internal/adapters/postgres: pool, migrations, + seeds, sqlc queries — package still named `db` until + the Phase 3 repository split scheduler/ Observe loop, probes, signals actuator/ SSH execution learning/ Pattern recognition, anomaly detection @@ -116,8 +118,8 @@ Never hand-edit `internal/httpapi/gen/api.gen.go`. ### Database access is sqlc-first -SQL queries live in `internal/db/queries/*.sql`. Go code is generated with -`sqlc` into `internal/db/sqlcgen/`. Config in `sqlc.yaml`. +SQL queries live in `internal/adapters/postgres/queries/*.sql`. Go code is generated with +`sqlc` into `internal/adapters/postgres/sqlcgen/`. Config in `sqlc.yaml`. - Queries target pgx/v5 with UUID + timestamptz overrides - Never hand-edit generated sqlc code diff --git a/Makefile b/Makefile index 40fa3d2b..86309d90 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ 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/ + $(GO) test -race -count=1 ./internal/adapters/postgres/ ./internal/httpapi/ ./internal/mcp/ lint: vet golangci govulncheck @@ -40,7 +40,7 @@ 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 \ + @git diff --exit-code -- internal/httpapi/gen internal/adapters/postgres/sqlcgen \ || (echo "generated code is stale — run 'make generate' and commit" && exit 1) migrate: diff --git a/VERSION b/VERSION index 989b29cc..d721c768 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.32.2 +0.32.3 diff --git a/cmd/nomos/store.go b/cmd/nomos/store.go index 7d4ec994..ae47ee22 100644 --- a/cmd/nomos/store.go +++ b/cmd/nomos/store.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/observability" "github.com/google/uuid" "github.com/jackc/pgx/v5" diff --git a/cmd/nomos/store_test.go b/cmd/nomos/store_test.go index 4204c71c..9c0dedda 100644 --- a/cmd/nomos/store_test.go +++ b/cmd/nomos/store_test.go @@ -17,7 +17,7 @@ import ( "strings" "testing" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) diff --git a/cmd/oikos/main.go b/cmd/oikos/main.go index 2fd2dd8e..9308794b 100644 --- a/cmd/oikos/main.go +++ b/cmd/oikos/main.go @@ -11,7 +11,7 @@ import ( "syscall" "github.com/dtoro/oikos/internal/config" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/dtoro/oikos/internal/execworker" "github.com/dtoro/oikos/internal/httpapi" "github.com/dtoro/oikos/internal/knowledge" diff --git a/internal/actuator/actuator.go b/internal/actuator/actuator.go index 8ce74abb..d15b4cb2 100644 --- a/internal/actuator/actuator.go +++ b/internal/actuator/actuator.go @@ -14,8 +14,8 @@ import ( "time" "github.com/dtoro/oikos/internal/config" - "github.com/dtoro/oikos/internal/db" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/google/uuid" ) diff --git a/internal/actuator/hostkeys_infisical.go b/internal/actuator/hostkeys_infisical.go index a0cb4007..e10bce19 100644 --- a/internal/actuator/hostkeys_infisical.go +++ b/internal/actuator/hostkeys_infisical.go @@ -4,7 +4,7 @@ import ( "context" "log/slog" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/dtoro/oikos/internal/secrets" ) diff --git a/internal/db/checks.go b/internal/adapters/postgres/checks.go similarity index 100% rename from internal/db/checks.go rename to internal/adapters/postgres/checks.go diff --git a/internal/adapters/postgres/doc.go b/internal/adapters/postgres/doc.go new file mode 100644 index 00000000..0e13b8db --- /dev/null +++ b/internal/adapters/postgres/doc.go @@ -0,0 +1,6 @@ +// Package db is the postgres adapter: connection pool, migrations, seed +// ingest, and sqlc-generated queries. It moved from internal/db in Phase 2 +// of the hexagonal refactor (ADR 0016); the package identifier stays `db` +// until the repository split (Phase 3) renames it alongside the first +// ports implementations landing here. +package db diff --git a/internal/db/entity_cache.go b/internal/adapters/postgres/entity_cache.go similarity index 75% rename from internal/db/entity_cache.go rename to internal/adapters/postgres/entity_cache.go index fc2d7ebf..b50a2692 100644 --- a/internal/db/entity_cache.go +++ b/internal/adapters/postgres/entity_cache.go @@ -12,12 +12,15 @@ type entityCacheEntry struct { exp time.Time } +// EntityCache is a TTL cache mapping entity IDs to slugs and back, +// keyed for the hot resolution paths. type EntityCache struct { mu sync.RWMutex m map[string]entityCacheEntry ttl time.Duration } +// NewEntityCache builds a cache with the given TTL. func NewEntityCache(ttl time.Duration) *EntityCache { return &EntityCache{ m: make(map[string]entityCacheEntry), @@ -25,6 +28,7 @@ func NewEntityCache(ttl time.Duration) *EntityCache { } } +// GetSlug resolves an entity ID to its slug. func (c *EntityCache) GetSlug(id string) (string, bool) { c.mu.RLock() e, ok := c.m[id] @@ -35,6 +39,7 @@ func (c *EntityCache) GetSlug(id string) (string, bool) { return e.slug, true } +// GetID resolves a slug to its entity ID. func (c *EntityCache) GetID(slug string) (string, bool) { c.mu.RLock() e, ok := c.m[slug] @@ -45,6 +50,7 @@ func (c *EntityCache) GetID(slug string) (string, bool) { return e.id, true } +// Set records the slug/id pair and serialized attributes. func (c *EntityCache) Set(slug, id, attrs string) { exp := time.Now().Add(c.ttl) c.mu.Lock() @@ -53,6 +59,7 @@ func (c *EntityCache) Set(slug, id, attrs string) { c.mu.Unlock() } +// Invalidate drops the cached entries for one slug/id pair. func (c *EntityCache) Invalidate(slug, id string) { c.mu.Lock() delete(c.m, slug) diff --git a/internal/db/export.go b/internal/adapters/postgres/export.go similarity index 100% rename from internal/db/export.go rename to internal/adapters/postgres/export.go diff --git a/internal/db/integration_test.go b/internal/adapters/postgres/integration_test.go similarity index 100% rename from internal/db/integration_test.go rename to internal/adapters/postgres/integration_test.go diff --git a/internal/db/lifecycle.go b/internal/adapters/postgres/lifecycle.go similarity index 99% rename from internal/db/lifecycle.go rename to internal/adapters/postgres/lifecycle.go index 966eef58..152726ce 100644 --- a/internal/db/lifecycle.go +++ b/internal/adapters/postgres/lifecycle.go @@ -6,7 +6,7 @@ import ( "errors" "fmt" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) diff --git a/internal/db/lifecycle_test.go b/internal/adapters/postgres/lifecycle_test.go similarity index 100% rename from internal/db/lifecycle_test.go rename to internal/adapters/postgres/lifecycle_test.go diff --git a/internal/db/pool.go b/internal/adapters/postgres/pool.go similarity index 95% rename from internal/db/pool.go rename to internal/adapters/postgres/pool.go index a8fbffe3..81023e2e 100644 --- a/internal/db/pool.go +++ b/internal/adapters/postgres/pool.go @@ -56,7 +56,11 @@ func (p *Pool) Migrate(ctx context.Context) error { 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) + defer func() { + if _, err := conn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", migrationLockKey); err != nil { + slog.Warn("postgres: release migration lock failed", "error", err) + } + }() // Create tracking table if not exists _, err = conn.Exec(ctx, ` @@ -156,7 +160,11 @@ func (p *Pool) SeedIngest(ctx context.Context, filename string, content []byte, if err != nil { return fmt.Errorf("begin tx: %w", err) } - defer tx.Rollback(ctx) + defer func() { + if err := tx.Rollback(ctx); err != nil { + slog.Debug("postgres: rollback after failed ingest", "error", err) + } + }() if err := ingestFn(ctx, tx, data); err != nil { return fmt.Errorf("ingest %s: %w", filename, err) diff --git a/internal/db/queries/entities.sql b/internal/adapters/postgres/queries/entities.sql similarity index 100% rename from internal/db/queries/entities.sql rename to internal/adapters/postgres/queries/entities.sql diff --git a/internal/db/queries/ontology.sql b/internal/adapters/postgres/queries/ontology.sql similarity index 100% rename from internal/db/queries/ontology.sql rename to internal/adapters/postgres/queries/ontology.sql diff --git a/internal/db/queries/operations.sql b/internal/adapters/postgres/queries/operations.sql similarity index 100% rename from internal/db/queries/operations.sql rename to internal/adapters/postgres/queries/operations.sql diff --git a/internal/db/queries/relationships.sql b/internal/adapters/postgres/queries/relationships.sql similarity index 100% rename from internal/db/queries/relationships.sql rename to internal/adapters/postgres/queries/relationships.sql diff --git a/internal/db/seed.go b/internal/adapters/postgres/seed.go similarity index 100% rename from internal/db/seed.go rename to internal/adapters/postgres/seed.go diff --git a/internal/db/splitsql_test.go b/internal/adapters/postgres/splitsql_test.go similarity index 100% rename from internal/db/splitsql_test.go rename to internal/adapters/postgres/splitsql_test.go diff --git a/internal/db/sqlcgen/db.go b/internal/adapters/postgres/sqlcgen/db.go similarity index 100% rename from internal/db/sqlcgen/db.go rename to internal/adapters/postgres/sqlcgen/db.go diff --git a/internal/db/sqlcgen/entities.sql.go b/internal/adapters/postgres/sqlcgen/entities.sql.go similarity index 100% rename from internal/db/sqlcgen/entities.sql.go rename to internal/adapters/postgres/sqlcgen/entities.sql.go diff --git a/internal/db/sqlcgen/models.go b/internal/adapters/postgres/sqlcgen/models.go similarity index 100% rename from internal/db/sqlcgen/models.go rename to internal/adapters/postgres/sqlcgen/models.go diff --git a/internal/db/sqlcgen/ontology.sql.go b/internal/adapters/postgres/sqlcgen/ontology.sql.go similarity index 100% rename from internal/db/sqlcgen/ontology.sql.go rename to internal/adapters/postgres/sqlcgen/ontology.sql.go diff --git a/internal/db/sqlcgen/operations.sql.go b/internal/adapters/postgres/sqlcgen/operations.sql.go similarity index 100% rename from internal/db/sqlcgen/operations.sql.go rename to internal/adapters/postgres/sqlcgen/operations.sql.go diff --git a/internal/db/sqlcgen/relationships.sql.go b/internal/adapters/postgres/sqlcgen/relationships.sql.go similarity index 100% rename from internal/db/sqlcgen/relationships.sql.go rename to internal/adapters/postgres/sqlcgen/relationships.sql.go diff --git a/internal/db/typetree.go b/internal/adapters/postgres/typetree.go similarity index 100% rename from internal/db/typetree.go rename to internal/adapters/postgres/typetree.go diff --git a/internal/adapters/remote/resolver.go b/internal/adapters/remote/resolver.go new file mode 100644 index 00000000..0a5d5d48 --- /dev/null +++ b/internal/adapters/remote/resolver.go @@ -0,0 +1,63 @@ +// Package remote implements ports.TargetResolver over internal/remote. +// The resolver logic (address preference, guest wrapping, hosting-compute +// walks) is unchanged; this adapter maps its results onto the port types. +// When the postgres repositories land (Phase 3+), the underlying functions +// move into this package on top of ports.EntityRepository. +package remote + +import ( + "context" + + "github.com/google/uuid" + + postgres "github.com/dtoro/oikos/internal/adapters/postgres" + "github.com/dtoro/oikos/internal/core/domain" + "github.com/dtoro/oikos/internal/core/ports" + intremote "github.com/dtoro/oikos/internal/remote" +) + +// Resolver resolves execution targets from the entity graph. +type Resolver struct { + pool *postgres.Pool +} + +var _ ports.TargetResolver = (*Resolver)(nil) + +// NewResolver builds a resolver over the postgres pool. +func NewResolver(pool *postgres.Pool) *Resolver { return &Resolver{pool: pool} } + +func toPort(t intremote.ExecTarget) ports.Target { + return ports.Target{Host: t.Host, User: t.User, Wrap: t.Wrap} +} + +// ResolveExecTarget resolves a slug to its execution endpoint. +func (r *Resolver) ResolveExecTarget(ctx context.Context, targetSlug string) (ports.Target, error) { + t, err := intremote.ResolveExecTarget(ctx, r.pool, targetSlug, intremote.DefaultUser) + if err != nil { + return ports.Target{}, err + } + return toPort(t), nil +} + +// ResolveForCheck resolves a check's entity (by ID and type) to its endpoint. +func (r *Resolver) ResolveForCheck(ctx context.Context, targetID domain.UUID, targetType string) (ports.Target, error) { + id, err := uuid.Parse(string(targetID)) + if err != nil { + return ports.Target{}, err + } + t, err := intremote.ResolveExecTargetForCheck(ctx, r.pool, id, targetType, intremote.DefaultUser) + if err != nil { + return ports.Target{}, err + } + return toPort(t), nil +} + +// ResolveHost resolves a host slug to address and SSH user. +func (r *Resolver) ResolveHost(ctx context.Context, hostSlug, fallbackUser string) (string, string, error) { + return intremote.ResolveHost(ctx, r.pool, hostSlug, fallbackUser) +} + +// IsGuest reports whether an entity type is reached via pct/qm exec. +func (r *Resolver) IsGuest(entityType string) bool { + return intremote.IsGuest(entityType) +} diff --git a/internal/adapters/ssh/executor.go b/internal/adapters/ssh/executor.go new file mode 100644 index 00000000..8e6b3e95 --- /dev/null +++ b/internal/adapters/ssh/executor.go @@ -0,0 +1,114 @@ +// Package ssh implements ports.CommandExecutor over internal/actuator: +// the dial pool, host-key handling, and streaming/combined execution. +package ssh + +import ( + "context" + "fmt" + "log/slog" + "os" + "sync" + "time" + + cryptossh "golang.org/x/crypto/ssh" + + "github.com/dtoro/oikos/internal/actuator" + "github.com/dtoro/oikos/internal/core/ports" +) + +const ( + defaultExecTimeout = 10 * time.Minute + defaultKeyPathEnv = "OIKOS_SSH_KEY_PATH" + defaultKeyPath = "/etc/oikos/ssh_key" +) + +// SignerSource supplies the SSH signer used for all dials. The secrets +// adapter provides one backed by Infisical/SOPS; tests inject a static one. +type SignerSource func(ctx context.Context) (cryptossh.Signer, error) + +// FileSignerSource reads an OpenSSH private key from disk once and parses +// it (path from env OIKOS_SSH_KEY_PATH, default /etc/oikos/ssh_key — the +// same resolution the httpapi path used before the extraction). +func FileSignerSource() SignerSource { + var ( + once sync.Once + signer cryptossh.Signer + err error + ) + return func(context.Context) (cryptossh.Signer, error) { + once.Do(func() { + path := os.Getenv(defaultKeyPathEnv) + if path == "" { + path = defaultKeyPath + } + key, rerr := os.ReadFile(path) + if rerr != nil { + err = fmt.Errorf("read ssh key %s: %w", path, rerr) + return + } + signer, err = actuator.LoadSignerFromBytes(key) + }) + return signer, err + } +} + +// Executor runs commands over SSH through a dial pool. +type Executor struct { + signer SignerSource + pool *actuator.DialPool +} + +var _ ports.CommandExecutor = (*Executor)(nil) + +// NewExecutor builds an executor. The dial pool reuses connections per +// host/user for the given TTL. +func NewExecutor(signer SignerSource, poolTTL time.Duration) *Executor { + return &Executor{ + signer: signer, + pool: actuator.NewDialPool(poolTTL), + } +} + +// Close releases pooled connections. +func (e *Executor) Close() { e.pool.Close() } + +// Run dials the target (via the pool), wraps the command for transport when +// the target needs it (pct/qm guests), executes with streaming output, and +// maps the outcome onto ports.ExecResult. +func (e *Executor) Run(ctx context.Context, target ports.Target, command string, opts ports.ExecOpts) ports.ExecResult { + start := time.Now() + + signer, err := e.signer(ctx) + if err != nil { + return ports.ExecResult{Err: err, Duration: time.Since(start)} + } + + client, err := e.pool.Get(ctx, actuator.DialOptions{ + Host: target.Host, + User: target.User, + Signer: signer, + }) + if err != nil { + return ports.ExecResult{Err: err, Duration: time.Since(start)} + } + // Pooled client: do not close here; the pool evicts on TTL. + + if target.Wrap != nil { + command = target.Wrap(command) + } + + timeout := opts.Timeout + if timeout <= 0 { + timeout = defaultExecTimeout + } + + output, runErr := actuator.RunStreaming(ctx, client, command, opts.Sink, timeout) + if runErr != nil { + slog.Debug("ssh exec: command failed", "host", target.Host, "error", runErr) + } + return ports.ExecResult{ + Output: output, + Duration: time.Since(start), + Err: runErr, + } +} diff --git a/internal/audit/audit.go b/internal/audit/audit.go index d6055de8..9e7702ac 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -13,7 +13,7 @@ package audit import ( "context" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" ) // Finding is one drift item the operator should look at. diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index bd674dcc..58019122 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/google/uuid" ) diff --git a/internal/audit/testutil_test.go b/internal/audit/testutil_test.go index 619ebe37..754e53ff 100644 --- a/internal/audit/testutil_test.go +++ b/internal/audit/testutil_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/jackc/pgx/v5" ) diff --git a/internal/core/ports/doc.go b/internal/core/ports/doc.go index 6d9ea583..f925df9a 100644 --- a/internal/core/ports/doc.go +++ b/internal/core/ports/doc.go @@ -2,4 +2,16 @@ // repositories, executors, resolvers, probes, secrets, and events. // Core packages define these interfaces; adapters under internal/adapters // implement them. See docs/adr/0016-hexagonal-ports-adapters.md. +// +// Port rules (plan §3.3/§3.6): +// - Command-side repository methods are transaction-scoped aggregates: +// one method = one BEGIN…COMMIT = everything that must succeed or +// fail together. Input structs carry derived checks, audit entries, +// and events as fields. +// - Read methods are plain queries. +// - ReadModels (query-shaped report reads consumed directly by the +// httpapi/mcpserver adapters) materializes with the first vertical +// slice in Phase 3 and grows as report handlers rewire. +// - Signatures reference core/domain types (plus port-local payload +// types); they firm up per phase as the slices land. package ports diff --git a/internal/core/ports/entities.go b/internal/core/ports/entities.go new file mode 100644 index 00000000..46fa268e --- /dev/null +++ b/internal/core/ports/entities.go @@ -0,0 +1,83 @@ +package ports + +import ( + "context" + + "github.com/dtoro/oikos/internal/core/domain" + "github.com/dtoro/oikos/internal/ontology" +) + +// TypeTree aliases the ontology tree: entity types, relationship types, +// lifecycle definitions. ontology is pure over domain; it moves under +// core/ when checkdefaults is absorbed (Phase 3). +type TypeTree = ontology.TypeTree + +// EntityFilters bounds entity list/search reads. +type EntityFilters struct { + Type string + State string + Q string + Limit int +} + +// EntityCreateInput is one transaction: the entity, its derived check +// definitions, and the audit/event side-effects of the creation. +type EntityCreateInput struct { + Entity domain.Entity + DerivedChecks []CheckDef + Audit []AuditEntry + Event *Event +} + +// EntityUpdateInput mirrors EntityCreateInput for updates. +type EntityUpdateInput struct { + Entity domain.Entity + DerivedChecks []CheckDef + Audit []AuditEntry + Event *Event +} + +// EntityTransitionInput is a lifecycle state change: the check-then-act +// precondition (current state) is validated inside the transaction. +type EntityTransitionInput struct { + Slug string + From string + To string + Audit []AuditEntry + Event *Event +} + +// EntityRepository is the entity aggregate. Command methods are +// transaction-scoped: everything in the input commits or nothing does. +type EntityRepository interface { + Get(ctx context.Context, id domain.UUID) (domain.Entity, error) + BySlug(ctx context.Context, slug string) (domain.Entity, error) + List(ctx context.Context, filters EntityFilters) ([]domain.Entity, error) + Search(ctx context.Context, q string, limit int) ([]domain.Entity, error) + + Create(ctx context.Context, input EntityCreateInput) (domain.Entity, error) + Update(ctx context.Context, input EntityUpdateInput) (domain.Entity, error) + SetState(ctx context.Context, input EntityTransitionInput) (domain.Entity, error) +} + +// RelationshipCreateInput validates endpoints against the ontology in core +// before this is called; the repository persists the edge (+audit/event). +type RelationshipCreateInput struct { + Relationship domain.Relationship + Audit []AuditEntry + Event *Event +} + +// RelationshipRepository is the relationship aggregate. +type RelationshipRepository interface { + Create(ctx context.Context, input RelationshipCreateInput) (domain.Relationship, error) + End(ctx context.Context, source, target domain.UUID, relType string) error + ListFor(ctx context.Context, entityID domain.UUID, direction string) ([]domain.Relationship, error) +} + +// OntologyStore loads the type tree; implementations cache. Consumers +// validate entity types, relationship endpoints, and lifecycle transitions +// against it before issuing repository writes. +type OntologyStore interface { + LoadTypeTree(ctx context.Context) (TypeTree, error) +} diff --git a/internal/core/ports/events.go b/internal/core/ports/events.go new file mode 100644 index 00000000..2f9eb413 --- /dev/null +++ b/internal/core/ports/events.go @@ -0,0 +1,45 @@ +package ports + +import ( + "context" + "time" + + "github.com/dtoro/oikos/internal/core/domain" +) + +// Event is a domain occurrence worth publishing (SSE + events table). The +// events adapter maps it to the persistence/SSE shapes. +type Event struct { + Type string // e.g. "execution.completed", "health.changed" + Severity string // "info", "warning", "critical" + Source string // "api", "mcp", "scheduler", "webhook" + EntityID domain.UUID + Data map[string]any + Ts time.Time +} + +// EventPublisher fans events out to subscribers and persists them. The +// events adapter owns the SSE broker, the events table, and the dedicated +// LISTEN/NOTIFY connection. +type EventPublisher interface { + Publish(ctx context.Context, event Event) error +} + +// AuditEntry is an append-only audit-log record. +type AuditEntry struct { + ActorType string // "agent", "operator", "system" + ActorLabel string + Action string + EntityID domain.UUID + Details map[string]any + Ts time.Time +} + +// AuditRepository appends audit records and events. Entries are usually +// carried inside other repositories' input structs so they commit in the +// same transaction (§3.6 of the plan); the standalone methods serve +// read-path actions that audit without another aggregate write. +type AuditRepository interface { + AppendAudit(ctx context.Context, entries []AuditEntry) error + AppendEvent(ctx context.Context, events []Event) error +} diff --git a/internal/core/ports/execution.go b/internal/core/ports/execution.go new file mode 100644 index 00000000..a6245d1a --- /dev/null +++ b/internal/core/ports/execution.go @@ -0,0 +1,74 @@ +package ports + +import ( + "context" + "time" + + "github.com/dtoro/oikos/internal/core/domain" +) + +// Target is a resolved execution endpoint (mirrors remote.ExecTarget; the +// remote adapter maps between them). Wrap rewrites a plain command for +// transport: identity for a host, pct/qm wrapping for guests. +type Target struct { + Host string + User string + Wrap func(cmd string) string +} + +// ExecOpts carries execution options; Sink, when non-nil, receives output +// chunks as they arrive (streaming path). +type ExecOpts struct { + Timeout time.Duration + Sink func(stream string, chunk []byte) +} + +// ExecResult is the outcome of one command execution. +type ExecResult struct { + Output string + Duration time.Duration + Err error +} + +// CommandExecutor runs a command on a resolved target over SSH. Implemented +// by the ssh adapter wrapping internal/actuator (dial pool + breaker). +type CommandExecutor interface { + Run(ctx context.Context, target Target, command string, opts ExecOpts) ExecResult +} + +// TargetResolver resolves entity slugs/IDs to execution endpoints. +// Implemented by the remote adapter on top of EntityRepository. +type TargetResolver interface { + ResolveExecTarget(ctx context.Context, targetSlug string) (Target, error) + ResolveForCheck(ctx context.Context, targetID domain.UUID, targetType string) (Target, error) + ResolveHost(ctx context.Context, hostSlug, fallbackUser string) (addr, user string, err error) + // IsGuest reports whether an entity type is reached via pct/qm exec + // through a Proxmox host rather than by direct SSH. + IsGuest(entityType string) bool +} + +// Provisioner creates guests via pct/qm on a Proxmox host (Phase 7 fills +// the input payloads in; signatures firm up with ProvisioningService). +type Provisioner interface { + CreateLXC(ctx context.Context, host domain.UUID, input LXCInput) (domain.UUID, error) + CreateVM(ctx context.Context, host domain.UUID, input VMInput) (domain.UUID, error) +} + +// LXCInput is a placeholder until ProvisioningService (Phase 7) fixes the +// create payloads; declared now so the port surface is complete. +type LXCInput struct { + Name string + Template string + Cores int + MemoryMB int + DiskGB int +} + +// VMInput mirrors LXCInput for VM creation via qm. +type VMInput struct { + Name string + TemplateID int + Cores int + MemoryMB int + DiskGB int +} diff --git a/internal/core/ports/governance.go b/internal/core/ports/governance.go new file mode 100644 index 00000000..aea5c397 --- /dev/null +++ b/internal/core/ports/governance.go @@ -0,0 +1,97 @@ +package ports + +import ( + "context" + "time" + + "github.com/dtoro/oikos/internal/core/domain" +) + +// SignalUpsertInput is the observe-pass signal write: open/resolve +// transitions with their triggers, committed atomically. +type SignalUpsertInput struct { + Signal domain.Signal + Triggers []SignalTrigger + Audit []AuditEntry + Event *Event +} + +// SignalTrigger is an automatic follow-up fired on a signal transition +// (payload firm; shapes firm up with the Phase 5 observation slice). +type SignalTrigger struct { + Kind string + EntityID domain.UUID + Parameters map[string]any +} + +// SignalTransitionInput acks/resolves/mutes a signal (check-then-act on the +// signal's current state inside the transaction). +type SignalTransitionInput struct { + SignalID domain.UUID + Action string // "ack", "resolve", "mute" + Note string + MuteFor time.Duration + Actor string + Audit []AuditEntry + Event *Event +} + +// SignalRepository is the signal aggregate. +type SignalRepository interface { + Open(ctx context.Context) ([]domain.Signal, error) + History(ctx context.Context, entityID domain.UUID, limit int) ([]domain.Signal, error) + + UpsertWithTriggers(ctx context.Context, input SignalUpsertInput) error + Transition(ctx context.Context, input SignalTransitionInput) (domain.Signal, error) +} + +// ExecutionSubmitInput is the queued-execution write: execution row, +// approval (for gated risk classes), audit, and event — one transaction. +type ExecutionSubmitInput struct { + Execution domain.Execution + Approval *domain.Approval + Audit []AuditEntry + Event *Event +} + +// ExecutionCompleteInput closes out an execution: final status, output +// summary, audit, event. +type ExecutionCompleteInput struct { + ExecutionID domain.UUID + Status string + Output string + ExitCode int + Audit []AuditEntry + Event *Event +} + +// ExecutionRepository is the execution aggregate. Claim uses an advisory +// lock so exactly one worker claims a queued execution. +type ExecutionRepository interface { + List(ctx context.Context, cursor string, limit int) ([]domain.Execution, error) + ReadLog(ctx context.Context, executionID domain.UUID) ([]string, error) + + SubmitQueued(ctx context.Context, input ExecutionSubmitInput) (domain.Execution, error) + Claim(ctx context.Context) (*domain.Execution, error) + AppendLog(ctx context.Context, executionID domain.UUID, chunk string) error + Complete(ctx context.Context, input ExecutionCompleteInput) error +} + +// ApprovalDecideInput verifies the HMAC token (check-then-act), flips the +// approval, un-gates the execution, and appends audit — one transaction. +// Double-approve must not double-execute. +type ApprovalDecideInput struct { + ApprovalID domain.UUID + Token string + Approved bool + Actor string + Audit []AuditEntry + Event *Event +} + +// ApprovalRepository is the approval aggregate. +type ApprovalRepository interface { + ListPending(ctx context.Context, entityID domain.UUID, limit int) ([]domain.Approval, error) + + Decide(ctx context.Context, input ApprovalDecideInput) (domain.Approval, error) +} diff --git a/internal/core/ports/knowledge.go b/internal/core/ports/knowledge.go new file mode 100644 index 00000000..7b875658 --- /dev/null +++ b/internal/core/ports/knowledge.go @@ -0,0 +1,54 @@ +package ports + +import ( + "context" + "time" + + "github.com/dtoro/oikos/internal/core/domain" +) + +// KnowledgeEntry is a knowledge-base document/investigation/runbook. +type KnowledgeEntry struct { + Slug string + Title string + Kind string // "document", "investigation", "runbook" + Tags []string + Content string + About []string // entity slugs the entry describes + UpdatedAt time.Time +} + +// KnowledgeUpsertInput is the knowledge write: row + revision + about-edges +// in one transaction. +type KnowledgeUpsertInput struct { + Entry KnowledgeEntry + Audit []AuditEntry + Event *Event +} + +// KnowledgeRepository is the knowledge aggregate. +type KnowledgeRepository interface { + Search(ctx context.Context, query string, limit int) ([]KnowledgeEntry, error) + GetContent(ctx context.Context, slug string) (KnowledgeEntry, error) + Revisions(ctx context.Context, slug string, limit int) ([]KnowledgeEntry, error) + Tags(ctx context.Context) (map[string]int, error) + Orphans(ctx context.Context, staleDays int) ([]KnowledgeEntry, error) + Duplicates(ctx context.Context, threshold float64) ([]KnowledgeEntry, error) + + Upsert(ctx context.Context, input KnowledgeUpsertInput) (KnowledgeEntry, error) + Merge(ctx context.Context, targetSlug string, sourceSlugs []string) error + SoftDelete(ctx context.Context, slug string) error + Restore(ctx context.Context, slug string) error +} + +// LearningRepository — patterns, feedback, skills (reads plus simple +// upserts; no multi-aggregate transactions here). +type LearningRepository interface { + ListFeedback(ctx context.Context, limit int) ([]domain.Pattern, error) + ListPatterns(ctx context.Context, limit int) ([]domain.Pattern, error) + ListSkills(ctx context.Context) ([]domain.Pattern, error) + + UpsertPattern(ctx context.Context, pattern domain.Pattern) error + Validate(ctx context.Context, patternID domain.UUID) error + Quarantine(ctx context.Context, patternID domain.UUID, reason string) error +} diff --git a/internal/core/ports/observation.go b/internal/core/ports/observation.go new file mode 100644 index 00000000..f0cdf01d --- /dev/null +++ b/internal/core/ports/observation.go @@ -0,0 +1,61 @@ +package ports + +import ( + "context" + "encoding/json" + "time" + + "github.com/dtoro/oikos/internal/core/domain" +) + +// CheckDef is a health-check definition (subset of the check_defs row the +// observe pass consumes; grows with the probe adapters in Phase 5). +type CheckDef struct { + ID domain.UUID + EntityID domain.UUID + Kind string + Name string + Config json.RawMessage + IntervalS int + Enabled bool + Severity string + CreatedAt time.Time + UpdatedAt time.Time +} + +// CheckResult is what one probe run produces. +type CheckResult struct { + Value float64 + State string // "ok", "warning", "critical", "unknown" + Message string +} + +// Checker probes one check kind. One adapter per kind under adapters/probes; +// the ObservationService picks the adapter by CheckDef.Kind. +type Checker interface { + Check(ctx context.Context, def CheckDef, target Target) CheckResult +} + +// MetricSample is one metric observation to record. +type MetricSample struct { + Metric string + Value float64 + Timestamp time.Time +} + +// MetricsRepository records observe-pass metrics (Timescale write path; +// bucketed/trend reads belong to ReadModels). +type MetricsRepository interface { + InsertSamples(ctx context.Context, entityID domain.UUID, samples []MetricSample) error +} + +// CheckRepository — monitoring definitions. EnsureFor is the read-diff-write +// derivation in one transaction. +type CheckRepository interface { + ListEnabled(ctx context.Context) ([]CheckDef, error) + ListFor(ctx context.Context, entityID domain.UUID) ([]CheckDef, error) + // EnsureFor inserts defs that are missing, updates changed ones, and + // removes stale ones for the entity — atomically. + EnsureFor(ctx context.Context, entityID domain.UUID, desired []CheckDef) error + SetEnabled(ctx context.Context, checkID domain.UUID, enabled bool) error +} diff --git a/internal/core/ports/portstest/fakes.go b/internal/core/ports/portstest/fakes.go new file mode 100644 index 00000000..3bb8c6b3 --- /dev/null +++ b/internal/core/ports/portstest/fakes.go @@ -0,0 +1,242 @@ +// Package portstest provides in-memory fakes for the driven ports, for +// use in core/app service tests (and anywhere else a backend-free stand-in +// helps). +package portstest + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/dtoro/oikos/internal/core/domain" + "github.com/dtoro/oikos/internal/core/ports" +) + +// EntityRepo is an in-memory ports.EntityRepository. Command inputs' audit, +// event, and derived-check fields are recorded for assertion. +type EntityRepo struct { + mu sync.Mutex + byID map[domain.UUID]domain.Entity + bySlug map[string]domain.UUID + order []domain.UUID + nextID int + Audits []ports.AuditEntry + Events []ports.Event + Checks map[domain.UUID][]ports.CheckDef + ErrStub error // returned by every command when set +} + +// NewEntityRepo builds an empty in-memory entity repository. +func NewEntityRepo() *EntityRepo { + return &EntityRepo{ + byID: make(map[domain.UUID]domain.Entity), + bySlug: make(map[string]domain.UUID), + Checks: make(map[domain.UUID][]ports.CheckDef), + } +} + +// Get returns the entity by ID. +func (r *EntityRepo) Get(_ context.Context, id domain.UUID) (domain.Entity, error) { + r.mu.Lock() + defer r.mu.Unlock() + e, ok := r.byID[id] + if !ok { + return domain.Entity{}, domain.ErrNotFound + } + return e, nil +} + +// BySlug returns the entity by slug. +func (r *EntityRepo) BySlug(_ context.Context, slug string) (domain.Entity, error) { + r.mu.Lock() + defer r.mu.Unlock() + id, ok := r.bySlug[slug] + if !ok { + return domain.Entity{}, domain.ErrNotFound + } + return r.byID[id], nil +} + +// List returns entities filtered by type/state, bounded by limit. +func (r *EntityRepo) List(_ context.Context, f ports.EntityFilters) ([]domain.Entity, error) { + r.mu.Lock() + defer r.mu.Unlock() + var out []domain.Entity + for _, id := range r.order { + e := r.byID[id] + if f.Type != "" && e.Type != f.Type { + continue + } + if f.State != "" && e.State != f.State { + continue + } + out = append(out, e) + if f.Limit > 0 && len(out) >= f.Limit { + break + } + } + return out, nil +} + +// Search matches name/slug substrings. +func (r *EntityRepo) Search(ctx context.Context, q string, limit int) ([]domain.Entity, error) { + // Substring over name/slug is enough for service tests. + all, err := r.List(ctx, ports.EntityFilters{Limit: limit}) + if err != nil { + return nil, err + } + var out []domain.Entity + for _, e := range all { + if strings.Contains(e.Name, q) || strings.Contains(e.Slug, q) { + out = append(out, e) + } + } + return out, nil +} + +// Create stores a new entity and records its input side-effects. +func (r *EntityRepo) Create(_ context.Context, in ports.EntityCreateInput) (domain.Entity, error) { + if r.ErrStub != nil { + return domain.Entity{}, r.ErrStub + } + r.mu.Lock() + defer r.mu.Unlock() + if in.Entity.ID == "" { + r.nextID++ + in.Entity.ID = domain.UUID(fmt.Sprintf("fake-entity-%03d", r.nextID)) + } + if _, dup := r.bySlug[in.Entity.Slug]; dup { + return domain.Entity{}, domain.ErrConflict + } + r.store(in.Entity) + r.Audits = append(r.Audits, in.Audit...) + if in.Event != nil { + r.Events = append(r.Events, *in.Event) + } + r.Checks[in.Entity.ID] = in.DerivedChecks + return in.Entity, nil +} + +// Update replaces a stored entity and records its input side-effects. +func (r *EntityRepo) Update(_ context.Context, in ports.EntityUpdateInput) (domain.Entity, error) { + if r.ErrStub != nil { + return domain.Entity{}, r.ErrStub + } + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.byID[in.Entity.ID]; !ok { + return domain.Entity{}, domain.ErrNotFound + } + delete(r.bySlug, r.byID[in.Entity.ID].Slug) + r.store(in.Entity) + r.Audits = append(r.Audits, in.Audit...) + if in.Event != nil { + r.Events = append(r.Events, *in.Event) + } + r.Checks[in.Entity.ID] = in.DerivedChecks + return in.Entity, nil +} + +// SetState applies a lifecycle transition, refusing stale From states. +func (r *EntityRepo) SetState(_ context.Context, in ports.EntityTransitionInput) (domain.Entity, error) { + if r.ErrStub != nil { + return domain.Entity{}, r.ErrStub + } + r.mu.Lock() + defer r.mu.Unlock() + id, ok := r.bySlug[in.Slug] + if !ok { + return domain.Entity{}, domain.ErrNotFound + } + e := r.byID[id] + if e.State != in.From { + return domain.Entity{}, domain.ErrConflict + } + e.State = in.To + r.byID[id] = e + r.Audits = append(r.Audits, in.Audit...) + if in.Event != nil { + r.Events = append(r.Events, *in.Event) + } + return e, nil +} + +func (r *EntityRepo) store(e domain.Entity) { + r.byID[e.ID] = e + r.bySlug[e.Slug] = e.ID + r.order = append(r.order, e.ID) +} + +// FindBySlug is a test helper bypassing the port interface. +func (r *EntityRepo) FindBySlug(slug string) (domain.Entity, bool) { + r.mu.Lock() + defer r.mu.Unlock() + id, ok := r.bySlug[slug] + if !ok { + return domain.Entity{}, false + } + return r.byID[id], true +} + +// RecordingExecutor records every command it is asked to run and replies +// with canned results (default: empty success). +type RecordingExecutor struct { + mu sync.Mutex + Calls []ExecCall + Results []ports.ExecResult // popped in order; last one repeats +} + +// ExecCall is one recorded CommandExecutor.Run invocation. +type ExecCall struct { + Target ports.Target + Command string +} + +// Run records the call and replies with the next canned result. +func (e *RecordingExecutor) Run(_ context.Context, target ports.Target, command string, _ ports.ExecOpts) ports.ExecResult { + e.mu.Lock() + defer e.mu.Unlock() + e.Calls = append(e.Calls, ExecCall{Target: target, Command: command}) + if len(e.Results) == 0 { + return ports.ExecResult{} + } + res := e.Results[0] + if len(e.Results) > 1 { + e.Results = e.Results[1:] + } + return res +} + +// FakeChecker answers per check kind; unregistered kinds report "unknown". +type FakeChecker struct { + mu sync.Mutex + ByKind map[string]ports.CheckResult + Calls []string +} + +// Check answers from the kind map, recording the call. +func (c *FakeChecker) Check(_ context.Context, def ports.CheckDef, _ ports.Target) ports.CheckResult { + c.mu.Lock() + defer c.mu.Unlock() + c.Calls = append(c.Calls, def.Kind) + if res, ok := c.ByKind[def.Kind]; ok { + return res + } + return ports.CheckResult{State: "unknown"} +} + +// SpyPublisher records published events. +type SpyPublisher struct { + mu sync.Mutex + Events []ports.Event + Err error +} + +// Publish records the event. +func (p *SpyPublisher) Publish(_ context.Context, event ports.Event) error { + p.mu.Lock() + defer p.mu.Unlock() + p.Events = append(p.Events, event) + return p.Err +} diff --git a/internal/core/ports/portstest/fakes_test.go b/internal/core/ports/portstest/fakes_test.go new file mode 100644 index 00000000..339f7246 --- /dev/null +++ b/internal/core/ports/portstest/fakes_test.go @@ -0,0 +1,121 @@ +package portstest + +import ( + "context" + "errors" + "testing" + + "github.com/dtoro/oikos/internal/core/domain" + "github.com/dtoro/oikos/internal/core/ports" +) + +func TestEntityRepoCreateReadUpdate(t *testing.T) { + r := NewEntityRepo() + ctx := context.Background() + + e, err := r.Create(ctx, ports.EntityCreateInput{ + Entity: domain.Entity{Slug: "lxc:test", Name: "test", Type: "lxc", State: "active"}, + Audit: []ports.AuditEntry{{Action: "entity.create"}}, + Event: &ports.Event{Type: "entity.created"}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if e.ID == "" { + t.Fatal("create did not assign an ID") + } + + got, err := r.BySlug(ctx, "lxc:test") + if err != nil { + t.Fatalf("bySlug: %v", err) + } + if got.ID != e.ID { + t.Fatalf("bySlug returned %s, want %s", got.ID, e.ID) + } + + if len(r.Audits) != 1 || r.Audits[0].Action != "entity.create" { + t.Fatalf("audit not recorded: %+v", r.Audits) + } + if len(r.Events) != 1 || r.Events[0].Type != "entity.created" { + t.Fatalf("event not recorded: %+v", r.Events) + } + + _, err = r.Create(ctx, ports.EntityCreateInput{ + Entity: domain.Entity{Slug: "lxc:test", Name: "dup", Type: "lxc"}, + }) + if !errors.Is(err, domain.ErrConflict) && !errors.Is(err, domain.ErrAlreadyExists) { + t.Fatalf("duplicate slug: got %v, want conflict", err) + } +} + +func TestEntityRepoSetStateCheckThenAct(t *testing.T) { + r := NewEntityRepo() + ctx := context.Background() + _, err := r.Create(ctx, ports.EntityCreateInput{ + Entity: domain.Entity{Slug: "host:one", Name: "one", Type: "host", State: "active"}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + _, err = r.SetState(ctx, ports.EntityTransitionInput{Slug: "host:one", From: "planned", To: "decommissioned"}) + if !errors.Is(err, domain.ErrConflict) { + t.Fatalf("stale From: got %v, want ErrConflict", err) + } + + e, err := r.SetState(ctx, ports.EntityTransitionInput{Slug: "host:one", From: "active", To: "decommissioned"}) + if err != nil { + t.Fatalf("SetState: %v", err) + } + if e.State != "decommissioned" { + t.Fatalf("state = %s, want decommissioned", e.State) + } +} + +func TestRecordingExecutor(t *testing.T) { + e := &RecordingExecutor{Results: []ports.ExecResult{{Output: "first"}, {Output: "second"}}} + res := e.Run(context.Background(), ports.Target{Host: "h", User: "root"}, "uptime", ports.ExecOpts{}) + if res.Output != "first" { + t.Fatalf("first run output = %q", res.Output) + } + e.Run(context.Background(), ports.Target{Host: "h"}, "w", ports.ExecOpts{}) + e.Run(context.Background(), ports.Target{Host: "h"}, "true", ports.ExecOpts{}) + if len(e.Calls) != 3 || e.Calls[2].Command != "true" { + t.Fatalf("calls not recorded: %+v", e.Calls) + } + if got := e.Run(context.Background(), ports.Target{}, "x", ports.ExecOpts{}); got.Output != "second" { + t.Fatalf("last result should repeat, got %q", got.Output) + } +} + +func TestFakeChecker(t *testing.T) { + c := &FakeChecker{ByKind: map[string]ports.CheckResult{ + "http": {State: "ok", Value: 200}, + }} + res := c.Check(context.Background(), ports.CheckDef{Kind: "http"}, ports.Target{}) + if res.State != "ok" || res.Value != 200 { + t.Fatalf("http check = %+v", res) + } + res = c.Check(context.Background(), ports.CheckDef{Kind: "dns"}, ports.Target{}) + if res.State != "unknown" { + t.Fatalf("unregistered kind = %+v, want unknown", res) + } +} + +func TestSpyPublisher(t *testing.T) { + p := &SpyPublisher{} + if err := p.Publish(context.Background(), ports.Event{Type: "x"}); err != nil { + t.Fatalf("publish: %v", err) + } + if len(p.Events) != 1 || p.Events[0].Type != "x" { + t.Fatalf("events = %+v", p.Events) + } +} + +// Interface satisfaction guards: the fakes must implement the ports. +var ( + _ ports.EntityRepository = (*EntityRepo)(nil) + _ ports.CommandExecutor = (*RecordingExecutor)(nil) + _ ports.Checker = (*FakeChecker)(nil) + _ ports.EventPublisher = (*SpyPublisher)(nil) +) diff --git a/internal/core/ports/secrets.go b/internal/core/ports/secrets.go new file mode 100644 index 00000000..5aa4d34b --- /dev/null +++ b/internal/core/ports/secrets.go @@ -0,0 +1,14 @@ +package ports + +import "context" + +// Secrets retrieves and stores secrets. Infisical/SOPS implementations +// stay in internal/secrets; the caching Manager satisfies this by +// delegation. The interface lives here so core code never imports the +// backends. +type Secrets interface { + Get(ctx context.Context, key string) (string, error) + List(ctx context.Context) ([]string, error) + Set(ctx context.Context, key string, value string) error + Name() string +} diff --git a/internal/execlog/execlog.go b/internal/execlog/execlog.go index 9529ee5c..aab8b62b 100644 --- a/internal/execlog/execlog.go +++ b/internal/execlog/execlog.go @@ -14,8 +14,8 @@ import ( "sync" "time" - "github.com/dtoro/oikos/internal/db" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/observability" "github.com/google/uuid" ) diff --git a/internal/execworker/init.go b/internal/execworker/init.go index 76b45a3d..16889132 100644 --- a/internal/execworker/init.go +++ b/internal/execworker/init.go @@ -4,7 +4,7 @@ import ( "context" "github.com/dtoro/oikos/internal/config" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" ) // RunnerForMain provides the run function for registration in main. diff --git a/internal/execworker/worker.go b/internal/execworker/worker.go index 2d9beb42..84ea646e 100644 --- a/internal/execworker/worker.go +++ b/internal/execworker/worker.go @@ -14,8 +14,8 @@ import ( "github.com/dtoro/oikos/internal/actuator" "github.com/dtoro/oikos/internal/config" - "github.com/dtoro/oikos/internal/db" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/health" "github.com/dtoro/oikos/internal/remote" "github.com/google/uuid" diff --git a/internal/httpapi/actuator.go b/internal/httpapi/actuator.go index de12259e..4ec7cbe3 100644 --- a/internal/httpapi/actuator.go +++ b/internal/httpapi/actuator.go @@ -12,8 +12,8 @@ import ( "time" "github.com/dtoro/oikos/internal/actuator" - "github.com/dtoro/oikos/internal/db" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/execlog" "github.com/dtoro/oikos/internal/observability" "github.com/google/uuid" diff --git a/internal/httpapi/api_test.go b/internal/httpapi/api_test.go index c59f0a2a..0721555d 100644 --- a/internal/httpapi/api_test.go +++ b/internal/httpapi/api_test.go @@ -16,7 +16,7 @@ import ( "testing" "github.com/dtoro/oikos/internal/config" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/jackc/pgx/v5" ) diff --git a/internal/httpapi/approval_rules.go b/internal/httpapi/approval_rules.go index 8fd15c1c..263673be 100644 --- a/internal/httpapi/approval_rules.go +++ b/internal/httpapi/approval_rules.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" diff --git a/internal/httpapi/approvals.go b/internal/httpapi/approvals.go index 101bee8a..8bce52fa 100644 --- a/internal/httpapi/approvals.go +++ b/internal/httpapi/approvals.go @@ -7,7 +7,7 @@ import ( "log/slog" "time" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" diff --git a/internal/httpapi/autonomy.go b/internal/httpapi/autonomy.go index a579c78f..f4f969d3 100644 --- a/internal/httpapi/autonomy.go +++ b/internal/httpapi/autonomy.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" diff --git a/internal/httpapi/checks.go b/internal/httpapi/checks.go index 60b122ea..3d7d302f 100644 --- a/internal/httpapi/checks.go +++ b/internal/httpapi/checks.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" diff --git a/internal/httpapi/client_lifecycle.go b/internal/httpapi/client_lifecycle.go index a5361edb..7a25b795 100644 --- a/internal/httpapi/client_lifecycle.go +++ b/internal/httpapi/client_lifecycle.go @@ -9,7 +9,7 @@ import ( "strconv" "time" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" diff --git a/internal/httpapi/default_checks.go b/internal/httpapi/default_checks.go index e59cc9ff..dfcb4802 100644 --- a/internal/httpapi/default_checks.go +++ b/internal/httpapi/default_checks.go @@ -3,7 +3,7 @@ package httpapi import ( "context" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) diff --git a/internal/httpapi/entities.go b/internal/httpapi/entities.go index 02290602..1bc4492d 100644 --- a/internal/httpapi/entities.go +++ b/internal/httpapi/entities.go @@ -6,7 +6,7 @@ import ( "strconv" "time" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/google/uuid" ) diff --git a/internal/httpapi/entity_mutations.go b/internal/httpapi/entity_mutations.go index 3fe0a027..8283fe52 100644 --- a/internal/httpapi/entity_mutations.go +++ b/internal/httpapi/entity_mutations.go @@ -9,8 +9,8 @@ import ( "strconv" "strings" - "github.com/dtoro/oikos/internal/db" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" diff --git a/internal/httpapi/entity_types.go b/internal/httpapi/entity_types.go index e256e4d9..036149dd 100644 --- a/internal/httpapi/entity_types.go +++ b/internal/httpapi/entity_types.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" diff --git a/internal/httpapi/executions.go b/internal/httpapi/executions.go index db67113b..03634a8c 100644 --- a/internal/httpapi/executions.go +++ b/internal/httpapi/executions.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" diff --git a/internal/httpapi/fleet_health.go b/internal/httpapi/fleet_health.go index 4f6b0bc4..54ea271d 100644 --- a/internal/httpapi/fleet_health.go +++ b/internal/httpapi/fleet_health.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/dtoro/oikos/internal/httpapi/gen" ) diff --git a/internal/httpapi/impl.go b/internal/httpapi/impl.go index 16ea1f85..b4dd760d 100644 --- a/internal/httpapi/impl.go +++ b/internal/httpapi/impl.go @@ -6,7 +6,7 @@ import ( "fmt" "time" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/google/uuid" diff --git a/internal/httpapi/ontology.go b/internal/httpapi/ontology.go index 7a2ed2a1..cdf15fac 100644 --- a/internal/httpapi/ontology.go +++ b/internal/httpapi/ontology.go @@ -5,7 +5,7 @@ import ( "encoding/json" "fmt" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/httpapi/gen" ) diff --git a/internal/httpapi/patterns.go b/internal/httpapi/patterns.go index 4f75f2a0..9dbe8d11 100644 --- a/internal/httpapi/patterns.go +++ b/internal/httpapi/patterns.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" diff --git a/internal/httpapi/relationships.go b/internal/httpapi/relationships.go index 475cc9a6..73e6836b 100644 --- a/internal/httpapi/relationships.go +++ b/internal/httpapi/relationships.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 9f75116b..f64b2fba 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -25,7 +25,7 @@ import ( "github.com/dtoro/oikos/internal/actuator" "github.com/dtoro/oikos/internal/config" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/dtoro/oikos/internal/httpapi/gen" mcphandler "github.com/dtoro/oikos/internal/mcp" "github.com/dtoro/oikos/internal/safego" diff --git a/internal/httpapi/skills.go b/internal/httpapi/skills.go index f147bc88..6419c200 100644 --- a/internal/httpapi/skills.go +++ b/internal/httpapi/skills.go @@ -6,7 +6,7 @@ import ( "fmt" "log/slog" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" diff --git a/internal/httpapi/sse.go b/internal/httpapi/sse.go index 1b74c72a..cd206c8e 100644 --- a/internal/httpapi/sse.go +++ b/internal/httpapi/sse.go @@ -11,7 +11,7 @@ import ( "sync" "time" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" diff --git a/internal/learning/learning.go b/internal/learning/learning.go index f0aa7c5e..19bbccce 100644 --- a/internal/learning/learning.go +++ b/internal/learning/learning.go @@ -11,8 +11,8 @@ import ( "time" "github.com/dtoro/oikos/internal/config" - "github.com/dtoro/oikos/internal/db" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/google/uuid" ) diff --git a/internal/learning/learning_test.go b/internal/learning/learning_test.go index 0ff4fc5b..35ad6ce8 100644 --- a/internal/learning/learning_test.go +++ b/internal/learning/learning_test.go @@ -4,7 +4,7 @@ import ( "math" "testing" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" ) func TestWilsonLowerBound(t *testing.T) { diff --git a/internal/mcp/analysis_tools.go b/internal/mcp/analysis_tools.go index 6a67ca94..1fff73cf 100644 --- a/internal/mcp/analysis_tools.go +++ b/internal/mcp/analysis_tools.go @@ -4,13 +4,14 @@ import ( "context" "encoding/json" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/core/ports" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/dtoro/oikos/internal/policy" "github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/mcp" ) -func AnalysisTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg { +func AnalysisTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg { return []toolReg{ {tool: &mcp.Tool{Name: "get_health_summary", Description: "Fleet health per entity — optionally filter by health state(s)", InputSchema: objSchema( diff --git a/internal/mcp/create_entity_test.go b/internal/mcp/create_entity_test.go index 7a039215..74cb4707 100644 --- a/internal/mcp/create_entity_test.go +++ b/internal/mcp/create_entity_test.go @@ -16,7 +16,7 @@ import ( "testing" "github.com/dtoro/oikos/internal/checkdefaults" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/internal/mcp/discover.go b/internal/mcp/discover.go index b77717d2..d85385f0 100644 --- a/internal/mcp/discover.go +++ b/internal/mcp/discover.go @@ -5,7 +5,7 @@ import ( "context" "strings" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/dtoro/oikos/internal/execlog" "github.com/dtoro/oikos/internal/remote" ) diff --git a/internal/mcp/entity_tools.go b/internal/mcp/entity_tools.go index c5db9605..ffd0ecd7 100644 --- a/internal/mcp/entity_tools.go +++ b/internal/mcp/entity_tools.go @@ -7,13 +7,14 @@ import ( "strings" "github.com/dtoro/oikos/internal/audit" - "github.com/dtoro/oikos/internal/db" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/core/ports" + "github.com/dtoro/oikos/internal/adapters/postgres" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/mcp" ) -func EntityTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg { +func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg { return []toolReg{ {tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.", InputSchema: objSchema(), diff --git a/internal/mcp/knowledge_tools.go b/internal/mcp/knowledge_tools.go index d2c2e75e..b99225bc 100644 --- a/internal/mcp/knowledge_tools.go +++ b/internal/mcp/knowledge_tools.go @@ -6,12 +6,13 @@ import ( "fmt" "strings" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/core/ports" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/mcp" ) -func KnowledgeTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg { +func KnowledgeTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg { return []toolReg{ {tool: &mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.", InputSchema: objSchema(prop{"query", "string", "Search terms"}), diff --git a/internal/mcp/ops_tools.go b/internal/mcp/ops_tools.go index c83efd9e..01434cab 100644 --- a/internal/mcp/ops_tools.go +++ b/internal/mcp/ops_tools.go @@ -10,14 +10,15 @@ import ( "strings" "time" - "github.com/dtoro/oikos/internal/db" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/core/ports" + "github.com/dtoro/oikos/internal/adapters/postgres" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/observability" "github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/mcp" ) -func OpsTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg { +func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg { return []toolReg{ // ── request_execution (legacy fixed enum) retired 2026-07-14 ── // All mutations now route through `run`. The handler functions diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 1ed6f526..e77e9abc 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -20,8 +20,9 @@ import ( "time" "github.com/dtoro/oikos/internal/actuator" - "github.com/dtoro/oikos/internal/db" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/core/ports" + "github.com/dtoro/oikos/internal/adapters/postgres" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/execlog" "github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/policy" @@ -48,18 +49,9 @@ func objSchema(props ...prop) *jsonschema.Schema { return s } -// secretBackend is the interface MCP tools use to access the secrets store. -// Defined here to avoid importing the full secrets package (which brings in -// the Infisical SDK). Mirrors the subset of secrets.Backend used by tools. -type secretBackend interface { - Get(ctx context.Context, key string) (string, error) - Set(ctx context.Context, key string, value string) error - List(ctx context.Context) ([]string, error) -} - // NewHandler creates an http.Handler that serves the Oikos MCP server. // agentID is the Nomos agent entity UUID; tool calls are logged to agent_activity. -func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec secretBackend) http.Handler { +func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secrets) http.Handler { s := newServer(pool, agentID, sec) handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { if token != "" { @@ -75,7 +67,7 @@ func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec secretBacken // toolHandler is the function signature registered via AddTool. type toolHandler = mcp.ToolHandler -func newServer(pool *db.Pool, agentID uuid.UUID, sec secretBackend) *mcp.Server { +func newServer(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) *mcp.Server { s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{ Logger: slog.Default(), }) diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index ca3a3518..e88737af 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -6,7 +6,8 @@ import ( "strings" "github.com/dtoro/oikos/internal/checkdefaults" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/core/ports" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -16,7 +17,7 @@ type toolReg struct { handler toolHandler } -func allTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg { +func allTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg { return append(append(append(append( []toolReg{}, EntityTools(pool, agentID, sec)...), diff --git a/internal/observability/record.go b/internal/observability/record.go index 7c74b7fe..ce3069ed 100644 --- a/internal/observability/record.go +++ b/internal/observability/record.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/google/uuid" ) diff --git a/internal/remote/remote.go b/internal/remote/remote.go index 6271ee16..3ea35b3e 100644 --- a/internal/remote/remote.go +++ b/internal/remote/remote.go @@ -22,7 +22,7 @@ import ( "fmt" "strings" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/google/uuid" ) diff --git a/internal/remote/remote_test.go b/internal/remote/remote_test.go index a41932b0..f788a44c 100644 --- a/internal/remote/remote_test.go +++ b/internal/remote/remote_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/google/uuid" ) diff --git a/internal/remote/testutil_test.go b/internal/remote/testutil_test.go index 744ba092..5319c388 100644 --- a/internal/remote/testutil_test.go +++ b/internal/remote/testutil_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/jackc/pgx/v5" ) diff --git a/internal/scheduler/backup.go b/internal/scheduler/backup.go index 558be255..14a960fa 100644 --- a/internal/scheduler/backup.go +++ b/internal/scheduler/backup.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" ) // checkBackupFreshness reports whether a backup target has a recent artifact. diff --git a/internal/scheduler/backup_test.go b/internal/scheduler/backup_test.go index 2e1ad147..f93ad9d9 100644 --- a/internal/scheduler/backup_test.go +++ b/internal/scheduler/backup_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" ) func backupCheckDef(t *testing.T, config string) sqlcgen.ListEnabledCheckDefsRow { diff --git a/internal/scheduler/coverage.go b/internal/scheduler/coverage.go index be53fbf3..35ed5a8b 100644 --- a/internal/scheduler/coverage.go +++ b/internal/scheduler/coverage.go @@ -5,7 +5,7 @@ import ( "fmt" "log/slog" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/google/uuid" ) diff --git a/internal/scheduler/coverage_test.go b/internal/scheduler/coverage_test.go index eb25bc9e..f6988457 100644 --- a/internal/scheduler/coverage_test.go +++ b/internal/scheduler/coverage_test.go @@ -12,7 +12,7 @@ import ( "strings" "testing" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) diff --git a/internal/scheduler/init.go b/internal/scheduler/init.go index 0319a5ce..653c1ef5 100644 --- a/internal/scheduler/init.go +++ b/internal/scheduler/init.go @@ -4,7 +4,7 @@ import ( "context" "github.com/dtoro/oikos/internal/config" - "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/adapters/postgres" ) // RunnerForMain provides the run function for registration in main. diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index a85c715a..2028472b 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -21,8 +21,8 @@ import ( "github.com/dtoro/oikos/internal/actuator" "github.com/dtoro/oikos/internal/config" - "github.com/dtoro/oikos/internal/db" - "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/adapters/postgres" + "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "github.com/dtoro/oikos/internal/health" "github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/remote" diff --git a/internal/secrets/backend.go b/internal/secrets/backend.go index f02b7c23..f213112a 100644 --- a/internal/secrets/backend.go +++ b/internal/secrets/backend.go @@ -6,18 +6,17 @@ import ( "log/slog" "sync" "time" + + "github.com/dtoro/oikos/internal/core/ports" ) var ErrNotFound = errors.New("secret not found") var ErrBackendUnavailable = errors.New("secret backend unavailable") -// Backend is the interface for retrieving and storing secrets. -type Backend interface { - Get(ctx context.Context, key string) (string, error) - List(ctx context.Context) ([]string, error) - Set(ctx context.Context, key string, value string) error - Name() string -} +// Backend is the secrets port. The interface lives in core/ports (ADR 0016); +// this alias keeps existing call sites working while implementations +// (Infisical, SOPS, Manager) stay in this package. +type Backend = ports.Secrets // Manager holds a primary and fallback backend. If the primary fails, // it falls back to the secondary. Supports periodic background refresh diff --git a/plans/2026-08-15-hexagonal-architecture.md b/plans/2026-08-15-hexagonal-architecture.md index 4e9cf542..2fd52884 100644 --- a/plans/2026-08-15-hexagonal-architecture.md +++ b/plans/2026-08-15-hexagonal-architecture.md @@ -1,7 +1,7 @@ # Hexagonal architecture for Oikos — design and phased refactor plan **Date:** 2026-08-15 -**Status:** In progress — Phase 0 shipped; Phases 1–9 pending +**Status:** In progress — Phases 0–2 shipped; Phases 3–9 pending **Scope:** All Go code (`cmd/oikos`, `cmd/nomos`, `cmd/webhook`) and the UI split. One hexagon covers the oikos backend; nomos is an external agent client that gets an internal cleanup (Phase 8) but stays outside the core. diff --git a/plans/index.md b/plans/index.md index e1c3af39..adbaa0af 100644 --- a/plans/index.md +++ b/plans/index.md @@ -22,7 +22,7 @@ went sideways, open an investigation. | 2026-08-04 | [Hermes MCP client integration](done/2026-08-04-hermes-mcp-client-integration.md) | Done — deployed | | 2026-08-05 | [Agent execution safety: QEMU guest agent gate + host-mutation guard](done/2026-08-05-agent-execution-safety-qemu-guest-agent-gate.md) | Done — implemented (1b9c761) | | 2026-08-05 | [Backend evaluation: architecture, security, and reliability improvements](2026-08-05-backend-evaluation-improvements.md) | Done — all three phases (B, D, E) implemented as code (0.28.0–0.29.0), deployed, and hardened via review. Remaining: C (security) and F (performance) backlog. | -| 2026-08-15 | [Hexagonal architecture — design and phased refactor](2026-08-15-hexagonal-architecture.md) | In Progress — Phase 0 done (ADR 0016, core scaffold, domain moved, depguard) | +| 2026-08-15 | [Hexagonal architecture — design and phased refactor](2026-08-15-hexagonal-architecture.md) | In Progress — Phases 0–2 done (ADR 0016, client extracted to dtoro/oikos-web, ports + adapters scaffold); Phase 3 next | ## Done diff --git a/sqlc.yaml b/sqlc.yaml index 13102010..6d1af2b5 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -1,19 +1,19 @@ # 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 +# (internal/adapters/postgres/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" + queries: "internal/adapters/postgres/queries" strict_order_by: false gen: go: package: "sqlcgen" - out: "internal/db/sqlcgen" + out: "internal/adapters/postgres/sqlcgen" sql_package: "pgx/v5" emit_pointers_for_null_types: true overrides: