feat: Phase 2 — ports package, secrets port move, postgres adapter move
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

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.
This commit is contained in:
2026-08-15 22:56:56 +02:00
parent d4d99a7473
commit 64f7d54011
93 changed files with 1102 additions and 103 deletions

View File

@@ -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`,

View File

@@ -72,7 +72,7 @@ linters:
path: internal/httpapi/gen/
- linters:
- all
path: internal/db/sqlcgen/
path: internal/adapters/postgres/sqlcgen/
paths:
- third_party$
- builtin$

View File

@@ -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

View File

@@ -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:

View File

@@ -1 +1 @@
0.32.2
0.32.3

View File

@@ -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"

View File

@@ -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"
)

View File

@@ -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"

View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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"
)

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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,
}
}

View File

@@ -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.

View File

@@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/google/uuid"
)

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
)

View File

@@ -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
}

View File

@@ -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"
)

View File

@@ -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.

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"
)

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"
)

View File

@@ -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"

View File

@@ -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"
)

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"
)

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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"

View File

@@ -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"
)

View File

@@ -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(),

View File

@@ -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"}),

View File

@@ -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

View File

@@ -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(),
})

View File

@@ -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)...),

View File

@@ -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"
)

View File

@@ -22,7 +22,7 @@ import (
"fmt"
"strings"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/google/uuid"
)

View File

@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/google/uuid"
)

View File

@@ -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"
)

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -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.

View File

@@ -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"

View File

@@ -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

View File

@@ -1,7 +1,7 @@
# Hexagonal architecture for Oikos — design and phased refactor plan
**Date:** 2026-08-15
**Status:** In progress — Phase 0 shipped; Phases 19 pending
**Status:** In progress — Phases 02 shipped; Phases 39 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.

View File

@@ -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.00.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 02 done (ADR 0016, client extracted to dtoro/oikos-web, ports + adapters scaffold); Phase 3 next |
## Done

View File

@@ -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: