Serialize restore recovery and rebase manifest paths
This commit is contained in:
187
internal/app/restore_recovery_test.go
Normal file
187
internal/app/restore_recovery_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func TestRestoreHoldsTransitionLockBeforePlanning(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
fake := &storage.FakeBackend{}
|
||||
cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
||||
|
||||
origStoreFn := newObjectStoreFromConfigFn
|
||||
origDiscoverFn := discoverRemoteCurrentStateFn
|
||||
origPlanFn := buildRestorePlanFn
|
||||
t.Cleanup(func() {
|
||||
newObjectStoreFromConfigFn = origStoreFn
|
||||
discoverRemoteCurrentStateFn = origDiscoverFn
|
||||
buildRestorePlanFn = origPlanFn
|
||||
})
|
||||
|
||||
current, err := discoverRemoteCurrentState(context.Background(), cfg, fake)
|
||||
if err != nil {
|
||||
t.Fatalf("discoverRemoteCurrentState() error = %v", err)
|
||||
}
|
||||
planning := make(chan struct{})
|
||||
releasePlanning := make(chan struct{})
|
||||
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { return fake, nil }
|
||||
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
|
||||
return current, nil
|
||||
}
|
||||
buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
|
||||
close(planning)
|
||||
<-releasePlanning
|
||||
return &RestorePlan{}, nil
|
||||
}
|
||||
|
||||
restoreDone := make(chan error, 1)
|
||||
go func() {
|
||||
restoreDone <- Restore(context.Background(), []string{
|
||||
cfg.Session.SessionID,
|
||||
"--config", pipelinePath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
}, &bytes.Buffer{})
|
||||
}()
|
||||
<-planning
|
||||
|
||||
runnerCtx, cancelRunner := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancelRunner()
|
||||
if _, err := executeStages(runnerCtx, cfg, nil, RunOptions{}); err == nil || !strings.Contains(err.Error(), "acquire session lock") {
|
||||
t.Fatalf("runner while restore plans error = %v, want session-lock failure", err)
|
||||
}
|
||||
|
||||
close(releasePlanning)
|
||||
if err := <-restoreDone; err != nil {
|
||||
t.Fatalf("Restore() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesRefusesIncompleteRestore(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
if err := writeRestoreMarker(paths); err != nil {
|
||||
t.Fatalf("writeRestoreMarker() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := executeStages(context.Background(), cfg, nil, RunOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "restore is incomplete") {
|
||||
t.Fatalf("executeStages() error = %v, want incomplete restore failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForcedRestoreRetryClearsIncompleteMarker(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
fake := &storage.FakeBackend{}
|
||||
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
||||
artifactKey := sessionPrefix + "artifacts/session_recap.md"
|
||||
transcriptKey := sessionPrefix + "transcripts/full.json"
|
||||
seedRestoreObject(fake, artifactKey, []byte("remote recap\n"))
|
||||
seedRestoreObject(fake, transcriptKey, []byte("remote transcript\n"))
|
||||
|
||||
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "local recap\n")
|
||||
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local transcript\n")
|
||||
fake.DownloadHook = func(call storage.FakeDownloadCall) error {
|
||||
if call.Key == transcriptKey {
|
||||
return context.Canceled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
restoreWithStoreAndRealPhases(t, fake)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
args := []string{"session", "restore", cfg.Session.SessionID, "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}
|
||||
if code := Execute(args, &stdout, &stderr); code == 0 {
|
||||
t.Fatal("forced restore exit code = 0, want partial-install failure")
|
||||
}
|
||||
markerPath := artifacts.SessionRestoreMarkerPathForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
if _, err := os.Stat(markerPath); err != nil {
|
||||
t.Fatalf("incomplete restore marker missing after partial forced restore: %v", err)
|
||||
}
|
||||
mustReadEquals(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "remote recap\n")
|
||||
|
||||
fake.DownloadHook = nil
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := Execute(args, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("restore retry exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("incomplete restore marker should be cleared after retry; stat err=%v", err)
|
||||
}
|
||||
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "remote transcript\n")
|
||||
}
|
||||
|
||||
func TestRebaseRestoredManifestPathsRejectsUnsafeReferencesAndRebasesPortablePaths(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
workDir string
|
||||
output string
|
||||
wantPath string
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "unix absolute path within producer root",
|
||||
workDir: "/producer/work/sample-campaign/2026-05-03/runs/20260519T010203Z-a1b2c3d4",
|
||||
output: "/producer/work/sample-campaign/2026-05-03/artifacts/recap.md",
|
||||
wantPath: "artifacts/recap.md",
|
||||
},
|
||||
{
|
||||
name: "windows absolute path within producer root",
|
||||
workDir: `C:\producer\work\sample-campaign\2026-05-03\runs\20260519T010203Z-a1b2c3d4`,
|
||||
output: `C:\producer\work\sample-campaign\2026-05-03\artifacts\recap.md`,
|
||||
wantPath: "artifacts/recap.md",
|
||||
},
|
||||
{
|
||||
name: "absolute path outside producer root",
|
||||
workDir: "/producer/work/sample-campaign/2026-05-03/runs/20260519T010203Z-a1b2c3d4",
|
||||
output: "/another-machine/recap.md",
|
||||
wantError: "outside the producer session root",
|
||||
},
|
||||
{
|
||||
name: "relative traversal",
|
||||
workDir: "/producer/work/sample-campaign/2026-05-03/runs/20260519T010203Z-a1b2c3d4",
|
||||
output: "../recap.md",
|
||||
wantError: "unsafe relative path",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
m := manifest.New(cfg.Session.SessionID, nowUTC())
|
||||
m.Campaign = cfg.Session.Campaign
|
||||
m.RunID = "20260519T010203Z-a1b2c3d4"
|
||||
m.LocalWorkDir = test.workDir
|
||||
m.Stages["analyze"] = &manifest.StageRecord{Outputs: []manifest.ArtifactRecord{{Kind: "recap", LocalPath: test.output}}}
|
||||
destination := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
|
||||
err := rebaseRestoredManifestPaths(cfg, m, destination)
|
||||
if test.wantError != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
||||
t.Fatalf("rebaseRestoredManifestPaths() error = %v, want %q", err, test.wantError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("rebaseRestoredManifestPaths() error = %v", err)
|
||||
}
|
||||
if got, want := m.Stages["analyze"].Outputs[0].LocalPath, filepath.Join(destination, test.wantPath); got != want {
|
||||
t.Fatalf("rebased local path = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user