phase 5: secrets migration — Infisical backend, SOPS→Infisical migrate, rotation runbooks

- 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 
This commit is contained in:
2026-07-07 17:27:10 +02:00
parent f4a00a6cfd
commit 890fe1a1c3
12 changed files with 993 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"net/http"
@@ -16,6 +17,7 @@ import (
"github.com/dtoro/oikos/internal/notifier"
"github.com/dtoro/oikos/internal/observability"
"github.com/dtoro/oikos/internal/scheduler"
"github.com/dtoro/oikos/internal/secrets"
"github.com/jackc/pgx/v5"
)
@@ -89,6 +91,8 @@ func main() {
}
case "version":
fmt.Println("oikos dev (Phase 1)")
case "secret":
runSecret(ctx, cfg)
case "help", "--help", "-h":
usage()
default:
@@ -111,6 +115,7 @@ Roles:
scheduler Run the observe + act loop (Phase 3)
notifier Run the notification service (Phase 3)
all Run all roles in one process (dev mode)
secret Secret management (Phase 5)
version Print version info
Environment:
@@ -254,6 +259,92 @@ func runWithPool(ctx context.Context, cfg config.Config, name string, fn func(co
fn(ctx, pool, cfg)
}
func runSecret(ctx context.Context, cfg config.Config) {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: oikos secret <list|migrate|export-sops>")
os.Exit(1)
}
sub := os.Args[2]
secretsDir := cfg.SecretsDir
if secretsDir == "" {
secretsDir = "secrets"
}
sopsBackend := secrets.NewSOPSBackend(secretsDir)
switch sub {
case "list":
keys, err := sopsBackend.List(ctx)
if err != nil {
slog.Error("secret list", "error", err)
os.Exit(1)
}
for _, k := range keys {
fmt.Println(k)
}
case "migrate":
infCfg := secrets.InfisicalConfig{
SiteURL: cfg.InfisicalSiteURL,
ClientID: cfg.InfisicalClientID,
ClientSecret: cfg.InfisicalClientSecret,
ProjectID: cfg.InfisicalProjectID,
SecretPath: "/",
Env: cfg.InfisicalEnv,
}
if infCfg.Env == "" {
infCfg.Env = "dev"
}
if infCfg.SiteURL == "" {
fmt.Fprintln(os.Stderr, "error: OIKOS_INFISICAL_SITE_URL not set")
os.Exit(1)
}
infBackend := secrets.NewInfisicalBackend(infCfg)
keys, err := sopsBackend.List(ctx)
if err != nil {
slog.Error("migrate: read sops", "error", err)
os.Exit(1)
}
migrated, failed := 0, 0
for _, key := range keys {
val, err := sopsBackend.Get(ctx, key)
if err != nil {
slog.Warn("migrate: skip", "key", key, "error", err)
failed++
continue
}
sKey := strings.ReplaceAll(key, "/", "_")
if err := infBackend.Set(ctx, sKey, val); err != nil {
slog.Warn("migrate: push failed", "key", sKey, "error", err)
failed++
continue
}
migrated++
fmt.Printf("migrated: %s → %s\n", key, sKey)
}
fmt.Printf("migrated %d, failed %d\n", migrated, failed)
case "export-sops":
keys, err := sopsBackend.List(ctx)
if err != nil {
slog.Error("export-sops: read", "error", err)
os.Exit(1)
}
fmt.Printf("# SOPS DR fallback — %d keys — export date: now\n", len(keys))
fmt.Printf("# Store in a secure offline location.\n\n")
for _, k := range keys {
fmt.Printf("%s: <sops-encrypted>\n", k)
}
fmt.Printf("\n# To restore: sops -d secrets/*.yaml\n")
default:
fmt.Fprintf(os.Stderr, "unknown secret command: %s\n", sub)
os.Exit(1)
}
}
func runExport(ctx context.Context, cfg config.Config) error {
pool, err := db.New(ctx, cfg.DatabaseURL)
if err != nil {