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

Problem: the hexagon's Phase 2 (plans/2026-08-15-hexagonal-architecture.md)
must give the use-cases-to-be their contract surface: driven-port
interfaces, test fakes, the secrets interface moved into core, and the
postgres package inside the adapters tree — before the first vertical
slice (Phase 3) can wire a composition root.

Change:
- internal/core/ports: full driven-port catalog per plan §3.3 —
  repositories as transaction-scoped aggregates whose inputs carry
  derived checks, audit, and events (§3.6), plus CommandExecutor,
  TargetResolver, Checker, Secrets, EventPublisher, Provisioner.
  Port-local payload types (Event, AuditEntry, CheckDef, KnowledgeEntry,
  ExecResult) keep signatures off infrastructure; TypeTree aliases
  internal/ontology (pure over domain) until checkdefaults is absorbed.
  ReadModels intentionally not declared yet — it materializes with the
  Phase 3 slice and grows as report handlers rewire.
- secrets.Backend is now an alias of ports.Secrets; implementations
  (Infisical, SOPS, Manager) unchanged. mcp's local secretBackend
  subset is deleted; tool constructors take ports.Secrets.
- internal/db → internal/adapters/postgres (mechanical import rewrite;
  package identifier stays db until the Phase 3 repository split).
  sqlc.yaml, Makefile, golangci exclusions, and docs follow the move;
  make generate-check verified.
- internal/adapters/ssh: Executor implements ports.CommandExecutor over
  the actuator dial pool + RunStreaming (10-min default timeout carried
  over from the httpapi path).
- internal/adapters/remote: Resolver implements ports.TargetResolver
  delegating to internal/remote (still pool-based; drops onto
  ports.EntityRepository when repositories land in Phase 3 — documented
  transitional import).
- internal/core/ports/portstest: importable fakes — in-memory
  EntityRepo (with check-then-act SetState, side-effect recording),
  RecordingExecutor, FakeChecker, SpyPublisher; port-satisfaction
  guards; tests.

Risk: ports are declared ahead of implementations — signatures firm up
per phase as slices land (documented in the package doc); the
remote→postgres transitional import is explicit and dissolves in
Phase 3.

Verification: go vet, make test (race, 19 packages), generate-check,
golangci on core+adapters — 0 issues; full-repo baseline down
365→344.
This commit is contained in:
2026-08-15 22:56:56 +02:00
parent d4d99a7473
commit 64f7d54011
93 changed files with 1102 additions and 103 deletions

View File

@@ -2,4 +2,16 @@
// repositories, executors, resolvers, probes, secrets, and events.
// Core packages define these interfaces; adapters under internal/adapters
// implement them. See docs/adr/0016-hexagonal-ports-adapters.md.
//
// Port rules (plan §3.3/§3.6):
// - Command-side repository methods are transaction-scoped aggregates:
// one method = one BEGIN…COMMIT = everything that must succeed or
// fail together. Input structs carry derived checks, audit entries,
// and events as fields.
// - Read methods are plain queries.
// - ReadModels (query-shaped report reads consumed directly by the
// httpapi/mcpserver adapters) materializes with the first vertical
// slice in Phase 3 and grows as report handlers rewire.
// - Signatures reference core/domain types (plus port-local payload
// types); they firm up per phase as the slices land.
package ports

View File

@@ -0,0 +1,83 @@
package ports
import (
"context"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/ontology"
)
// TypeTree aliases the ontology tree: entity types, relationship types,
// lifecycle definitions. ontology is pure over domain; it moves under
// core/ when checkdefaults is absorbed (Phase 3).
type TypeTree = ontology.TypeTree
// EntityFilters bounds entity list/search reads.
type EntityFilters struct {
Type string
State string
Q string
Limit int
}
// EntityCreateInput is one transaction: the entity, its derived check
// definitions, and the audit/event side-effects of the creation.
type EntityCreateInput struct {
Entity domain.Entity
DerivedChecks []CheckDef
Audit []AuditEntry
Event *Event
}
// EntityUpdateInput mirrors EntityCreateInput for updates.
type EntityUpdateInput struct {
Entity domain.Entity
DerivedChecks []CheckDef
Audit []AuditEntry
Event *Event
}
// EntityTransitionInput is a lifecycle state change: the check-then-act
// precondition (current state) is validated inside the transaction.
type EntityTransitionInput struct {
Slug string
From string
To string
Audit []AuditEntry
Event *Event
}
// EntityRepository is the entity aggregate. Command methods are
// transaction-scoped: everything in the input commits or nothing does.
type EntityRepository interface {
Get(ctx context.Context, id domain.UUID) (domain.Entity, error)
BySlug(ctx context.Context, slug string) (domain.Entity, error)
List(ctx context.Context, filters EntityFilters) ([]domain.Entity, error)
Search(ctx context.Context, q string, limit int) ([]domain.Entity, error)
Create(ctx context.Context, input EntityCreateInput) (domain.Entity, error)
Update(ctx context.Context, input EntityUpdateInput) (domain.Entity, error)
SetState(ctx context.Context, input EntityTransitionInput) (domain.Entity, error)
}
// RelationshipCreateInput validates endpoints against the ontology in core
// before this is called; the repository persists the edge (+audit/event).
type RelationshipCreateInput struct {
Relationship domain.Relationship
Audit []AuditEntry
Event *Event
}
// RelationshipRepository is the relationship aggregate.
type RelationshipRepository interface {
Create(ctx context.Context, input RelationshipCreateInput) (domain.Relationship, error)
End(ctx context.Context, source, target domain.UUID, relType string) error
ListFor(ctx context.Context, entityID domain.UUID, direction string) ([]domain.Relationship, error)
}
// OntologyStore loads the type tree; implementations cache. Consumers
// validate entity types, relationship endpoints, and lifecycle transitions
// against it before issuing repository writes.
type OntologyStore interface {
LoadTypeTree(ctx context.Context) (TypeTree, error)
}

View File

@@ -0,0 +1,45 @@
package ports
import (
"context"
"time"
"github.com/dtoro/oikos/internal/core/domain"
)
// Event is a domain occurrence worth publishing (SSE + events table). The
// events adapter maps it to the persistence/SSE shapes.
type Event struct {
Type string // e.g. "execution.completed", "health.changed"
Severity string // "info", "warning", "critical"
Source string // "api", "mcp", "scheduler", "webhook"
EntityID domain.UUID
Data map[string]any
Ts time.Time
}
// EventPublisher fans events out to subscribers and persists them. The
// events adapter owns the SSE broker, the events table, and the dedicated
// LISTEN/NOTIFY connection.
type EventPublisher interface {
Publish(ctx context.Context, event Event) error
}
// AuditEntry is an append-only audit-log record.
type AuditEntry struct {
ActorType string // "agent", "operator", "system"
ActorLabel string
Action string
EntityID domain.UUID
Details map[string]any
Ts time.Time
}
// AuditRepository appends audit records and events. Entries are usually
// carried inside other repositories' input structs so they commit in the
// same transaction (§3.6 of the plan); the standalone methods serve
// read-path actions that audit without another aggregate write.
type AuditRepository interface {
AppendAudit(ctx context.Context, entries []AuditEntry) error
AppendEvent(ctx context.Context, events []Event) error
}

View File

@@ -0,0 +1,74 @@
package ports
import (
"context"
"time"
"github.com/dtoro/oikos/internal/core/domain"
)
// Target is a resolved execution endpoint (mirrors remote.ExecTarget; the
// remote adapter maps between them). Wrap rewrites a plain command for
// transport: identity for a host, pct/qm wrapping for guests.
type Target struct {
Host string
User string
Wrap func(cmd string) string
}
// ExecOpts carries execution options; Sink, when non-nil, receives output
// chunks as they arrive (streaming path).
type ExecOpts struct {
Timeout time.Duration
Sink func(stream string, chunk []byte)
}
// ExecResult is the outcome of one command execution.
type ExecResult struct {
Output string
Duration time.Duration
Err error
}
// CommandExecutor runs a command on a resolved target over SSH. Implemented
// by the ssh adapter wrapping internal/actuator (dial pool + breaker).
type CommandExecutor interface {
Run(ctx context.Context, target Target, command string, opts ExecOpts) ExecResult
}
// TargetResolver resolves entity slugs/IDs to execution endpoints.
// Implemented by the remote adapter on top of EntityRepository.
type TargetResolver interface {
ResolveExecTarget(ctx context.Context, targetSlug string) (Target, error)
ResolveForCheck(ctx context.Context, targetID domain.UUID, targetType string) (Target, error)
ResolveHost(ctx context.Context, hostSlug, fallbackUser string) (addr, user string, err error)
// IsGuest reports whether an entity type is reached via pct/qm exec
// through a Proxmox host rather than by direct SSH.
IsGuest(entityType string) bool
}
// Provisioner creates guests via pct/qm on a Proxmox host (Phase 7 fills
// the input payloads in; signatures firm up with ProvisioningService).
type Provisioner interface {
CreateLXC(ctx context.Context, host domain.UUID, input LXCInput) (domain.UUID, error)
CreateVM(ctx context.Context, host domain.UUID, input VMInput) (domain.UUID, error)
}
// LXCInput is a placeholder until ProvisioningService (Phase 7) fixes the
// create payloads; declared now so the port surface is complete.
type LXCInput struct {
Name string
Template string
Cores int
MemoryMB int
DiskGB int
}
// VMInput mirrors LXCInput for VM creation via qm.
type VMInput struct {
Name string
TemplateID int
Cores int
MemoryMB int
DiskGB int
}

View File

@@ -0,0 +1,97 @@
package ports
import (
"context"
"time"
"github.com/dtoro/oikos/internal/core/domain"
)
// SignalUpsertInput is the observe-pass signal write: open/resolve
// transitions with their triggers, committed atomically.
type SignalUpsertInput struct {
Signal domain.Signal
Triggers []SignalTrigger
Audit []AuditEntry
Event *Event
}
// SignalTrigger is an automatic follow-up fired on a signal transition
// (payload firm; shapes firm up with the Phase 5 observation slice).
type SignalTrigger struct {
Kind string
EntityID domain.UUID
Parameters map[string]any
}
// SignalTransitionInput acks/resolves/mutes a signal (check-then-act on the
// signal's current state inside the transaction).
type SignalTransitionInput struct {
SignalID domain.UUID
Action string // "ack", "resolve", "mute"
Note string
MuteFor time.Duration
Actor string
Audit []AuditEntry
Event *Event
}
// SignalRepository is the signal aggregate.
type SignalRepository interface {
Open(ctx context.Context) ([]domain.Signal, error)
History(ctx context.Context, entityID domain.UUID, limit int) ([]domain.Signal, error)
UpsertWithTriggers(ctx context.Context, input SignalUpsertInput) error
Transition(ctx context.Context, input SignalTransitionInput) (domain.Signal, error)
}
// ExecutionSubmitInput is the queued-execution write: execution row,
// approval (for gated risk classes), audit, and event — one transaction.
type ExecutionSubmitInput struct {
Execution domain.Execution
Approval *domain.Approval
Audit []AuditEntry
Event *Event
}
// ExecutionCompleteInput closes out an execution: final status, output
// summary, audit, event.
type ExecutionCompleteInput struct {
ExecutionID domain.UUID
Status string
Output string
ExitCode int
Audit []AuditEntry
Event *Event
}
// ExecutionRepository is the execution aggregate. Claim uses an advisory
// lock so exactly one worker claims a queued execution.
type ExecutionRepository interface {
List(ctx context.Context, cursor string, limit int) ([]domain.Execution, error)
ReadLog(ctx context.Context, executionID domain.UUID) ([]string, error)
SubmitQueued(ctx context.Context, input ExecutionSubmitInput) (domain.Execution, error)
Claim(ctx context.Context) (*domain.Execution, error)
AppendLog(ctx context.Context, executionID domain.UUID, chunk string) error
Complete(ctx context.Context, input ExecutionCompleteInput) error
}
// ApprovalDecideInput verifies the HMAC token (check-then-act), flips the
// approval, un-gates the execution, and appends audit — one transaction.
// Double-approve must not double-execute.
type ApprovalDecideInput struct {
ApprovalID domain.UUID
Token string
Approved bool
Actor string
Audit []AuditEntry
Event *Event
}
// ApprovalRepository is the approval aggregate.
type ApprovalRepository interface {
ListPending(ctx context.Context, entityID domain.UUID, limit int) ([]domain.Approval, error)
Decide(ctx context.Context, input ApprovalDecideInput) (domain.Approval, error)
}

View File

@@ -0,0 +1,54 @@
package ports
import (
"context"
"time"
"github.com/dtoro/oikos/internal/core/domain"
)
// KnowledgeEntry is a knowledge-base document/investigation/runbook.
type KnowledgeEntry struct {
Slug string
Title string
Kind string // "document", "investigation", "runbook"
Tags []string
Content string
About []string // entity slugs the entry describes
UpdatedAt time.Time
}
// KnowledgeUpsertInput is the knowledge write: row + revision + about-edges
// in one transaction.
type KnowledgeUpsertInput struct {
Entry KnowledgeEntry
Audit []AuditEntry
Event *Event
}
// KnowledgeRepository is the knowledge aggregate.
type KnowledgeRepository interface {
Search(ctx context.Context, query string, limit int) ([]KnowledgeEntry, error)
GetContent(ctx context.Context, slug string) (KnowledgeEntry, error)
Revisions(ctx context.Context, slug string, limit int) ([]KnowledgeEntry, error)
Tags(ctx context.Context) (map[string]int, error)
Orphans(ctx context.Context, staleDays int) ([]KnowledgeEntry, error)
Duplicates(ctx context.Context, threshold float64) ([]KnowledgeEntry, error)
Upsert(ctx context.Context, input KnowledgeUpsertInput) (KnowledgeEntry, error)
Merge(ctx context.Context, targetSlug string, sourceSlugs []string) error
SoftDelete(ctx context.Context, slug string) error
Restore(ctx context.Context, slug string) error
}
// LearningRepository — patterns, feedback, skills (reads plus simple
// upserts; no multi-aggregate transactions here).
type LearningRepository interface {
ListFeedback(ctx context.Context, limit int) ([]domain.Pattern, error)
ListPatterns(ctx context.Context, limit int) ([]domain.Pattern, error)
ListSkills(ctx context.Context) ([]domain.Pattern, error)
UpsertPattern(ctx context.Context, pattern domain.Pattern) error
Validate(ctx context.Context, patternID domain.UUID) error
Quarantine(ctx context.Context, patternID domain.UUID, reason string) error
}

View File

@@ -0,0 +1,61 @@
package ports
import (
"context"
"encoding/json"
"time"
"github.com/dtoro/oikos/internal/core/domain"
)
// CheckDef is a health-check definition (subset of the check_defs row the
// observe pass consumes; grows with the probe adapters in Phase 5).
type CheckDef struct {
ID domain.UUID
EntityID domain.UUID
Kind string
Name string
Config json.RawMessage
IntervalS int
Enabled bool
Severity string
CreatedAt time.Time
UpdatedAt time.Time
}
// CheckResult is what one probe run produces.
type CheckResult struct {
Value float64
State string // "ok", "warning", "critical", "unknown"
Message string
}
// Checker probes one check kind. One adapter per kind under adapters/probes;
// the ObservationService picks the adapter by CheckDef.Kind.
type Checker interface {
Check(ctx context.Context, def CheckDef, target Target) CheckResult
}
// MetricSample is one metric observation to record.
type MetricSample struct {
Metric string
Value float64
Timestamp time.Time
}
// MetricsRepository records observe-pass metrics (Timescale write path;
// bucketed/trend reads belong to ReadModels).
type MetricsRepository interface {
InsertSamples(ctx context.Context, entityID domain.UUID, samples []MetricSample) error
}
// CheckRepository — monitoring definitions. EnsureFor is the read-diff-write
// derivation in one transaction.
type CheckRepository interface {
ListEnabled(ctx context.Context) ([]CheckDef, error)
ListFor(ctx context.Context, entityID domain.UUID) ([]CheckDef, error)
// EnsureFor inserts defs that are missing, updates changed ones, and
// removes stale ones for the entity — atomically.
EnsureFor(ctx context.Context, entityID domain.UUID, desired []CheckDef) error
SetEnabled(ctx context.Context, checkID domain.UUID, enabled bool) error
}

View File

@@ -0,0 +1,242 @@
// Package portstest provides in-memory fakes for the driven ports, for
// use in core/app service tests (and anywhere else a backend-free stand-in
// helps).
package portstest
import (
"context"
"fmt"
"strings"
"sync"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
)
// EntityRepo is an in-memory ports.EntityRepository. Command inputs' audit,
// event, and derived-check fields are recorded for assertion.
type EntityRepo struct {
mu sync.Mutex
byID map[domain.UUID]domain.Entity
bySlug map[string]domain.UUID
order []domain.UUID
nextID int
Audits []ports.AuditEntry
Events []ports.Event
Checks map[domain.UUID][]ports.CheckDef
ErrStub error // returned by every command when set
}
// NewEntityRepo builds an empty in-memory entity repository.
func NewEntityRepo() *EntityRepo {
return &EntityRepo{
byID: make(map[domain.UUID]domain.Entity),
bySlug: make(map[string]domain.UUID),
Checks: make(map[domain.UUID][]ports.CheckDef),
}
}
// Get returns the entity by ID.
func (r *EntityRepo) Get(_ context.Context, id domain.UUID) (domain.Entity, error) {
r.mu.Lock()
defer r.mu.Unlock()
e, ok := r.byID[id]
if !ok {
return domain.Entity{}, domain.ErrNotFound
}
return e, nil
}
// BySlug returns the entity by slug.
func (r *EntityRepo) BySlug(_ context.Context, slug string) (domain.Entity, error) {
r.mu.Lock()
defer r.mu.Unlock()
id, ok := r.bySlug[slug]
if !ok {
return domain.Entity{}, domain.ErrNotFound
}
return r.byID[id], nil
}
// List returns entities filtered by type/state, bounded by limit.
func (r *EntityRepo) List(_ context.Context, f ports.EntityFilters) ([]domain.Entity, error) {
r.mu.Lock()
defer r.mu.Unlock()
var out []domain.Entity
for _, id := range r.order {
e := r.byID[id]
if f.Type != "" && e.Type != f.Type {
continue
}
if f.State != "" && e.State != f.State {
continue
}
out = append(out, e)
if f.Limit > 0 && len(out) >= f.Limit {
break
}
}
return out, nil
}
// Search matches name/slug substrings.
func (r *EntityRepo) Search(ctx context.Context, q string, limit int) ([]domain.Entity, error) {
// Substring over name/slug is enough for service tests.
all, err := r.List(ctx, ports.EntityFilters{Limit: limit})
if err != nil {
return nil, err
}
var out []domain.Entity
for _, e := range all {
if strings.Contains(e.Name, q) || strings.Contains(e.Slug, q) {
out = append(out, e)
}
}
return out, nil
}
// Create stores a new entity and records its input side-effects.
func (r *EntityRepo) Create(_ context.Context, in ports.EntityCreateInput) (domain.Entity, error) {
if r.ErrStub != nil {
return domain.Entity{}, r.ErrStub
}
r.mu.Lock()
defer r.mu.Unlock()
if in.Entity.ID == "" {
r.nextID++
in.Entity.ID = domain.UUID(fmt.Sprintf("fake-entity-%03d", r.nextID))
}
if _, dup := r.bySlug[in.Entity.Slug]; dup {
return domain.Entity{}, domain.ErrConflict
}
r.store(in.Entity)
r.Audits = append(r.Audits, in.Audit...)
if in.Event != nil {
r.Events = append(r.Events, *in.Event)
}
r.Checks[in.Entity.ID] = in.DerivedChecks
return in.Entity, nil
}
// Update replaces a stored entity and records its input side-effects.
func (r *EntityRepo) Update(_ context.Context, in ports.EntityUpdateInput) (domain.Entity, error) {
if r.ErrStub != nil {
return domain.Entity{}, r.ErrStub
}
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.byID[in.Entity.ID]; !ok {
return domain.Entity{}, domain.ErrNotFound
}
delete(r.bySlug, r.byID[in.Entity.ID].Slug)
r.store(in.Entity)
r.Audits = append(r.Audits, in.Audit...)
if in.Event != nil {
r.Events = append(r.Events, *in.Event)
}
r.Checks[in.Entity.ID] = in.DerivedChecks
return in.Entity, nil
}
// SetState applies a lifecycle transition, refusing stale From states.
func (r *EntityRepo) SetState(_ context.Context, in ports.EntityTransitionInput) (domain.Entity, error) {
if r.ErrStub != nil {
return domain.Entity{}, r.ErrStub
}
r.mu.Lock()
defer r.mu.Unlock()
id, ok := r.bySlug[in.Slug]
if !ok {
return domain.Entity{}, domain.ErrNotFound
}
e := r.byID[id]
if e.State != in.From {
return domain.Entity{}, domain.ErrConflict
}
e.State = in.To
r.byID[id] = e
r.Audits = append(r.Audits, in.Audit...)
if in.Event != nil {
r.Events = append(r.Events, *in.Event)
}
return e, nil
}
func (r *EntityRepo) store(e domain.Entity) {
r.byID[e.ID] = e
r.bySlug[e.Slug] = e.ID
r.order = append(r.order, e.ID)
}
// FindBySlug is a test helper bypassing the port interface.
func (r *EntityRepo) FindBySlug(slug string) (domain.Entity, bool) {
r.mu.Lock()
defer r.mu.Unlock()
id, ok := r.bySlug[slug]
if !ok {
return domain.Entity{}, false
}
return r.byID[id], true
}
// RecordingExecutor records every command it is asked to run and replies
// with canned results (default: empty success).
type RecordingExecutor struct {
mu sync.Mutex
Calls []ExecCall
Results []ports.ExecResult // popped in order; last one repeats
}
// ExecCall is one recorded CommandExecutor.Run invocation.
type ExecCall struct {
Target ports.Target
Command string
}
// Run records the call and replies with the next canned result.
func (e *RecordingExecutor) Run(_ context.Context, target ports.Target, command string, _ ports.ExecOpts) ports.ExecResult {
e.mu.Lock()
defer e.mu.Unlock()
e.Calls = append(e.Calls, ExecCall{Target: target, Command: command})
if len(e.Results) == 0 {
return ports.ExecResult{}
}
res := e.Results[0]
if len(e.Results) > 1 {
e.Results = e.Results[1:]
}
return res
}
// FakeChecker answers per check kind; unregistered kinds report "unknown".
type FakeChecker struct {
mu sync.Mutex
ByKind map[string]ports.CheckResult
Calls []string
}
// Check answers from the kind map, recording the call.
func (c *FakeChecker) Check(_ context.Context, def ports.CheckDef, _ ports.Target) ports.CheckResult {
c.mu.Lock()
defer c.mu.Unlock()
c.Calls = append(c.Calls, def.Kind)
if res, ok := c.ByKind[def.Kind]; ok {
return res
}
return ports.CheckResult{State: "unknown"}
}
// SpyPublisher records published events.
type SpyPublisher struct {
mu sync.Mutex
Events []ports.Event
Err error
}
// Publish records the event.
func (p *SpyPublisher) Publish(_ context.Context, event ports.Event) error {
p.mu.Lock()
defer p.mu.Unlock()
p.Events = append(p.Events, event)
return p.Err
}

View File

@@ -0,0 +1,121 @@
package portstest
import (
"context"
"errors"
"testing"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
)
func TestEntityRepoCreateReadUpdate(t *testing.T) {
r := NewEntityRepo()
ctx := context.Background()
e, err := r.Create(ctx, ports.EntityCreateInput{
Entity: domain.Entity{Slug: "lxc:test", Name: "test", Type: "lxc", State: "active"},
Audit: []ports.AuditEntry{{Action: "entity.create"}},
Event: &ports.Event{Type: "entity.created"},
})
if err != nil {
t.Fatalf("create: %v", err)
}
if e.ID == "" {
t.Fatal("create did not assign an ID")
}
got, err := r.BySlug(ctx, "lxc:test")
if err != nil {
t.Fatalf("bySlug: %v", err)
}
if got.ID != e.ID {
t.Fatalf("bySlug returned %s, want %s", got.ID, e.ID)
}
if len(r.Audits) != 1 || r.Audits[0].Action != "entity.create" {
t.Fatalf("audit not recorded: %+v", r.Audits)
}
if len(r.Events) != 1 || r.Events[0].Type != "entity.created" {
t.Fatalf("event not recorded: %+v", r.Events)
}
_, err = r.Create(ctx, ports.EntityCreateInput{
Entity: domain.Entity{Slug: "lxc:test", Name: "dup", Type: "lxc"},
})
if !errors.Is(err, domain.ErrConflict) && !errors.Is(err, domain.ErrAlreadyExists) {
t.Fatalf("duplicate slug: got %v, want conflict", err)
}
}
func TestEntityRepoSetStateCheckThenAct(t *testing.T) {
r := NewEntityRepo()
ctx := context.Background()
_, err := r.Create(ctx, ports.EntityCreateInput{
Entity: domain.Entity{Slug: "host:one", Name: "one", Type: "host", State: "active"},
})
if err != nil {
t.Fatalf("create: %v", err)
}
_, err = r.SetState(ctx, ports.EntityTransitionInput{Slug: "host:one", From: "planned", To: "decommissioned"})
if !errors.Is(err, domain.ErrConflict) {
t.Fatalf("stale From: got %v, want ErrConflict", err)
}
e, err := r.SetState(ctx, ports.EntityTransitionInput{Slug: "host:one", From: "active", To: "decommissioned"})
if err != nil {
t.Fatalf("SetState: %v", err)
}
if e.State != "decommissioned" {
t.Fatalf("state = %s, want decommissioned", e.State)
}
}
func TestRecordingExecutor(t *testing.T) {
e := &RecordingExecutor{Results: []ports.ExecResult{{Output: "first"}, {Output: "second"}}}
res := e.Run(context.Background(), ports.Target{Host: "h", User: "root"}, "uptime", ports.ExecOpts{})
if res.Output != "first" {
t.Fatalf("first run output = %q", res.Output)
}
e.Run(context.Background(), ports.Target{Host: "h"}, "w", ports.ExecOpts{})
e.Run(context.Background(), ports.Target{Host: "h"}, "true", ports.ExecOpts{})
if len(e.Calls) != 3 || e.Calls[2].Command != "true" {
t.Fatalf("calls not recorded: %+v", e.Calls)
}
if got := e.Run(context.Background(), ports.Target{}, "x", ports.ExecOpts{}); got.Output != "second" {
t.Fatalf("last result should repeat, got %q", got.Output)
}
}
func TestFakeChecker(t *testing.T) {
c := &FakeChecker{ByKind: map[string]ports.CheckResult{
"http": {State: "ok", Value: 200},
}}
res := c.Check(context.Background(), ports.CheckDef{Kind: "http"}, ports.Target{})
if res.State != "ok" || res.Value != 200 {
t.Fatalf("http check = %+v", res)
}
res = c.Check(context.Background(), ports.CheckDef{Kind: "dns"}, ports.Target{})
if res.State != "unknown" {
t.Fatalf("unregistered kind = %+v, want unknown", res)
}
}
func TestSpyPublisher(t *testing.T) {
p := &SpyPublisher{}
if err := p.Publish(context.Background(), ports.Event{Type: "x"}); err != nil {
t.Fatalf("publish: %v", err)
}
if len(p.Events) != 1 || p.Events[0].Type != "x" {
t.Fatalf("events = %+v", p.Events)
}
}
// Interface satisfaction guards: the fakes must implement the ports.
var (
_ ports.EntityRepository = (*EntityRepo)(nil)
_ ports.CommandExecutor = (*RecordingExecutor)(nil)
_ ports.Checker = (*FakeChecker)(nil)
_ ports.EventPublisher = (*SpyPublisher)(nil)
)

View File

@@ -0,0 +1,14 @@
package ports
import "context"
// Secrets retrieves and stores secrets. Infisical/SOPS implementations
// stay in internal/secrets; the caching Manager satisfies this by
// delegation. The interface lives here so core code never imports the
// backends.
type Secrets interface {
Get(ctx context.Context, key string) (string, error)
List(ctx context.Context) ([]string, error)
Set(ctx context.Context, key string, value string) error
Name() string
}