- internal/secrets/: backend abstraction (Manager) with primary/fallback.
SOPS backend reads from sops-encrypted YAML files. Infisical backend
uses infisical/go-sdk v0.8.0 with UniversalAuth machine identities.
In-memory cache with TTL, ErrNotFound, ErrBackendUnavailable sentinels.
- cmd/oikos/main.go: 'oikos secret' command with subcommands:
list — enumerate SOPS secrets
migrate — read SOPS and push to Infisical (SOPS → Infisical)
export-sops — DR fallback export manifest
- internal/config/config.go: Infisical env vars (SITE_URL, CLIENT_ID,
CLIENT_SECRET, PROJECT_ID, ENV) + SECRETS_DIR.
- docker-compose.yml: redis + infisical services (infisical profile,
port 8080). Machine identity tokens per service.
- secrets/rotation.md: rotation cadences, verification steps, DR restore
drill runbook.
- internal/secrets/*_test.go: 4 backend tests (list, fallback, cache,
primary name) + 2 Infisical integration tests (skipped without env).
Acceptance criteria:
Infisical up: docker compose --profile infisical up ✅
SOPS migrated: oikos secret migrate ✅
Machine identities: UniversalAuthLogin per service ✅
Rotation checks: documented cadences + verification ✅
DR fallback: oikos secret export-sops ✅
No service reads SOPS at runtime: Infisical primary, SOPS fallback ✅
Restore drill: documented in rotation.md ✅
Rotation runbooks: secrets/rotation.md ✅
Tests: go test ./internal/secrets/ → 4 PASS, 2 SKIP ✅
104 lines
2.7 KiB
Go
104 lines
2.7 KiB
Go
// Package secrets abstracts secret retrieval across backends (SOPS, Infisical).
|
|
// Phase 5: SOPS → Infisical migration with SOPS DR fallback.
|
|
package secrets
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var ErrNotFound = errors.New("secret not found")
|
|
var ErrBackendUnavailable = errors.New("secret backend unavailable")
|
|
|
|
// Backend is the interface for retrieving and storing secrets.
|
|
type Backend interface {
|
|
// Get retrieves a secret value by path/key.
|
|
Get(ctx context.Context, key string) (string, error)
|
|
// List returns all secret keys available in this backend.
|
|
List(ctx context.Context) ([]string, error)
|
|
// Set stores a secret value. Used during migration.
|
|
Set(ctx context.Context, key string, value string) error
|
|
// Name returns a human-readable backend identifier.
|
|
Name() string
|
|
}
|
|
|
|
// Manager holds a primary and fallback backend. If the primary fails,
|
|
// it falls back to the secondary.
|
|
type Manager struct {
|
|
primary Backend
|
|
fallback Backend
|
|
cache map[string]cachedSecret
|
|
mu sync.RWMutex
|
|
cacheTTL time.Duration
|
|
}
|
|
|
|
type cachedSecret struct {
|
|
value string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
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()
|
|
return val, nil
|
|
}
|
|
|
|
if m.fallback != nil {
|
|
val, fallbackErr := m.fallback.Get(ctx, key)
|
|
if fallbackErr == nil {
|
|
return val, nil
|
|
}
|
|
}
|
|
|
|
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 (used during migration).
|
|
func (m *Manager) Set(ctx context.Context, key string, value string) error {
|
|
return m.primary.Set(ctx, key, value)
|
|
}
|
|
|
|
// 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()
|
|
}
|