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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user