Implement restore planning for the restore subcommand
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
var newObjectStoreFromConfigFn = storage.NewObjectStoreFromConfig
|
var newObjectStoreFromConfigFn = storage.NewObjectStoreFromConfig
|
||||||
var discoverRemoteCurrentStateFn = discoverRemoteCurrentState
|
var discoverRemoteCurrentStateFn = discoverRemoteCurrentState
|
||||||
|
var buildRestorePlanFn = buildRestorePlan
|
||||||
|
|
||||||
// Restore validates restore CLI/config inputs and storage preflight for future restore phases.
|
// Restore validates restore CLI/config inputs and storage preflight for future restore phases.
|
||||||
func Restore(ctx context.Context, args []string, out io.Writer) error {
|
func Restore(ctx context.Context, args []string, out io.Writer) error {
|
||||||
@@ -74,13 +75,32 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("restore: %w", err)
|
return fmt.Errorf("restore: %w", err)
|
||||||
}
|
}
|
||||||
|
plan, err := buildRestorePlanFn(ctx, cfg, current, objectStore, RestorePlanOptions{
|
||||||
// Phase 3 boundary: discovery is implemented; planning/execution is deferred.
|
IncludeAudio: includeAudio,
|
||||||
_ = dryRun
|
Force: force,
|
||||||
_ = force
|
DryRun: dryRun,
|
||||||
_ = includeAudio
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("restore: %w", err)
|
||||||
|
}
|
||||||
|
if err := writeRestorePlan(out, current, plan, RestorePlanOptions{
|
||||||
|
IncludeAudio: includeAudio,
|
||||||
|
Force: force,
|
||||||
|
DryRun: dryRun,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("restore: write plan output: %w", err)
|
||||||
|
}
|
||||||
|
if dryRun {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if plan.ConflictCount > 0 && !force {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"restore: plan has %d conflicting path(s); rerun with --force or resolve local conflicts",
|
||||||
|
plan.ConflictCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"restore: discovered remote current state for %s/%s (run %s); not yet implemented (phase 3: remote current-state discovery)",
|
"restore: planned remote restore for %s/%s (run %s); not yet implemented (phase 4: restore execution)",
|
||||||
current.Campaign,
|
current.Campaign,
|
||||||
current.SessionID,
|
current.SessionID,
|
||||||
current.RunID,
|
current.RunID,
|
||||||
|
|||||||
344
internal/app/restore_plan.go
Normal file
344
internal/app/restore_plan.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RestoreActionKind identifies one restore planner action.
|
||||||
|
type RestoreActionKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RestoreActionDownload RestoreActionKind = "download"
|
||||||
|
RestoreActionSkipSame RestoreActionKind = "skip_same"
|
||||||
|
RestoreActionConflict RestoreActionKind = "conflict"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RestoreAction is one deterministic planner action.
|
||||||
|
type RestoreAction struct {
|
||||||
|
Kind RestoreActionKind
|
||||||
|
RemoteKey string
|
||||||
|
LocalRelativePath string
|
||||||
|
LocalPath string
|
||||||
|
Size int64
|
||||||
|
ETag string
|
||||||
|
ExistsLocal bool
|
||||||
|
SameLocal bool
|
||||||
|
Conflict bool
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestorePlan is the deterministic output of restore planning.
|
||||||
|
type RestorePlan struct {
|
||||||
|
Actions []RestoreAction
|
||||||
|
DownloadCount int
|
||||||
|
SkipSameCount int
|
||||||
|
ConflictCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestorePlanOptions control restore planning scope and classification.
|
||||||
|
type RestorePlanOptions struct {
|
||||||
|
IncludeAudio bool
|
||||||
|
Force bool
|
||||||
|
DryRun bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRestorePlan(ctx context.Context, cfg *config.Config, current *RemoteCurrentState, store storage.ObjectStore, opts RestorePlanOptions) (*RestorePlan, error) {
|
||||||
|
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
|
||||||
|
return nil, fmt.Errorf("resolved config with pipeline/session is required")
|
||||||
|
}
|
||||||
|
if current == nil {
|
||||||
|
return nil, fmt.Errorf("remote current state is required")
|
||||||
|
}
|
||||||
|
if store == nil {
|
||||||
|
return nil, fmt.Errorf("remote object store is required")
|
||||||
|
}
|
||||||
|
prefix := normalizeRemoteKey(current.SessionPrefix)
|
||||||
|
if strings.TrimSpace(prefix) == "" {
|
||||||
|
return nil, fmt.Errorf("remote session prefix is required")
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(prefix, "/") {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionPaths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
objects, err := store.List(ctx, prefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list remote session objects under %q: %w", prefix, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := make(map[string]storage.ObjectInfo, len(objects)+1)
|
||||||
|
for _, obj := range objects {
|
||||||
|
key := normalizeRemoteKey(obj.Key)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
obj.Key = key
|
||||||
|
candidates[key] = obj
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(current.CurrentManifestKey) != "" {
|
||||||
|
key := normalizeRemoteKey(current.CurrentManifestKey)
|
||||||
|
if _, ok := candidates[key]; !ok {
|
||||||
|
candidates[key] = storage.ObjectInfo{Key: key}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actions := make([]RestoreAction, 0, len(candidates))
|
||||||
|
for key, obj := range candidates {
|
||||||
|
rel, include, err := restoreLocalRelativePathForKey(prefix, normalizeRemoteKey(current.CurrentManifestKey), key, opts.IncludeAudio)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("map remote key %q: %w", key, err)
|
||||||
|
}
|
||||||
|
if !include {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
localPath, err := joinWithinSessionRoot(sessionPaths.Root, rel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("map remote key %q: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
action, err := classifyRestoreAction(ctx, store, obj, rel, localPath, opts.Force)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("classify remote key %q: %w", key, err)
|
||||||
|
}
|
||||||
|
actions = append(actions, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(actions, func(i, j int) bool {
|
||||||
|
if actions[i].LocalRelativePath == actions[j].LocalRelativePath {
|
||||||
|
return actions[i].RemoteKey < actions[j].RemoteKey
|
||||||
|
}
|
||||||
|
return actions[i].LocalRelativePath < actions[j].LocalRelativePath
|
||||||
|
})
|
||||||
|
|
||||||
|
plan := &RestorePlan{Actions: actions}
|
||||||
|
for _, action := range actions {
|
||||||
|
switch action.Kind {
|
||||||
|
case RestoreActionDownload:
|
||||||
|
plan.DownloadCount++
|
||||||
|
case RestoreActionSkipSame:
|
||||||
|
plan.SkipSameCount++
|
||||||
|
case RestoreActionConflict:
|
||||||
|
plan.ConflictCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = opts.DryRun
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRemoteKey(v string) string {
|
||||||
|
return strings.Trim(strings.ReplaceAll(strings.TrimSpace(v), "\\", "/"), "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreLocalRelativePathForKey(sessionPrefix, currentManifestKey, key string, includeAudio bool) (string, bool, error) {
|
||||||
|
if key == "" {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if key == currentManifestKey {
|
||||||
|
return config.PathManifestFile, true, nil
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(key, sessionPrefix) {
|
||||||
|
return "", false, fmt.Errorf("key is outside resolved session prefix %q", sessionPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
rel := strings.TrimPrefix(key, sessionPrefix)
|
||||||
|
rel = strings.TrimSpace(rel)
|
||||||
|
if rel == "" {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanRel := path.Clean(rel)
|
||||||
|
if cleanRel == "." || cleanRel == "" {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") || strings.HasPrefix(cleanRel, "/") {
|
||||||
|
return "", false, fmt.Errorf("key relative path %q escapes session scope", rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cleanRel == config.PathManifestFile {
|
||||||
|
return config.PathManifestFile, true, nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(cleanRel, config.S3CurrentSegment+"/") {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(cleanRel, config.S3RunsSegment+"/") {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
excludedRoots := []string{
|
||||||
|
config.PathLogsDirSegment,
|
||||||
|
config.PathReportsDirSegment,
|
||||||
|
config.PathConfigDirSegment,
|
||||||
|
config.PathInputsDirSegment,
|
||||||
|
}
|
||||||
|
for _, root := range excludedRoots {
|
||||||
|
if cleanRel == root || strings.HasPrefix(cleanRel, root+"/") {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cleanRel == config.PathTranscriptsSegment || strings.HasPrefix(cleanRel, config.PathTranscriptsSegment+"/") {
|
||||||
|
return cleanRel, true, nil
|
||||||
|
}
|
||||||
|
if cleanRel == config.PathArtifactsDirSegment || strings.HasPrefix(cleanRel, config.PathArtifactsDirSegment+"/") {
|
||||||
|
return cleanRel, true, nil
|
||||||
|
}
|
||||||
|
if includeAudio && (cleanRel == config.PathAudioDirSegment || strings.HasPrefix(cleanRel, config.PathAudioDirSegment+"/")) {
|
||||||
|
return cleanRel, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinWithinSessionRoot(sessionRoot, relative string) (string, error) {
|
||||||
|
if strings.TrimSpace(sessionRoot) == "" {
|
||||||
|
return "", fmt.Errorf("session root is required")
|
||||||
|
}
|
||||||
|
cleanRel := path.Clean(strings.TrimSpace(relative))
|
||||||
|
if cleanRel == "." || cleanRel == "" {
|
||||||
|
return "", fmt.Errorf("relative path is required")
|
||||||
|
}
|
||||||
|
if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") || strings.HasPrefix(cleanRel, "/") {
|
||||||
|
return "", fmt.Errorf("relative path escapes session root")
|
||||||
|
}
|
||||||
|
abs := filepath.Clean(filepath.Join(sessionRoot, filepath.FromSlash(cleanRel)))
|
||||||
|
root := filepath.Clean(sessionRoot)
|
||||||
|
if abs != root && !strings.HasPrefix(abs, root+string(filepath.Separator)) {
|
||||||
|
return "", fmt.Errorf("resolved local path escapes session root")
|
||||||
|
}
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyRestoreAction(
|
||||||
|
ctx context.Context,
|
||||||
|
store storage.ObjectStore,
|
||||||
|
object storage.ObjectInfo,
|
||||||
|
localRelPath string,
|
||||||
|
localPath string,
|
||||||
|
force bool,
|
||||||
|
) (RestoreAction, error) {
|
||||||
|
action := RestoreAction{
|
||||||
|
RemoteKey: normalizeRemoteKey(object.Key),
|
||||||
|
LocalRelativePath: localRelPath,
|
||||||
|
LocalPath: localPath,
|
||||||
|
Size: object.Size,
|
||||||
|
ETag: object.ETag,
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(localPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
action.Kind = RestoreActionDownload
|
||||||
|
action.Reason = "local file missing"
|
||||||
|
return action, nil
|
||||||
|
}
|
||||||
|
return RestoreAction{}, fmt.Errorf("stat local file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
action.ExistsLocal = true
|
||||||
|
if info.IsDir() {
|
||||||
|
action.Kind = RestoreActionConflict
|
||||||
|
action.Conflict = true
|
||||||
|
action.Reason = "local path is a directory"
|
||||||
|
return action, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if object.Size > 0 && info.Size() != object.Size {
|
||||||
|
if force {
|
||||||
|
action.Kind = RestoreActionDownload
|
||||||
|
action.Reason = "local file differs (size mismatch); overwrite with --force"
|
||||||
|
return action, nil
|
||||||
|
}
|
||||||
|
action.Kind = RestoreActionConflict
|
||||||
|
action.Conflict = true
|
||||||
|
action.Reason = "local file differs (size mismatch)"
|
||||||
|
return action, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
localDigest, err := artifacts.SHA256File(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return RestoreAction{}, fmt.Errorf("checksum local file: %w", err)
|
||||||
|
}
|
||||||
|
remotePath, err := downloadObjectToTemp(ctx, store, action.RemoteKey, "narratio-restore-plan-remote-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return RestoreAction{}, fmt.Errorf("download remote object: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Remove(remotePath) }()
|
||||||
|
|
||||||
|
remoteDigest, err := artifacts.SHA256File(remotePath)
|
||||||
|
if err != nil {
|
||||||
|
return RestoreAction{}, fmt.Errorf("checksum remote object: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if remoteDigest == localDigest {
|
||||||
|
action.Kind = RestoreActionSkipSame
|
||||||
|
action.SameLocal = true
|
||||||
|
action.Reason = "local file matches remote content"
|
||||||
|
return action, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if force {
|
||||||
|
action.Kind = RestoreActionDownload
|
||||||
|
action.Reason = "local file differs; overwrite with --force"
|
||||||
|
return action, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
action.Kind = RestoreActionConflict
|
||||||
|
action.Conflict = true
|
||||||
|
action.Reason = "local file differs"
|
||||||
|
return action, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRestorePlan(out io.Writer, current *RemoteCurrentState, plan *RestorePlan, opts RestorePlanOptions) error {
|
||||||
|
if out == nil {
|
||||||
|
return fmt.Errorf("output writer is required")
|
||||||
|
}
|
||||||
|
if current == nil {
|
||||||
|
return fmt.Errorf("remote current state is required")
|
||||||
|
}
|
||||||
|
if plan == nil {
|
||||||
|
return fmt.Errorf("restore plan is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprintf(
|
||||||
|
out,
|
||||||
|
"restore plan: session %s/%s run=%s actions=%d download=%d skip_same=%d conflict=%d dry_run=%t force=%t include_audio=%t\n",
|
||||||
|
current.Campaign,
|
||||||
|
current.SessionID,
|
||||||
|
current.RunID,
|
||||||
|
len(plan.Actions),
|
||||||
|
plan.DownloadCount,
|
||||||
|
plan.SkipSameCount,
|
||||||
|
plan.ConflictCount,
|
||||||
|
opts.DryRun,
|
||||||
|
opts.Force,
|
||||||
|
opts.IncludeAudio,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, action := range plan.Actions {
|
||||||
|
if _, err := fmt.Fprintf(out, "%s %s <- %s", action.Kind, action.LocalRelativePath, action.RemoteKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(action.Reason) != "" {
|
||||||
|
if _, err := fmt.Fprintf(out, " (%s)", action.Reason); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
199
internal/app/restore_plan_test.go
Normal file
199
internal/app/restore_plan_test.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"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 TestRestorePlanDefaultScope(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
current := restorePlanCurrentState(t, cfg)
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
|
||||||
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
||||||
|
seedRestoreObject(store, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`))
|
||||||
|
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("# recap\n"))
|
||||||
|
seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
|
||||||
|
seedRestoreObject(store, current.SessionPrefix+"runs/20260519T010203Z-a1b2/manifest.json", []byte("{}"))
|
||||||
|
seedRestoreObject(store, current.SessionPrefix+"logs/archive.log", []byte("log"))
|
||||||
|
|
||||||
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := actionRelPaths(plan.Actions)
|
||||||
|
want := []string{"artifacts/session_recap.md", "manifest.json", "transcripts/full.json"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
if plan.DownloadCount != 3 || plan.SkipSameCount != 0 || plan.ConflictCount != 0 {
|
||||||
|
t.Fatalf("counts = download=%d skip_same=%d conflict=%d, want 3/0/0", plan.DownloadCount, plan.SkipSameCount, plan.ConflictCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestorePlanIncludeAudio(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
current := restorePlanCurrentState(t, cfg)
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
|
||||||
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
||||||
|
seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
|
||||||
|
|
||||||
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
got := actionRelPaths(plan.Actions)
|
||||||
|
want := []string{"audio/alice.flac", "manifest.json"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestorePlanClassifiesSameAndConflict(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
current := restorePlanCurrentState(t, cfg)
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
|
||||||
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
||||||
|
seedRestoreObject(store, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`))
|
||||||
|
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
|
||||||
|
|
||||||
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1]}`)
|
||||||
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "different\n")
|
||||||
|
|
||||||
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if plan.SkipSameCount != 1 {
|
||||||
|
t.Fatalf("SkipSameCount = %d, want 1", plan.SkipSameCount)
|
||||||
|
}
|
||||||
|
if plan.ConflictCount != 1 {
|
||||||
|
t.Fatalf("ConflictCount = %d, want 1", plan.ConflictCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
actionByRel := map[string]RestoreAction{}
|
||||||
|
for _, action := range plan.Actions {
|
||||||
|
actionByRel[action.LocalRelativePath] = action
|
||||||
|
}
|
||||||
|
if actionByRel["transcripts/full.json"].Kind != RestoreActionSkipSame {
|
||||||
|
t.Fatalf("transcripts/full.json kind = %q, want %q", actionByRel["transcripts/full.json"].Kind, RestoreActionSkipSame)
|
||||||
|
}
|
||||||
|
if actionByRel["artifacts/session_recap.md"].Kind != RestoreActionConflict {
|
||||||
|
t.Fatalf("artifacts/session_recap.md kind = %q, want %q", actionByRel["artifacts/session_recap.md"].Kind, RestoreActionConflict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestorePlanForceTurnsConflictsIntoDownloads(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
current := restorePlanCurrentState(t, cfg)
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
|
||||||
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
||||||
|
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
|
||||||
|
|
||||||
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "different\n")
|
||||||
|
|
||||||
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
actionByRel := map[string]RestoreAction{}
|
||||||
|
for _, action := range plan.Actions {
|
||||||
|
actionByRel[action.LocalRelativePath] = action
|
||||||
|
}
|
||||||
|
recap := actionByRel["artifacts/session_recap.md"]
|
||||||
|
if recap.Kind != RestoreActionDownload {
|
||||||
|
t.Fatalf("artifacts/session_recap.md kind = %q, want %q", recap.Kind, RestoreActionDownload)
|
||||||
|
}
|
||||||
|
if plan.ConflictCount != 0 {
|
||||||
|
t.Fatalf("ConflictCount = %d, want 0", plan.ConflictCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestorePlanTraversalUnsafeKeyFails(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
current := restorePlanCurrentState(t, cfg)
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
|
||||||
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
||||||
|
seedRestoreObject(store, current.SessionPrefix+"artifacts/../../escape.txt", []byte("bad"))
|
||||||
|
|
||||||
|
_, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "escapes session scope") {
|
||||||
|
t.Fatalf("error = %v, want traversal safety failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedRestoreObject(store *storage.FakeBackend, key string, data []byte) {
|
||||||
|
store.SeedObject(storage.FakeObject{Key: key, Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func actionRelPaths(actions []RestoreAction) []string {
|
||||||
|
out := make([]string, 0, len(actions))
|
||||||
|
for _, action := range actions {
|
||||||
|
out = append(out, action.LocalRelativePath)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func restorePlanConfig(t *testing.T) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
return &config.Config{
|
||||||
|
Pipeline: &config.PipelineConfig{
|
||||||
|
Workspace: config.WorkspaceConfig{Root: workspaceRoot},
|
||||||
|
},
|
||||||
|
Session: &config.SessionConfig{
|
||||||
|
SessionID: "2026-05-03",
|
||||||
|
Campaign: "sample-campaign",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState {
|
||||||
|
t.Helper()
|
||||||
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix)
|
||||||
|
return &RemoteCurrentState{
|
||||||
|
Bucket: "test-bucket",
|
||||||
|
SessionPrefix: sessionPrefix,
|
||||||
|
CurrentManifestKey: manifestKey,
|
||||||
|
CurrentRunIDKey: runIDKey,
|
||||||
|
RunID: "20260519T010203Z-a1b2c3d4",
|
||||||
|
SessionID: cfg.Session.SessionID,
|
||||||
|
Campaign: cfg.Session.Campaign,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteRestorePlan(t *testing.T) {
|
||||||
|
current := &RemoteCurrentState{Campaign: "sample-campaign", SessionID: "2026-05-03", RunID: "r-1"}
|
||||||
|
plan := &RestorePlan{Actions: []RestoreAction{{Kind: RestoreActionDownload, LocalRelativePath: "manifest.json", RemoteKey: "k", Reason: "local file missing"}}, DownloadCount: 1}
|
||||||
|
var out strings.Builder
|
||||||
|
if err := writeRestorePlan(&out, current, plan, RestorePlanOptions{DryRun: true}); err != nil {
|
||||||
|
t.Fatalf("writeRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
text := out.String()
|
||||||
|
if !strings.Contains(text, "restore plan: session sample-campaign/2026-05-03 run=r-1") {
|
||||||
|
t.Fatalf("output = %q, want plan summary", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "download manifest.json <- k") {
|
||||||
|
t.Fatalf("output = %q, want action line", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,9 +37,11 @@ func TestExecuteRestoreHelp(t *testing.T) {
|
|||||||
func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
|
func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
|
||||||
origStoreFn := newObjectStoreFromConfigFn
|
origStoreFn := newObjectStoreFromConfigFn
|
||||||
origDiscoverFn := discoverRemoteCurrentStateFn
|
origDiscoverFn := discoverRemoteCurrentStateFn
|
||||||
|
origPlanFn := buildRestorePlanFn
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
newObjectStoreFromConfigFn = origStoreFn
|
newObjectStoreFromConfigFn = origStoreFn
|
||||||
discoverRemoteCurrentStateFn = origDiscoverFn
|
discoverRemoteCurrentStateFn = origDiscoverFn
|
||||||
|
buildRestorePlanFn = origPlanFn
|
||||||
})
|
})
|
||||||
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
||||||
return &storage.FakeBackend{}, nil
|
return &storage.FakeBackend{}, nil
|
||||||
@@ -51,6 +53,19 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
|
|||||||
RunID: "20260519T010203Z-a1b2c3d4",
|
RunID: "20260519T010203Z-a1b2c3d4",
|
||||||
}, nil
|
}, 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
|
||||||
|
}
|
||||||
|
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
@@ -70,23 +85,23 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
|
|||||||
&stdout,
|
&stdout,
|
||||||
&stderr,
|
&stderr,
|
||||||
)
|
)
|
||||||
if code == 0 {
|
if code != 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero (phase 3 NYI boundary)")
|
t.Fatalf("exit code = %d, want 0 for --dry-run restore planning; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
errText := stderr.String()
|
if stderr.Len() != 0 {
|
||||||
if !strings.Contains(errText, "discovered remote current state for sample-campaign/2026-05-03 (run 20260519T010203Z-a1b2c3d4)") {
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
t.Fatalf("stderr = %q, want discovery summary context", errText)
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(errText, "not yet implemented (phase 3: remote current-state discovery)") {
|
outText := stdout.String()
|
||||||
t.Fatalf("stderr = %q, want phase-3 NYI marker", errText)
|
if !strings.Contains(outText, "restore plan: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4") {
|
||||||
|
t.Fatalf("stdout = %q, want restore plan summary", outText)
|
||||||
}
|
}
|
||||||
if strings.Contains(errText, "unknown command") {
|
if !strings.Contains(outText, "download manifest.json <- dnd/campaigns/sample-campaign/sessions/2026-05-03/current/manifest.json") {
|
||||||
t.Fatalf("stderr = %q, restore should be recognized command", errText)
|
t.Fatalf("stdout = %q, want action output", outText)
|
||||||
}
|
}
|
||||||
|
|
||||||
manifestPath := artifacts.SessionManifestPathForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
|
manifestPath := artifacts.SessionManifestPathForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
|
||||||
if _, err := os.Stat(manifestPath); !os.IsNotExist(err) {
|
if _, err := os.Stat(manifestPath); !os.IsNotExist(err) {
|
||||||
t.Fatalf("manifest should not be created during phase-3 restore discovery; stat err=%v", err)
|
t.Fatalf("manifest should not be created during phase-4 restore planning; stat err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +170,97 @@ func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) {
|
||||||
|
origStoreFn := newObjectStoreFromConfigFn
|
||||||
|
origDiscoverFn := discoverRemoteCurrentStateFn
|
||||||
|
origPlanFn := buildRestorePlanFn
|
||||||
|
t.Cleanup(func() {
|
||||||
|
newObjectStoreFromConfigFn = origStoreFn
|
||||||
|
discoverRemoteCurrentStateFn = origDiscoverFn
|
||||||
|
buildRestorePlanFn = origPlanFn
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
|
||||||
|
return &RestorePlan{
|
||||||
|
Actions: []RestoreAction{
|
||||||
|
{Kind: RestoreActionConflict, LocalRelativePath: "transcripts/full.json", RemoteKey: "k", Reason: "local file differs"},
|
||||||
|
},
|
||||||
|
ConflictCount: 1,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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(stdout.String(), "restore plan: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4") {
|
||||||
|
t.Fatalf("stdout = %q, want plan output", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "plan has 1 conflicting path(s)") {
|
||||||
|
t.Fatalf("stderr = %q, want conflict failure", stderr.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(stderr.String(), "phase 4: restore execution") {
|
||||||
|
t.Fatalf("stderr = %q, should fail before phase-4 NYI boundary", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteRestoreNonDryRunForceStillStopsAtNYI(t *testing.T) {
|
||||||
|
origStoreFn := newObjectStoreFromConfigFn
|
||||||
|
origDiscoverFn := discoverRemoteCurrentStateFn
|
||||||
|
origPlanFn := buildRestorePlanFn
|
||||||
|
t.Cleanup(func() {
|
||||||
|
newObjectStoreFromConfigFn = origStoreFn
|
||||||
|
discoverRemoteCurrentStateFn = origDiscoverFn
|
||||||
|
buildRestorePlanFn = origPlanFn
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
|
||||||
|
return &RestorePlan{
|
||||||
|
Actions: []RestoreAction{
|
||||||
|
{Kind: RestoreActionDownload, LocalRelativePath: "transcripts/full.json", RemoteKey: "k", Reason: "local file differs; overwrite with --force"},
|
||||||
|
},
|
||||||
|
DownloadCount: 1,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("exit code = 0, want non-zero at phase-4 execution boundary")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "restore plan: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4") {
|
||||||
|
t.Fatalf("stdout = %q, want plan output", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "not yet implemented (phase 4: restore execution)") {
|
||||||
|
t.Fatalf("stderr = %q, want phase-4 NYI marker", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeRestoreConfigWithoutStorage(t *testing.T, workspaceRoot string) (string, string) {
|
func writeRestoreConfigWithoutStorage(t *testing.T, workspaceRoot string) (string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user