diff --git a/internal/app/restore.go b/internal/app/restore.go index 52ae98e..901df35 100644 --- a/internal/app/restore.go +++ b/internal/app/restore.go @@ -6,10 +6,13 @@ import ( "flag" "fmt" "io" + "log/slog" + "os" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/logging" ) var newObjectStoreFromConfigFn = storage.NewObjectStoreFromConfig @@ -68,6 +71,9 @@ func Restore(ctx context.Context, args []string, out io.Writer) error { if err := config.Validate(cfg); err != nil { return fmt.Errorf("restore: %w", err) } + if _, err := loadSecretsFromConfig(cfg, logging.NewLogger(os.Stderr, slog.LevelInfo)); err != nil { + return fmt.Errorf("restore: %w", err) + } objectStore, err := newObjectStoreFromConfigFn(ctx, cfg) if err != nil { diff --git a/internal/app/restore_test.go b/internal/app/restore_test.go index f503d6a..e06987d 100644 --- a/internal/app/restore_test.go +++ b/internal/app/restore_test.go @@ -179,6 +179,103 @@ func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) { } } +func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) { + origStoreFn := newObjectStoreFromConfigFn + origDiscoverFn := discoverRemoteCurrentStateFn + origPlanFn := buildRestorePlanFn + origExecuteFn := executeRestorePlanFn + t.Cleanup(func() { + newObjectStoreFromConfigFn = origStoreFn + discoverRemoteCurrentStateFn = origDiscoverFn + buildRestorePlanFn = origPlanFn + executeRestorePlanFn = origExecuteFn + }) + + const accessKeyEnv = "OBJECT_STORAGE_KEY_ID" + const secretKeyEnv = "OBJECT_STORAGE_KEY" + restoreEnv := func(name string) { + value, exists := os.LookupEnv(name) + _ = os.Unsetenv(name) + t.Cleanup(func() { + if exists { + _ = os.Setenv(name, value) + return + } + _ = os.Unsetenv(name) + }) + } + restoreEnv(accessKeyEnv) + restoreEnv(secretKeyEnv) + + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + secretsDir := filepath.Join(t.TempDir(), "secrets") + mustWriteTestFile(t, filepath.Join(secretsDir, accessKeyEnv), "test-access-key-id\n") + mustWriteTestFile(t, filepath.Join(secretsDir, secretKeyEnv), "test-secret-key\n") + f, err := os.OpenFile(pipelinePath, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatalf("open pipeline config for append: %v", err) + } + defer f.Close() + if _, err := f.WriteString("\nsecrets:\n env_dir: " + secretsDir + "\n"); err != nil { + t.Fatalf("append secrets config: %v", err) + } + + storeInitCalled := false + newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { + storeInitCalled = true + gotID, okID := os.LookupEnv(accessKeyEnv) + if !okID || gotID != "test-access-key-id" { + return nil, fmt.Errorf("missing or unexpected %s: %q (set=%t)", accessKeyEnv, gotID, okID) + } + gotSecret, okSecret := os.LookupEnv(secretKeyEnv) + if !okSecret || gotSecret != "test-secret-key" { + return nil, fmt.Errorf("missing or unexpected %s: %q (set=%t)", secretKeyEnv, gotSecret, okSecret) + } + return &storage.FakeBackend{}, nil + } + discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) { + return &RemoteCurrentState{ + SessionID: "2026-05-03", + Campaign: "sample-campaign", + RunID: "20260519T010203Z-a1b2c3d4", + }, nil + } + buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) { + return &RestorePlan{ + Actions: []RestoreAction{ + { + Kind: RestoreActionDownload, + LocalRelativePath: "manifest.json", + RemoteKey: "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/manifest.json", + Reason: "local file missing", + }, + }, + DownloadCount: 1, + }, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute( + []string{ + "restore", + "--config", pipelinePath, + "--session", sessionPath, + "--session-id", "2026-05-03", + "--dry-run", + }, + &stdout, + &stderr, + ) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) + } + if !storeInitCalled { + t.Fatal("expected object store initialization to be called") + } +} + func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) { origStoreFn := newObjectStoreFromConfigFn origDiscoverFn := discoverRemoteCurrentStateFn