160 lines
5.5 KiB
Go
160 lines
5.5 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"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/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 {
|
|
positionalSessionID, args := pullLeadingSessionID(args)
|
|
fs := flag.NewFlagSet("restore", flag.ContinueOnError)
|
|
fs.SetOutput(out)
|
|
|
|
var pipelinePath string
|
|
var campaignPath string
|
|
var campaignFilePath 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(&campaignPath, "campaign", "", "campaign ID")
|
|
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
|
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
|
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
|
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 session restore <session_id> [--config <path>] [--campaign <id>] [--campaign-file <path>] [--session <path>] [--previous-session-id <value>] [--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 positionalSessionID == "" {
|
|
if err := applyParsedSessionIDArg("restore", fs, &sessionID); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if fs.NArg() != 0 {
|
|
return fmt.Errorf("restore: unexpected positional arguments")
|
|
}
|
|
if err := applyPositionalSessionID("restore", positionalSessionID, &sessionID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if strings.TrimSpace(sessionID) == "" {
|
|
return fmt.Errorf("restore: session_id is required")
|
|
}
|
|
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, 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)
|
|
}
|
|
objectStore, err := newCommandObjectStore(ctx, cfg, logging.NewLogger(os.Stderr, slog.LevelInfo))
|
|
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
|
|
}
|