package app import ( "context" "errors" "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 var discoverRemoteCurrentStateFn = discoverRemoteCurrentState var buildRestorePlanFn = buildRestorePlan var executeRestorePlanFn = executeRestorePlan // 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) fs.SetOutput(out) var pipelinePath string var sessionPath string var sessionID string var previousSessionID string var dryRun bool var force bool var includeAudio bool fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates") fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files") fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state") fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects") fs.Usage = func() { _, _ = fmt.Fprintln(out, "Usage: narratio restore [--config ] [--session ] [--session-id ] [--previous-session-id ] [--dry-run] [--force] [--include-audio]") _, _ = fmt.Fprintln(out) _, _ = fmt.Fprintln(out, "Flags:") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { return nil } return fmt.Errorf("restore: invalid flags: %w", err) } if fs.NArg() != 0 { return fmt.Errorf("restore: unexpected positional arguments") } resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath) if err != nil { return fmt.Errorf("restore: %w", err) } resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) if err != nil { return fmt.Errorf("restore: %w", err) } cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ SessionID: sessionID, PreviousSessionID: previousSessionID, }) if err != nil { return fmt.Errorf("restore: %w", err) } 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 { return fmt.Errorf("restore: %w", err) } current, err := discoverRemoteCurrentStateFn(ctx, cfg, objectStore) if err != nil { return fmt.Errorf("restore: %w", err) } plan, err := buildRestorePlanFn(ctx, cfg, current, objectStore, RestorePlanOptions{ IncludeAudio: includeAudio, Force: force, DryRun: dryRun, }) if err != nil { return fmt.Errorf("restore: %w", err) } report, err := newRestoreReport(current, plan, RestorePlanOptions{ IncludeAudio: includeAudio, Force: force, DryRun: dryRun, }) if err != nil { return fmt.Errorf("restore: %w", err) } if dryRun { if err := writeRestoreDryRunSummary(out, report); err != nil { return fmt.Errorf("restore: write plan output: %w", err) } return nil } artifactStore := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root) if _, err := artifactStore.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID); err != nil { return fmt.Errorf("restore: prepare workdir: %w", err) } lock, err := artifactStore.AcquireSessionLockFor(cfg.Session.Campaign, cfg.Session.SessionID) if err != nil { return fmt.Errorf("restore: acquire session lock: %w", err) } defer func() { _ = artifactStore.ReleaseSessionLock(lock) }() if plan.ConflictCount > 0 && !force { report.setFailed(fmt.Errorf("conflict: %d conflicting path(s)", plan.ConflictCount)) if _, reportErr := persistRestoreReport(artifactStore, cfg, report); reportErr != nil { return fmt.Errorf("restore: report failure: %w", reportErr) } return fmt.Errorf( "restore conflict: %d conflicting path(s); rerun with --force to overwrite (download=%d skip_same=%d conflicts=%d)", plan.ConflictCount, plan.DownloadCount, plan.SkipSameCount, plan.ConflictCount, ) } result, err := executeRestorePlanFn(ctx, cfg, current, plan, report, objectStore) if err != nil { report.setFailed(err) if _, reportErr := persistRestoreReport(artifactStore, cfg, report); reportErr != nil { return fmt.Errorf("restore: execute plan failed (%v) and report write failed (%v)", err, reportErr) } return fmt.Errorf("restore: execute plan: %w", err) } report.Execution.Downloaded = result.DownloadedCount report.setSucceeded() if _, err := persistRestoreReport(artifactStore, cfg, report); err != nil { return fmt.Errorf("restore: write report: %w", err) } if err := writeRestoreSuccessSummary(out, report); err != nil { return fmt.Errorf("restore: write summary: %w", err) } return nil }