From 128449040fe993474590ca51c264129aaa8bbd24 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 19 May 2026 21:49:19 -0500 Subject: [PATCH] Implement remote current-state discovery for the restore subcommand --- internal/app/restore.go | 18 +- internal/app/restore_discovery.go | 139 ++++++++++++++ internal/app/restore_discovery_test.go | 239 +++++++++++++++++++++++++ internal/app/restore_test.go | 67 ++++++- 4 files changed, 456 insertions(+), 7 deletions(-) create mode 100644 internal/app/restore_discovery.go create mode 100644 internal/app/restore_discovery_test.go diff --git a/internal/app/restore.go b/internal/app/restore.go index ebb0189..55742ee 100644 --- a/internal/app/restore.go +++ b/internal/app/restore.go @@ -11,6 +11,9 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/config" ) +var newObjectStoreFromConfigFn = storage.NewObjectStoreFromConfig +var discoverRemoteCurrentStateFn = discoverRemoteCurrentState + // Restore validates restore CLI/config inputs and storage preflight for future restore phases. func Restore(ctx context.Context, args []string, out io.Writer) error { fs := flag.NewFlagSet("restore", flag.ContinueOnError) @@ -63,14 +66,23 @@ func Restore(ctx context.Context, args []string, out io.Writer) error { return fmt.Errorf("restore: %w", err) } - _, err = storage.NewObjectStoreFromConfig(ctx, cfg) + objectStore, err := newObjectStoreFromConfigFn(ctx, cfg) + if err != nil { + return fmt.Errorf("restore: %w", err) + } + current, err := discoverRemoteCurrentStateFn(ctx, cfg, objectStore) if err != nil { return fmt.Errorf("restore: %w", err) } - // Phase 2 boundary: command wiring and preflight only. + // Phase 3 boundary: discovery is implemented; planning/execution is deferred. _ = dryRun _ = force _ = includeAudio - return fmt.Errorf("restore: not yet implemented (phase 3: remote current-state discovery)") + return fmt.Errorf( + "restore: discovered remote current state for %s/%s (run %s); not yet implemented (phase 3: remote current-state discovery)", + current.Campaign, + current.SessionID, + current.RunID, + ) } diff --git a/internal/app/restore_discovery.go b/internal/app/restore_discovery.go new file mode 100644 index 0000000..ced27a1 --- /dev/null +++ b/internal/app/restore_discovery.go @@ -0,0 +1,139 @@ +package app + +import ( + "context" + "fmt" + "os" + "strings" + + "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/manifest" +) + +// RemoteCurrentState captures discovered committed remote archive state for one session. +type RemoteCurrentState struct { + Bucket string + SessionPrefix string + CurrentRunIDKey string + CurrentManifestKey string + RunID string + SessionID string + Campaign string + Manifest *manifest.Manifest +} + +func discoverRemoteCurrentState(ctx context.Context, cfg *config.Config, store storage.ObjectStore) (*RemoteCurrentState, error) { + if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil { + return nil, fmt.Errorf("resolved config with pipeline/session is required") + } + if store == nil { + return nil, fmt.Errorf("remote object store is required") + } + + bucket := artifacts.ResolveArchiveBucket(cfg, nil) + if strings.TrimSpace(bucket) == "" { + return nil, fmt.Errorf("archive bucket is required") + } + sessionPrefix, err := artifacts.ResolveArchiveSessionPrefix(cfg, nil) + if err != nil { + return nil, fmt.Errorf("resolve archive session prefix: %w", err) + } + currentManifestKey, currentRunIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix) + + exists, err := store.Exists(ctx, currentRunIDKey) + if err != nil { + return nil, fmt.Errorf("check remote current run pointer %q: %w", currentRunIDKey, err) + } + if !exists { + return nil, fmt.Errorf("remote current run pointer missing: %q", currentRunIDKey) + } + + runIDPath, err := downloadObjectToTemp(ctx, store, currentRunIDKey, "narratio-restore-current-run-id-*.txt") + if err != nil { + return nil, fmt.Errorf("download remote current run pointer %q: %w", currentRunIDKey, err) + } + defer func() { _ = os.Remove(runIDPath) }() + + runIDData, err := os.ReadFile(runIDPath) + if err != nil { + return nil, fmt.Errorf("read downloaded run pointer %q: %w", currentRunIDKey, err) + } + runID := strings.TrimSpace(string(runIDData)) + if runID == "" { + return nil, fmt.Errorf("remote current run pointer %q is empty", currentRunIDKey) + } + + exists, err = store.Exists(ctx, currentManifestKey) + if err != nil { + return nil, fmt.Errorf("check remote current manifest %q: %w", currentManifestKey, err) + } + if !exists { + return nil, fmt.Errorf("remote current manifest missing: %q", currentManifestKey) + } + + manifestPath, err := downloadObjectToTemp(ctx, store, currentManifestKey, "narratio-restore-current-manifest-*.json") + if err != nil { + return nil, fmt.Errorf("download remote current manifest %q: %w", currentManifestKey, err) + } + defer func() { _ = os.Remove(manifestPath) }() + + manifestStore := &manifest.LocalStore{} + remoteManifest, err := manifestStore.Load(ctx, manifestPath) + if err != nil { + return nil, fmt.Errorf("remote current manifest decode failed: %w", err) + } + + requestedSession := strings.TrimSpace(cfg.Session.SessionID) + requestedCampaign := strings.TrimSpace(cfg.Session.Campaign) + manifestSession := strings.TrimSpace(remoteManifest.SessionID) + manifestCampaign := strings.TrimSpace(remoteManifest.Campaign) + + if manifestSession != requestedSession { + return nil, fmt.Errorf( + "remote current manifest session_id %q does not match requested session_id %q", + manifestSession, + requestedSession, + ) + } + if manifestCampaign == "" { + return nil, fmt.Errorf("remote current manifest campaign is required") + } + if manifestCampaign != requestedCampaign { + return nil, fmt.Errorf( + "remote current manifest campaign %q does not match requested campaign %q", + manifestCampaign, + requestedCampaign, + ) + } + + return &RemoteCurrentState{ + Bucket: bucket, + SessionPrefix: sessionPrefix, + CurrentRunIDKey: currentRunIDKey, + CurrentManifestKey: currentManifestKey, + RunID: runID, + SessionID: manifestSession, + Campaign: manifestCampaign, + Manifest: remoteManifest, + }, nil +} + +func downloadObjectToTemp(ctx context.Context, store storage.ObjectStore, key, pattern string) (string, error) { + tmp, err := os.CreateTemp("", pattern) + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + path := tmp.Name() + if err := tmp.Close(); err != nil { + _ = os.Remove(path) + return "", fmt.Errorf("close temp file: %w", err) + } + + if err := store.Download(ctx, key, path); err != nil { + _ = os.Remove(path) + return "", err + } + return path, nil +} diff --git a/internal/app/restore_discovery_test.go b/internal/app/restore_discovery_test.go new file mode 100644 index 0000000..d2f8185 --- /dev/null +++ b/internal/app/restore_discovery_test.go @@ -0,0 +1,239 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" +) + +func TestDiscoverRemoteCurrentStateSuccess(t *testing.T) { + cfg := restoreDiscoveryConfig() + store := &storage.FakeBackend{} + sessionPrefix, manifestKey, runIDKey := restoreDiscoveryKeys(cfg) + + store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) + store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)}) + + state, err := discoverRemoteCurrentState(context.Background(), cfg, store) + if err != nil { + t.Fatalf("discoverRemoteCurrentState() error = %v", err) + } + if state.RunID != "20260519T010203Z-a1b2c3d4" { + t.Fatalf("run id = %q, want 20260519T010203Z-a1b2c3d4", state.RunID) + } + if state.SessionPrefix != sessionPrefix { + t.Fatalf("session prefix = %q, want %q", state.SessionPrefix, sessionPrefix) + } + if state.CurrentRunIDKey != runIDKey { + t.Fatalf("current run id key = %q, want %q", state.CurrentRunIDKey, runIDKey) + } + if state.CurrentManifestKey != manifestKey { + t.Fatalf("current manifest key = %q, want %q", state.CurrentManifestKey, manifestKey) + } + if state.Manifest == nil { + t.Fatal("manifest is nil") + } +} + +func TestDiscoverRemoteCurrentStateMissingRunPointerFails(t *testing.T) { + cfg := restoreDiscoveryConfig() + store := &storage.FakeBackend{} + + _, err := discoverRemoteCurrentState(context.Background(), cfg, store) + if err == nil || !strings.Contains(err.Error(), "remote current run pointer missing") { + t.Fatalf("error = %v, want missing run pointer failure", err) + } +} + +func TestDiscoverRemoteCurrentStateEmptyRunPointerFails(t *testing.T) { + cfg := restoreDiscoveryConfig() + store := &storage.FakeBackend{} + _, manifestKey, runIDKey := restoreDiscoveryKeys(cfg) + + store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte(" \n\t")}) + store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)}) + + _, err := discoverRemoteCurrentState(context.Background(), cfg, store) + if err == nil || !strings.Contains(err.Error(), "is empty") { + t.Fatalf("error = %v, want empty run pointer failure", err) + } +} + +func TestDiscoverRemoteCurrentStateMissingManifestFails(t *testing.T) { + cfg := restoreDiscoveryConfig() + store := &storage.FakeBackend{} + _, _, runIDKey := restoreDiscoveryKeys(cfg) + + store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) + + _, err := discoverRemoteCurrentState(context.Background(), cfg, store) + if err == nil || !strings.Contains(err.Error(), "remote current manifest missing") { + t.Fatalf("error = %v, want missing manifest failure", err) + } +} + +func TestDiscoverRemoteCurrentStateInvalidManifestFails(t *testing.T) { + cfg := restoreDiscoveryConfig() + store := &storage.FakeBackend{} + _, manifestKey, runIDKey := restoreDiscoveryKeys(cfg) + + store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) + store.SeedObject(storage.FakeObject{Key: manifestKey, Data: []byte("{invalid json")}) + + _, err := discoverRemoteCurrentState(context.Background(), cfg, store) + if err == nil || !strings.Contains(err.Error(), "remote current manifest decode failed") { + t.Fatalf("error = %v, want manifest decode failure", err) + } +} + +func TestDiscoverRemoteCurrentStateSessionMismatchFails(t *testing.T) { + cfg := restoreDiscoveryConfig() + store := &storage.FakeBackend{} + _, manifestKey, runIDKey := restoreDiscoveryKeys(cfg) + + store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) + store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "wrong-session", cfg.Session.Campaign)}) + + _, err := discoverRemoteCurrentState(context.Background(), cfg, store) + if err == nil || !strings.Contains(err.Error(), "does not match requested session_id") { + t.Fatalf("error = %v, want session mismatch failure", err) + } +} + +func TestDiscoverRemoteCurrentStateCampaignMismatchFails(t *testing.T) { + cfg := restoreDiscoveryConfig() + store := &storage.FakeBackend{} + _, manifestKey, runIDKey := restoreDiscoveryKeys(cfg) + + store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) + store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, "wrong-campaign")}) + + _, err := discoverRemoteCurrentState(context.Background(), cfg, store) + if err == nil || !strings.Contains(err.Error(), "does not match requested campaign") { + t.Fatalf("error = %v, want campaign mismatch failure", err) + } +} + +func TestDiscoverRemoteCurrentStateEmptyCampaignFails(t *testing.T) { + cfg := restoreDiscoveryConfig() + store := &storage.FakeBackend{} + _, manifestKey, runIDKey := restoreDiscoveryKeys(cfg) + + store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) + store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, "")}) + + _, err := discoverRemoteCurrentState(context.Background(), cfg, store) + if err == nil || !strings.Contains(err.Error(), "campaign is required") { + t.Fatalf("error = %v, want empty campaign failure", err) + } +} + +func TestDiscoverRemoteCurrentStateUsesCurrentKeysUnderSessionPrefix(t *testing.T) { + cfg := restoreDiscoveryConfig() + sessionPrefix, manifestKey, runIDKey := restoreDiscoveryKeys(cfg) + base := &storage.FakeBackend{} + store := &captureObjectStore{delegate: base} + + base.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) + base.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)}) + + _, err := discoverRemoteCurrentState(context.Background(), cfg, store) + if err != nil { + t.Fatalf("discoverRemoteCurrentState() error = %v", err) + } + + expectedRunKey := fmt.Sprintf("%scurrent/run_id.txt", sessionPrefix) + expectedManifestKey := fmt.Sprintf("%scurrent/manifest.json", sessionPrefix) + if !containsString(store.existsKeys, expectedRunKey) { + t.Fatalf("exists keys = %#v, want run pointer key %q", store.existsKeys, expectedRunKey) + } + if !containsString(store.existsKeys, expectedManifestKey) { + t.Fatalf("exists keys = %#v, want manifest key %q", store.existsKeys, expectedManifestKey) + } + if !containsString(store.downloadKeys, expectedRunKey) { + t.Fatalf("download keys = %#v, want run pointer key %q", store.downloadKeys, expectedRunKey) + } + if !containsString(store.downloadKeys, expectedManifestKey) { + t.Fatalf("download keys = %#v, want manifest key %q", store.downloadKeys, expectedManifestKey) + } +} + +type captureObjectStore struct { + delegate storage.ObjectStore + existsKeys []string + downloadKeys []string +} + +func (s *captureObjectStore) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) { + return s.delegate.List(ctx, prefix) +} + +func (s *captureObjectStore) Download(ctx context.Context, key, localPath string) error { + s.downloadKeys = append(s.downloadKeys, key) + return s.delegate.Download(ctx, key, localPath) +} + +func (s *captureObjectStore) Upload(ctx context.Context, localPath, key string, opts storage.UploadOptions) (storage.ObjectInfo, error) { + return s.delegate.Upload(ctx, localPath, key, opts) +} + +func (s *captureObjectStore) Exists(ctx context.Context, key string) (bool, error) { + s.existsKeys = append(s.existsKeys, key) + return s.delegate.Exists(ctx, key) +} + +func restoreDiscoveryConfig() *config.Config { + return &config.Config{ + Pipeline: &config.PipelineConfig{ + Storage: config.StorageConfig{ + S3: &config.StorageS3Config{ + Bucket: "my-dnd-archive", + RootPrefix: "dnd", + }, + }, + }, + Session: &config.SessionConfig{ + SessionID: "2026-05-03", + Campaign: "sample-campaign", + }, + } +} + +func restoreDiscoveryKeys(cfg *config.Config) (sessionPrefix, manifestKey, runIDKey string) { + sessionPrefix = artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID) + manifestKey, runIDKey = artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix) + return sessionPrefix, manifestKey, runIDKey +} + +func restoreManifestJSON(t *testing.T, sessionID, campaign string) []byte { + t.Helper() + now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano) + payload := map[string]any{ + "session_id": sessionID, + "campaign": campaign, + "created_at": now, + "updated_at": now, + "stages": map[string]any{}, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal manifest payload: %v", err) + } + return append(data, '\n') +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/internal/app/restore_test.go b/internal/app/restore_test.go index b133fb0..721d60f 100644 --- a/internal/app/restore_test.go +++ b/internal/app/restore_test.go @@ -2,12 +2,16 @@ package app import ( "bytes" + "context" + "fmt" "os" "path/filepath" "strings" "testing" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" ) func TestExecuteRestoreHelp(t *testing.T) { @@ -31,6 +35,23 @@ func TestExecuteRestoreHelp(t *testing.T) { } func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { + origStoreFn := newObjectStoreFromConfigFn + origDiscoverFn := discoverRemoteCurrentStateFn + t.Cleanup(func() { + newObjectStoreFromConfigFn = origStoreFn + discoverRemoteCurrentStateFn = origDiscoverFn + }) + newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { + 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 + } + workspaceRoot := t.TempDir() pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) @@ -50,11 +71,14 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { &stderr, ) if code == 0 { - t.Fatal("exit code = 0, want non-zero (phase 2 NYI)") + t.Fatal("exit code = 0, want non-zero (phase 3 NYI boundary)") } errText := stderr.String() - if !strings.Contains(errText, "restore: not yet implemented (phase 3: remote current-state discovery)") { - t.Fatalf("stderr = %q, want NYI error", errText) + if !strings.Contains(errText, "discovered remote current state for sample-campaign/2026-05-03 (run 20260519T010203Z-a1b2c3d4)") { + t.Fatalf("stderr = %q, want discovery summary context", errText) + } + if !strings.Contains(errText, "not yet implemented (phase 3: remote current-state discovery)") { + t.Fatalf("stderr = %q, want phase-3 NYI marker", errText) } if strings.Contains(errText, "unknown command") { t.Fatalf("stderr = %q, restore should be recognized command", errText) @@ -62,7 +86,7 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { manifestPath := artifacts.SessionManifestPathForCampaign(workspaceRoot, "sample-campaign", "2026-05-03") if _, err := os.Stat(manifestPath); !os.IsNotExist(err) { - t.Fatalf("manifest should not be created during phase-2 restore preflight; stat err=%v", err) + t.Fatalf("manifest should not be created during phase-3 restore discovery; stat err=%v", err) } } @@ -82,6 +106,13 @@ func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) { } func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) { + origStoreFn := newObjectStoreFromConfigFn + origDiscoverFn := discoverRemoteCurrentStateFn + t.Cleanup(func() { + newObjectStoreFromConfigFn = origStoreFn + discoverRemoteCurrentStateFn = origDiscoverFn + }) + workspaceRoot := t.TempDir() pipelinePath, sessionPath := writeRestoreConfigWithoutStorage(t, workspaceRoot) @@ -96,6 +127,34 @@ func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) { } } +func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) { + origStoreFn := newObjectStoreFromConfigFn + origDiscoverFn := discoverRemoteCurrentStateFn + t.Cleanup(func() { + newObjectStoreFromConfigFn = origStoreFn + discoverRemoteCurrentStateFn = origDiscoverFn + }) + newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { + return &storage.FakeBackend{}, nil + } + discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) { + return nil, fmt.Errorf("remote current run pointer missing: %q", "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/run_id.txt") + } + + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) + if code == 0 { + t.Fatal("exit code = 0, want non-zero") + } + if !strings.Contains(stderr.String(), "remote current run pointer missing") { + t.Fatalf("stderr = %q, want discovery error context", stderr.String()) + } +} + func writeRestoreConfigWithoutStorage(t *testing.T, workspaceRoot string) (string, string) { t.Helper()