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/ REST + MCP server. Chi router. OpenAPI-generated types from
|
||||||
internal/httpapi/gen/api.gen.go. Strict server in impl.go.
|
internal/httpapi/gen/api.gen.go. Strict server in impl.go.
|
||||||
internal/mcp/ MCP tool implementations (get_entity, search_knowledge, etc.)
|
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)
|
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/scheduler/ Observe loop: probes, signals, check_defs
|
||||||
internal/actuator/ SSH execution with circuit breaker + retry
|
internal/actuator/ SSH execution with circuit breaker + retry
|
||||||
internal/learning/ Pattern extraction, anomaly detection
|
internal/learning/ Pattern extraction, anomaly detection
|
||||||
@@ -91,8 +91,8 @@ current phase status). To add a new capability:
|
|||||||
|
|
||||||
## SQL conventions
|
## SQL conventions
|
||||||
|
|
||||||
- Queries live in `internal/db/queries/*.sql` with `-- name: FuncName :exec`
|
- Queries live in `internal/adapters/postgres/queries/*.sql` with `-- name: FuncName :exec`
|
||||||
annotations for sqlc. Generated code in `internal/db/sqlcgen/` — never
|
annotations for sqlc. Generated code in `internal/adapters/postgres/sqlcgen/` — never
|
||||||
hand-edit. Call via `sqlcgen.New(pool).QueryName(ctx, params)`.
|
hand-edit. Call via `sqlcgen.New(pool).QueryName(ctx, params)`.
|
||||||
- **sqlc is the default** for all DB access. Raw `pool.Query/Exec` with inline
|
- **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`,
|
SQL is a documented carve-out for cases sqlc can't express: `LISTEN`/`NOTIFY`,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ linters:
|
|||||||
path: internal/httpapi/gen/
|
path: internal/httpapi/gen/
|
||||||
- linters:
|
- linters:
|
||||||
- all
|
- all
|
||||||
path: internal/db/sqlcgen/
|
path: internal/adapters/postgres/sqlcgen/
|
||||||
paths:
|
paths:
|
||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ internal/ All Go packages
|
|||||||
here phase by phase
|
here phase by phase
|
||||||
httpapi/ REST + MCP server (OpenAPI-generated)
|
httpapi/ REST + MCP server (OpenAPI-generated)
|
||||||
mcp/ MCP tool implementations
|
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
|
scheduler/ Observe loop, probes, signals
|
||||||
actuator/ SSH execution
|
actuator/ SSH execution
|
||||||
learning/ Pattern recognition, anomaly detection
|
learning/ Pattern recognition, anomaly detection
|
||||||
@@ -116,8 +118,8 @@ Never hand-edit `internal/httpapi/gen/api.gen.go`.
|
|||||||
|
|
||||||
### Database access is sqlc-first
|
### Database access is sqlc-first
|
||||||
|
|
||||||
SQL queries live in `internal/db/queries/*.sql`. Go code is generated with
|
SQL queries live in `internal/adapters/postgres/queries/*.sql`. Go code is generated with
|
||||||
`sqlc` into `internal/db/sqlcgen/`. Config in `sqlc.yaml`.
|
`sqlc` into `internal/adapters/postgres/sqlcgen/`. Config in `sqlc.yaml`.
|
||||||
|
|
||||||
- Queries target pgx/v5 with UUID + timestamptz overrides
|
- Queries target pgx/v5 with UUID + timestamptz overrides
|
||||||
- Never hand-edit generated sqlc code
|
- Never hand-edit generated sqlc code
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -18,7 +18,7 @@ test-db:
|
|||||||
docker compose up -d postgres
|
docker compose up -d postgres
|
||||||
@sleep 3
|
@sleep 3
|
||||||
OIKOS_TEST_DATABASE_URL="postgres://oikos:$${OIKOS_DB_PASSWORD:-oikos_dev}@localhost:5432/oikos?sslmode=disable" \
|
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
|
lint: vet golangci govulncheck
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ generate:
|
|||||||
|
|
||||||
# CI drift guard: regenerate and fail if the committed output changed.
|
# CI drift guard: regenerate and fail if the committed output changed.
|
||||||
generate-check: generate
|
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)
|
|| (echo "generated code is stale — run 'make generate' and commit" && exit 1)
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/config"
|
"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/execworker"
|
||||||
"github.com/dtoro/oikos/internal/httpapi"
|
"github.com/dtoro/oikos/internal/httpapi"
|
||||||
"github.com/dtoro/oikos/internal/knowledge"
|
"github.com/dtoro/oikos/internal/knowledge"
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/config"
|
"github.com/dtoro/oikos/internal/config"
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/dtoro/oikos/internal/secrets"
|
"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
|
exp time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EntityCache is a TTL cache mapping entity IDs to slugs and back,
|
||||||
|
// keyed for the hot resolution paths.
|
||||||
type EntityCache struct {
|
type EntityCache struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
m map[string]entityCacheEntry
|
m map[string]entityCacheEntry
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewEntityCache builds a cache with the given TTL.
|
||||||
func NewEntityCache(ttl time.Duration) *EntityCache {
|
func NewEntityCache(ttl time.Duration) *EntityCache {
|
||||||
return &EntityCache{
|
return &EntityCache{
|
||||||
m: make(map[string]entityCacheEntry),
|
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) {
|
func (c *EntityCache) GetSlug(id string) (string, bool) {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
e, ok := c.m[id]
|
e, ok := c.m[id]
|
||||||
@@ -35,6 +39,7 @@ func (c *EntityCache) GetSlug(id string) (string, bool) {
|
|||||||
return e.slug, true
|
return e.slug, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetID resolves a slug to its entity ID.
|
||||||
func (c *EntityCache) GetID(slug string) (string, bool) {
|
func (c *EntityCache) GetID(slug string) (string, bool) {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
e, ok := c.m[slug]
|
e, ok := c.m[slug]
|
||||||
@@ -45,6 +50,7 @@ func (c *EntityCache) GetID(slug string) (string, bool) {
|
|||||||
return e.id, true
|
return e.id, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set records the slug/id pair and serialized attributes.
|
||||||
func (c *EntityCache) Set(slug, id, attrs string) {
|
func (c *EntityCache) Set(slug, id, attrs string) {
|
||||||
exp := time.Now().Add(c.ttl)
|
exp := time.Now().Add(c.ttl)
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -53,6 +59,7 @@ func (c *EntityCache) Set(slug, id, attrs string) {
|
|||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate drops the cached entries for one slug/id pair.
|
||||||
func (c *EntityCache) Invalidate(slug, id string) {
|
func (c *EntityCache) Invalidate(slug, id string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
delete(c.m, slug)
|
delete(c.m, slug)
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"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 {
|
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
|
||||||
return fmt.Errorf("acquire migration lock: %w", err)
|
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
|
// Create tracking table if not exists
|
||||||
_, err = conn.Exec(ctx, `
|
_, err = conn.Exec(ctx, `
|
||||||
@@ -156,7 +160,11 @@ func (p *Pool) SeedIngest(ctx context.Context, filename string, content []byte,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin tx: %w", err)
|
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 {
|
if err := ingestFn(ctx, tx, data); err != nil {
|
||||||
return fmt.Errorf("ingest %s: %w", filename, err)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Finding is one drift item the operator should look at.
|
// Finding is one drift item the operator should look at.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,16 @@
|
|||||||
// repositories, executors, resolvers, probes, secrets, and events.
|
// repositories, executors, resolvers, probes, secrets, and events.
|
||||||
// Core packages define these interfaces; adapters under internal/adapters
|
// Core packages define these interfaces; adapters under internal/adapters
|
||||||
// implement them. See docs/adr/0016-hexagonal-ports-adapters.md.
|
// 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
|
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"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/config"
|
"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.
|
// 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/actuator"
|
||||||
"github.com/dtoro/oikos/internal/config"
|
"github.com/dtoro/oikos/internal/config"
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
"github.com/dtoro/oikos/internal/health"
|
"github.com/dtoro/oikos/internal/health"
|
||||||
"github.com/dtoro/oikos/internal/remote"
|
"github.com/dtoro/oikos/internal/remote"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/actuator"
|
"github.com/dtoro/oikos/internal/actuator"
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
"github.com/dtoro/oikos/internal/execlog"
|
"github.com/dtoro/oikos/internal/execlog"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/config"
|
"github.com/dtoro/oikos/internal/config"
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"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/core/domain"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"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/core/domain"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"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/core/domain"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"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/core/domain"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"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/core/domain"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"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/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"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/core/domain"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"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/core/domain"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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/core/domain"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"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/core/domain"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"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/core/domain"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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/core/domain"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
|
|
||||||
"github.com/dtoro/oikos/internal/actuator"
|
"github.com/dtoro/oikos/internal/actuator"
|
||||||
"github.com/dtoro/oikos/internal/config"
|
"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"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
mcphandler "github.com/dtoro/oikos/internal/mcp"
|
mcphandler "github.com/dtoro/oikos/internal/mcp"
|
||||||
"github.com/dtoro/oikos/internal/safego"
|
"github.com/dtoro/oikos/internal/safego"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"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/core/domain"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/config"
|
"github.com/dtoro/oikos/internal/config"
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWilsonLowerBound(t *testing.T) {
|
func TestWilsonLowerBound(t *testing.T) {
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"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/dtoro/oikos/internal/policy"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"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{
|
return []toolReg{
|
||||||
{tool: &mcp.Tool{Name: "get_health_summary", Description: "Fleet health per entity — optionally filter by health state(s)",
|
{tool: &mcp.Tool{Name: "get_health_summary", Description: "Fleet health per entity — optionally filter by health state(s)",
|
||||||
InputSchema: objSchema(
|
InputSchema: objSchema(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/checkdefaults"
|
"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/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"strings"
|
"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/execlog"
|
||||||
"github.com/dtoro/oikos/internal/remote"
|
"github.com/dtoro/oikos/internal/remote"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/audit"
|
"github.com/dtoro/oikos/internal/audit"
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
"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"
|
"github.com/google/uuid"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"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{
|
return []toolReg{
|
||||||
{tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.",
|
{tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.",
|
||||||
InputSchema: objSchema(),
|
InputSchema: objSchema(),
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"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/google/uuid"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"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{
|
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.",
|
{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"}),
|
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
"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/dtoro/oikos/internal/observability"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"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{
|
return []toolReg{
|
||||||
// ── request_execution (legacy fixed enum) retired 2026-07-14 ──
|
// ── request_execution (legacy fixed enum) retired 2026-07-14 ──
|
||||||
// All mutations now route through `run`. The handler functions
|
// All mutations now route through `run`. The handler functions
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/actuator"
|
"github.com/dtoro/oikos/internal/actuator"
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
"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/execlog"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/dtoro/oikos/internal/policy"
|
"github.com/dtoro/oikos/internal/policy"
|
||||||
@@ -48,18 +49,9 @@ func objSchema(props ...prop) *jsonschema.Schema {
|
|||||||
return s
|
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.
|
// 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.
|
// 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)
|
s := newServer(pool, agentID, sec)
|
||||||
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
||||||
if token != "" {
|
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.
|
// toolHandler is the function signature registered via AddTool.
|
||||||
type toolHandler = mcp.ToolHandler
|
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{
|
s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
|
||||||
Logger: slog.Default(),
|
Logger: slog.Default(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/checkdefaults"
|
"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/google/uuid"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
@@ -16,7 +17,7 @@ type toolReg struct {
|
|||||||
handler toolHandler
|
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(
|
return append(append(append(append(
|
||||||
[]toolReg{},
|
[]toolReg{},
|
||||||
EntityTools(pool, agentID, sec)...),
|
EntityTools(pool, agentID, sec)...),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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.
|
// checkBackupFreshness reports whether a backup target has a recent artifact.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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 {
|
func backupCheckDef(t *testing.T, config string) sqlcgen.ListEnabledCheckDefsRow {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/config"
|
"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.
|
// 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/actuator"
|
||||||
"github.com/dtoro/oikos/internal/config"
|
"github.com/dtoro/oikos/internal/config"
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
"github.com/dtoro/oikos/internal/health"
|
"github.com/dtoro/oikos/internal/health"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/dtoro/oikos/internal/remote"
|
"github.com/dtoro/oikos/internal/remote"
|
||||||
|
|||||||
@@ -6,18 +6,17 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNotFound = errors.New("secret not found")
|
var ErrNotFound = errors.New("secret not found")
|
||||||
var ErrBackendUnavailable = errors.New("secret backend unavailable")
|
var ErrBackendUnavailable = errors.New("secret backend unavailable")
|
||||||
|
|
||||||
// Backend is the interface for retrieving and storing secrets.
|
// Backend is the secrets port. The interface lives in core/ports (ADR 0016);
|
||||||
type Backend interface {
|
// this alias keeps existing call sites working while implementations
|
||||||
Get(ctx context.Context, key string) (string, error)
|
// (Infisical, SOPS, Manager) stay in this package.
|
||||||
List(ctx context.Context) ([]string, error)
|
type Backend = ports.Secrets
|
||||||
Set(ctx context.Context, key string, value string) error
|
|
||||||
Name() string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manager holds a primary and fallback backend. If the primary fails,
|
// Manager holds a primary and fallback backend. If the primary fails,
|
||||||
// it falls back to the secondary. Supports periodic background refresh
|
// it falls back to the secondary. Supports periodic background refresh
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Hexagonal architecture for Oikos — design and phased refactor plan
|
# Hexagonal architecture for Oikos — design and phased refactor plan
|
||||||
|
|
||||||
**Date:** 2026-08-15
|
**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
|
**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
|
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.
|
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-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 | [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-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
|
## Done
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
# sqlc — type-safe Go from SQL (plan SG17). `make generate` regenerates.
|
# sqlc — type-safe Go from SQL (plan SG17). `make generate` regenerates.
|
||||||
#
|
#
|
||||||
# Scope: API read/mutation paths use sqlc-generated queries
|
# 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
|
# hand-written pgx: they are generic bulk upserts driven by parsed YAML
|
||||||
# shapes, where sqlc's static typing adds nothing.
|
# shapes, where sqlc's static typing adds nothing.
|
||||||
version: "2"
|
version: "2"
|
||||||
sql:
|
sql:
|
||||||
- engine: "postgresql"
|
- engine: "postgresql"
|
||||||
schema: "migrations"
|
schema: "migrations"
|
||||||
queries: "internal/db/queries"
|
queries: "internal/adapters/postgres/queries"
|
||||||
strict_order_by: false
|
strict_order_by: false
|
||||||
gen:
|
gen:
|
||||||
go:
|
go:
|
||||||
package: "sqlcgen"
|
package: "sqlcgen"
|
||||||
out: "internal/db/sqlcgen"
|
out: "internal/adapters/postgres/sqlcgen"
|
||||||
sql_package: "pgx/v5"
|
sql_package: "pgx/v5"
|
||||||
emit_pointers_for_null_types: true
|
emit_pointers_for_null_types: true
|
||||||
overrides:
|
overrides:
|
||||||
|
|||||||
Reference in New Issue
Block a user