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.
198 lines
5.0 KiB
Go
198 lines
5.0 KiB
Go
package secrets
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dtoro/oikos/internal/core/ports"
|
|
)
|
|
|
|
var ErrNotFound = errors.New("secret not found")
|
|
var ErrBackendUnavailable = errors.New("secret backend unavailable")
|
|
|
|
// Backend is the secrets port. The interface lives in core/ports (ADR 0016);
|
|
// this alias keeps existing call sites working while implementations
|
|
// (Infisical, SOPS, Manager) stay in this package.
|
|
type Backend = ports.Secrets
|
|
|
|
// Manager holds a primary and fallback backend. If the primary fails,
|
|
// it falls back to the secondary. Supports periodic background refresh
|
|
// of cached secrets from the primary backend.
|
|
type Manager struct {
|
|
primary Backend
|
|
fallback Backend
|
|
cache map[string]cachedSecret
|
|
mu sync.RWMutex
|
|
cacheTTL time.Duration
|
|
refreshMu sync.Mutex
|
|
lastRefresh time.Time
|
|
}
|
|
|
|
type cachedSecret struct {
|
|
value string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// ManagerRefreshInterval controls how often cached secrets are re-fetched
|
|
// from the primary backend in the background. Zero disables background refresh.
|
|
var ManagerRefreshInterval = 5 * time.Minute
|
|
|
|
// NewManager creates a secret manager with primary and fallback backends.
|
|
func NewManager(primary, fallback Backend) *Manager {
|
|
return &Manager{
|
|
primary: primary,
|
|
fallback: fallback,
|
|
cache: make(map[string]cachedSecret),
|
|
cacheTTL: 5 * time.Minute,
|
|
lastRefresh: time.Now(),
|
|
}
|
|
}
|
|
|
|
// StartRefreshLoop starts a background goroutine that periodically refreshes
|
|
// cached secrets from the primary backend. Call from the server's main
|
|
// goroutine. The loop runs until ctx is cancelled.
|
|
func (m *Manager) StartRefreshLoop(ctx context.Context) {
|
|
if ManagerRefreshInterval <= 0 {
|
|
return
|
|
}
|
|
slog.Info("secrets: background refresh loop started",
|
|
"interval", ManagerRefreshInterval)
|
|
ticker := time.NewTicker(ManagerRefreshInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
slog.Info("secrets: refresh loop stopped")
|
|
return
|
|
case <-ticker.C:
|
|
m.refreshAll(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// refreshAll re-fetches all cached secrets from the primary backend.
|
|
// Keys not found in the primary are left cached (they may be in fallback).
|
|
// Logs a summary line.
|
|
func (m *Manager) refreshAll(ctx context.Context) {
|
|
m.refreshMu.Lock()
|
|
defer m.refreshMu.Unlock()
|
|
|
|
if m.primary == nil {
|
|
return
|
|
}
|
|
|
|
m.mu.RLock()
|
|
keys := make([]string, 0, len(m.cache))
|
|
for k := range m.cache {
|
|
keys = append(keys, k)
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
if len(keys) == 0 {
|
|
return
|
|
}
|
|
|
|
refreshed, stale, failed := 0, 0, 0
|
|
for _, key := range keys {
|
|
val, err := m.primary.Get(ctx, key)
|
|
if err != nil {
|
|
failed++
|
|
continue
|
|
}
|
|
m.mu.RLock()
|
|
cached, ok := m.cache[key]
|
|
m.mu.RUnlock()
|
|
if !ok || val != cached.value {
|
|
m.mu.Lock()
|
|
m.cache[key] = cachedSecret{value: val, expiresAt: time.Now().Add(m.cacheTTL)}
|
|
m.mu.Unlock()
|
|
refreshed++
|
|
} else {
|
|
stale++
|
|
}
|
|
}
|
|
|
|
m.lastRefresh = time.Now()
|
|
slog.Info("secrets: background refresh complete",
|
|
"refreshed", refreshed, "stale", stale, "failed", failed,
|
|
"cached", len(keys))
|
|
}
|
|
|
|
// Get retrieves a secret from primary, falling back to secondary on error.
|
|
func (m *Manager) Get(ctx context.Context, key string) (string, error) {
|
|
m.mu.RLock()
|
|
if cached, ok := m.cache[key]; ok && time.Now().Before(cached.expiresAt) {
|
|
m.mu.RUnlock()
|
|
slog.Debug("secret: cache hit", "key", key)
|
|
return cached.value, nil
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
val, err := m.primary.Get(ctx, key)
|
|
if err == nil {
|
|
m.mu.Lock()
|
|
m.cache[key] = cachedSecret{value: val, expiresAt: time.Now().Add(m.cacheTTL)}
|
|
m.mu.Unlock()
|
|
slog.Debug("secret: fetched from primary", "key", key)
|
|
return val, nil
|
|
}
|
|
|
|
if m.fallback != nil {
|
|
val, fallbackErr := m.fallback.Get(ctx, key)
|
|
if fallbackErr == nil {
|
|
slog.Warn("secret: primary failed, using fallback",
|
|
"key", key, "primary_error", err)
|
|
return val, nil
|
|
}
|
|
}
|
|
|
|
slog.Warn("secret: not found in any backend", "key", key, "error", err)
|
|
return "", err
|
|
}
|
|
|
|
// List returns all keys from the primary backend.
|
|
func (m *Manager) List(ctx context.Context) ([]string, error) {
|
|
keys, err := m.primary.List(ctx)
|
|
if err != nil && m.fallback != nil {
|
|
return m.fallback.List(ctx)
|
|
}
|
|
return keys, err
|
|
}
|
|
|
|
// Set stores a secret in the primary backend and invalidates the cache.
|
|
func (m *Manager) Set(ctx context.Context, key string, value string) error {
|
|
m.InvalidateCache()
|
|
return m.primary.Set(ctx, key, value)
|
|
}
|
|
|
|
// Name returns the primary backend name.
|
|
func (m *Manager) Name() string {
|
|
return m.primary.Name()
|
|
}
|
|
|
|
// PrimaryName returns the name of the primary backend.
|
|
func (m *Manager) PrimaryName() string {
|
|
return m.primary.Name()
|
|
}
|
|
|
|
// InvalidateCache clears cached secrets.
|
|
func (m *Manager) InvalidateCache() {
|
|
m.mu.Lock()
|
|
m.cache = make(map[string]cachedSecret)
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// LastRefresh returns the timestamp of the last background refresh.
|
|
func (m *Manager) LastRefresh() time.Time {
|
|
return m.lastRefresh
|
|
}
|
|
|
|
// secretOverlay maps an Infisical key to a config setter function.
|
|
type secretOverlay struct {
|
|
infisicalKey string
|
|
apply func(value string)
|
|
}
|