feat: Phase 2 — ports package, secrets port move, postgres adapter move
Problem: the hexagon's Phase 2 (plans/2026-08-15-hexagonal-architecture.md) must give the use-cases-to-be their contract surface: driven-port interfaces, test fakes, the secrets interface moved into core, and the postgres package inside the adapters tree — before the first vertical slice (Phase 3) can wire a composition root. Change: - internal/core/ports: full driven-port catalog per plan §3.3 — repositories as transaction-scoped aggregates whose inputs carry derived checks, audit, and events (§3.6), plus CommandExecutor, TargetResolver, Checker, Secrets, EventPublisher, Provisioner. Port-local payload types (Event, AuditEntry, CheckDef, KnowledgeEntry, ExecResult) keep signatures off infrastructure; TypeTree aliases internal/ontology (pure over domain) until checkdefaults is absorbed. ReadModels intentionally not declared yet — it materializes with the Phase 3 slice and grows as report handlers rewire. - secrets.Backend is now an alias of ports.Secrets; implementations (Infisical, SOPS, Manager) unchanged. mcp's local secretBackend subset is deleted; tool constructors take ports.Secrets. - internal/db → internal/adapters/postgres (mechanical import rewrite; package identifier stays db until the Phase 3 repository split). sqlc.yaml, Makefile, golangci exclusions, and docs follow the move; make generate-check verified. - internal/adapters/ssh: Executor implements ports.CommandExecutor over the actuator dial pool + RunStreaming (10-min default timeout carried over from the httpapi path). - internal/adapters/remote: Resolver implements ports.TargetResolver delegating to internal/remote (still pool-based; drops onto ports.EntityRepository when repositories land in Phase 3 — documented transitional import). - internal/core/ports/portstest: importable fakes — in-memory EntityRepo (with check-then-act SetState, side-effect recording), RecordingExecutor, FakeChecker, SpyPublisher; port-satisfaction guards; tests. Risk: ports are declared ahead of implementations — signatures firm up per phase as slices land (documented in the package doc); the remote→postgres transitional import is explicit and dissolves in Phase 3. Verification: go vet, make test (race, 19 packages), generate-check, golangci on core+adapters — 0 issues; full-repo baseline down 365→344.
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -72,7 +72,7 @@ linters:
|
||||
path: internal/httpapi/gen/
|
||||
- linters:
|
||||
- all
|
||||
path: internal/db/sqlcgen/
|
||||
path: internal/adapters/postgres/sqlcgen/
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
|
||||
@@ -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
|
||||
|
||||
4
Makefile
4
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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
6
internal/adapters/postgres/doc.go
Normal file
6
internal/adapters/postgres/doc.go
Normal 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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
63
internal/adapters/remote/resolver.go
Normal file
63
internal/adapters/remote/resolver.go
Normal 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)
|
||||
}
|
||||
114
internal/adapters/ssh/executor.go
Normal file
114
internal/adapters/ssh/executor.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
83
internal/core/ports/entities.go
Normal file
83
internal/core/ports/entities.go
Normal 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)
|
||||
}
|
||||
45
internal/core/ports/events.go
Normal file
45
internal/core/ports/events.go
Normal 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
|
||||
}
|
||||
74
internal/core/ports/execution.go
Normal file
74
internal/core/ports/execution.go
Normal 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
|
||||
}
|
||||
97
internal/core/ports/governance.go
Normal file
97
internal/core/ports/governance.go
Normal 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)
|
||||
}
|
||||
54
internal/core/ports/knowledge.go
Normal file
54
internal/core/ports/knowledge.go
Normal 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
|
||||
}
|
||||
61
internal/core/ports/observation.go
Normal file
61
internal/core/ports/observation.go
Normal 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
|
||||
}
|
||||
242
internal/core/ports/portstest/fakes.go
Normal file
242
internal/core/ports/portstest/fakes.go
Normal 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
|
||||
}
|
||||
121
internal/core/ports/portstest/fakes_test.go
Normal file
121
internal/core/ports/portstest/fakes_test.go
Normal 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)
|
||||
)
|
||||
14
internal/core/ports/secrets.go
Normal file
14
internal/core/ports/secrets.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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)...),
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user