test(daemon): cover incremental restore and import
- commit
- 106b3dec59745bd84255b1cacbbd8cdc55acf6dc
- author
- brice · · 2026-07-21 23:36 UTC
- parent
- 12d44e0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +518 -36
M internal/usecase/daemon/daemon.go +26 -21
| @@ -126,8 +126,12 @@ type Daemon struct { | |||
| 126 | 126 | // narrower checkpoint-only test seam. | |
| 127 | 127 | snapshotDeletion ports.SnapshotDeletionTombstone | |
| 128 | 128 | snapsEnabled bool | |
| 129 | - | noticeStore ports.NoticeStore | |
| 130 | - | snapshotMarshal func(snapcodec.Session) ([]byte, error) | |
| 129 | + | // legacyImportPending holds verified legacy blobs whose source deletion | |
| 130 | + | // failed, so a later import retries only deletion rather than publication. | |
| 131 | + | legacyImportMu sync.Mutex | |
| 132 | + | legacyImportPending map[string][]byte | |
| 133 | + | noticeStore ports.NoticeStore | |
| 134 | + | snapshotMarshal func(snapcodec.Session) ([]byte, error) | |
| 131 | 135 | // snapshotChunkCache contains only encoded immutable sealed chunks. It is | |
| 132 | 136 | // independent of pane state and is bounded to prevent checkpoint history | |
| 133 | 137 | // from becoming unbounded daemon memory. | |
| @@ -374,25 +378,26 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 374 | 378 | shell = "/bin/sh" | |
| 375 | 379 | } | |
| 376 | 380 | d := &Daemon{ | |
| 377 | - | sessions: make(map[domain.SessionID]*session), | |
| 378 | - | stopped: make(map[string]stoppedSession), | |
| 379 | - | parked: make(map[uint64]*parkedAttachment), | |
| 380 | - | ptys: ptys, | |
| 381 | - | clock: clock, | |
| 382 | - | log: log, | |
| 383 | - | baseEnv: os.Environ(), | |
| 384 | - | shell: shell, | |
| 385 | - | persist: persist.New(nil), | |
| 386 | - | dirOrHome: dirOrHome, | |
| 387 | - | done: make(chan struct{}), | |
| 388 | - | restoreDone: make(chan struct{}), | |
| 389 | - | animWake: make(chan struct{}, 1), | |
| 390 | - | snapshotMarshal: snapcodec.Marshal, | |
| 391 | - | snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit), | |
| 392 | - | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | |
| 393 | - | snapshotWake: make(chan struct{}, 1), | |
| 394 | - | notices: newNoticeCenter(), | |
| 395 | - | resumeParkGrace: defaultResumeParkGrace, | |
| 381 | + | sessions: make(map[domain.SessionID]*session), | |
| 382 | + | stopped: make(map[string]stoppedSession), | |
| 383 | + | parked: make(map[uint64]*parkedAttachment), | |
| 384 | + | ptys: ptys, | |
| 385 | + | clock: clock, | |
| 386 | + | log: log, | |
| 387 | + | baseEnv: os.Environ(), | |
| 388 | + | shell: shell, | |
| 389 | + | persist: persist.New(nil), | |
| 390 | + | dirOrHome: dirOrHome, | |
| 391 | + | done: make(chan struct{}), | |
| 392 | + | restoreDone: make(chan struct{}), | |
| 393 | + | animWake: make(chan struct{}, 1), | |
| 394 | + | snapshotMarshal: snapcodec.Marshal, | |
| 395 | + | snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit), | |
| 396 | + | legacyImportPending: make(map[string][]byte), | |
| 397 | + | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | |
| 398 | + | snapshotWake: make(chan struct{}, 1), | |
| 399 | + | notices: newNoticeCenter(), | |
| 400 | + | resumeParkGrace: defaultResumeParkGrace, | |
| 396 | 401 | barScripts: &barScriptState{ | |
| 397 | 402 | cfg: barConfigFromDomain(domain.Defaults().Bar), | |
| 398 | 403 | outputs: make(map[domain.SessionID]barScriptOutputs), | |
| @@ -126,8 +126,12 @@ type Daemon struct { | |||||
| 126 | // narrower checkpoint-only test seam. | 126 | // narrower checkpoint-only test seam. | ||
| 127 | snapshotDeletion ports.SnapshotDeletionTombstone | 127 | snapshotDeletion ports.SnapshotDeletionTombstone | ||
| 128 | snapsEnabled bool | 128 | snapsEnabled bool | ||
| 129 | - | noticeStore ports.NoticeStore | 129 | + | // legacyImportPending holds verified legacy blobs whose source deletion |
| 130 | - | snapshotMarshal func(snapcodec.Session) ([]byte, error) | 130 | + | // failed, so a later import retries only deletion rather than publication. |
| 131 | + | legacyImportMu sync.Mutex | |||
| 132 | + | legacyImportPending map[string][]byte | |||
| 133 | + | noticeStore ports.NoticeStore | |||
| 134 | + | snapshotMarshal func(snapcodec.Session) ([]byte, error) | |||
| 131 | // snapshotChunkCache contains only encoded immutable sealed chunks. It is | 135 | // snapshotChunkCache contains only encoded immutable sealed chunks. It is | ||
| 132 | // independent of pane state and is bounded to prevent checkpoint history | 136 | // independent of pane state and is bounded to prevent checkpoint history | ||
| 133 | // from becoming unbounded daemon memory. | 137 | // from becoming unbounded daemon memory. | ||
| @@ -374,25 +378,26 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 374 | shell = "/bin/sh" | 378 | shell = "/bin/sh" | ||
| 375 | } | 379 | } | ||
| 376 | d := &Daemon{ | 380 | d := &Daemon{ | ||
| 377 | - | sessions: make(map[domain.SessionID]*session), | 381 | + | sessions: make(map[domain.SessionID]*session), |
| 378 | - | stopped: make(map[string]stoppedSession), | 382 | + | stopped: make(map[string]stoppedSession), |
| 379 | - | parked: make(map[uint64]*parkedAttachment), | 383 | + | parked: make(map[uint64]*parkedAttachment), |
| 380 | - | ptys: ptys, | 384 | + | ptys: ptys, |
| 381 | - | clock: clock, | 385 | + | clock: clock, |
| 382 | - | log: log, | 386 | + | log: log, |
| 383 | - | baseEnv: os.Environ(), | 387 | + | baseEnv: os.Environ(), |
| 384 | - | shell: shell, | 388 | + | shell: shell, |
| 385 | - | persist: persist.New(nil), | 389 | + | persist: persist.New(nil), |
| 386 | - | dirOrHome: dirOrHome, | 390 | + | dirOrHome: dirOrHome, |
| 387 | - | done: make(chan struct{}), | 391 | + | done: make(chan struct{}), |
| 388 | - | restoreDone: make(chan struct{}), | 392 | + | restoreDone: make(chan struct{}), |
| 389 | - | animWake: make(chan struct{}, 1), | 393 | + | animWake: make(chan struct{}, 1), |
| 390 | - | snapshotMarshal: snapcodec.Marshal, | 394 | + | snapshotMarshal: snapcodec.Marshal, |
| 391 | - | snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit), | 395 | + | snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit), |
| 392 | - | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | 396 | + | legacyImportPending: make(map[string][]byte), |
| 393 | - | snapshotWake: make(chan struct{}, 1), | 397 | + | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), |
| 394 | - | notices: newNoticeCenter(), | 398 | + | snapshotWake: make(chan struct{}, 1), |
| 395 | - | resumeParkGrace: defaultResumeParkGrace, | 399 | + | notices: newNoticeCenter(), |
| 400 | + | resumeParkGrace: defaultResumeParkGrace, | |||
| 396 | barScripts: &barScriptState{ | 401 | barScripts: &barScriptState{ | ||
| 397 | cfg: barConfigFromDomain(domain.Defaults().Bar), | 402 | cfg: barConfigFromDomain(domain.Defaults().Bar), | ||
| 398 | outputs: make(map[domain.SessionID]barScriptOutputs), | 403 | outputs: make(map[domain.SessionID]barScriptOutputs), | ||
A internal/usecase/daemon/snapshot_import_test.go +172 -0
| @@ -0,0 +1,172 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type snapshotAcceptanceLegacySource struct { | |
| 15 | + | blobs []ports.LegacySnapshot | |
| 16 | + | loadCalls int | |
| 17 | + | deleteCalls []string | |
| 18 | + | deleteErr error | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (s *snapshotAcceptanceLegacySource) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, error) { | |
| 22 | + | if err := ctx.Err(); err != nil { | |
| 23 | + | return nil, err | |
| 24 | + | } | |
| 25 | + | s.loadCalls++ | |
| 26 | + | out := make([]ports.LegacySnapshot, len(s.blobs)) | |
| 27 | + | for i, blob := range s.blobs { | |
| 28 | + | out[i] = ports.LegacySnapshot{Name: blob.Name, Data: append([]byte(nil), blob.Data...)} | |
| 29 | + | } | |
| 30 | + | return out, nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name string) error { | |
| 34 | + | if err := ctx.Err(); err != nil { | |
| 35 | + | return err | |
| 36 | + | } | |
| 37 | + | s.deleteCalls = append(s.deleteCalls, name) | |
| 38 | + | if s.deleteErr != nil { | |
| 39 | + | return s.deleteErr | |
| 40 | + | } | |
| 41 | + | for i, blob := range s.blobs { | |
| 42 | + | if blob.Name == name { | |
| 43 | + | s.blobs = append(s.blobs[:i], s.blobs[i+1:]...) | |
| 44 | + | break | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | return nil | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func legacyAcceptanceBlob(t *testing.T, snapshot snapcodec.Session) ports.LegacySnapshot { | |
| 51 | + | t.Helper() | |
| 52 | + | data, err := snapcodec.Marshal(snapshot) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | return ports.LegacySnapshot{Name: snapshot.Name, Data: data} | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.T) { | |
| 58 | + | snapshot := restoreAcceptanceSession(t, "legacy") | |
| 59 | + | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | |
| 60 | + | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}} | |
| 61 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 62 | + | WithSnapshotRepository(repository, legacy)(d) | |
| 63 | + | expected, verifyErr := legacyPublication(snapshot) | |
| 64 | + | require.NoError(t, verifyErr) | |
| 65 | + | _, verifyErr = snapcodec.Unmarshal(legacy.blobs[0].Data) | |
| 66 | + | require.NoError(t, verifyErr) | |
| 67 | + | require.NoError(t, verifyLegacyImportGeneration(acceptanceGeneration(t, snapshot, 1), expected)) | |
| 68 | + | ||
| 69 | + | d.importLegacySnapshots(context.Background()) | |
| 70 | + | ||
| 71 | + | require.Len(t, repository.publishes, 1) | |
| 72 | + | publication := repository.publishes[0] | |
| 73 | + | require.Equal(t, snapshot.Name, publication.Name) | |
| 74 | + | require.EqualValues(t, 1, publication.Generation) | |
| 75 | + | manifest, err := snapcodec.UnmarshalManifest(publication.Manifest) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | require.Equal(t, uint64(1), manifest.Generation) | |
| 78 | + | require.Len(t, manifest.Tabs[0].Panes[0].Sealed, 2) | |
| 79 | + | for _, object := range publication.Objects { | |
| 80 | + | kind, _, err := snapcodec.UnmarshalObject(object.Data) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | require.Contains(t, []snapcodec.ObjectKind{snapcodec.HistoryChunk, snapcodec.HistoryTail, snapcodec.Visible}, kind) | |
| 83 | + | require.Equal(t, object.Digest, snapcodec.ManifestDigest(object.Data)) | |
| 84 | + | } | |
| 85 | + | require.Equal(t, []string{snapshot.Name}, legacy.deleteCalls) | |
| 86 | + | require.Empty(t, legacy.blobs) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) { | |
| 90 | + | snapshot := restoreAcceptanceSession(t, "already-incremental") | |
| 91 | + | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: make(map[string]ports.SnapshotGeneration)} | |
| 92 | + | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}} | |
| 93 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 94 | + | WithSnapshotRepository(repository, legacy)(d) | |
| 95 | + | ||
| 96 | + | d.importLegacySnapshots(context.Background()) | |
| 97 | + | ||
| 98 | + | require.Empty(t, repository.publishes) | |
| 99 | + | require.Empty(t, legacy.deleteCalls) | |
| 100 | + | require.Len(t, legacy.blobs, 1) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) { | |
| 104 | + | snapshot := restoreAcceptanceSession(t, "retain") | |
| 105 | + | for _, test := range []struct { | |
| 106 | + | name string | |
| 107 | + | publish error | |
| 108 | + | load error | |
| 109 | + | mutate func(ports.SnapshotGeneration, ports.SnapshotPublication) ports.SnapshotGeneration | |
| 110 | + | }{ | |
| 111 | + | {"publish failure", errors.New("publish failed"), nil, nil}, | |
| 112 | + | {"reload failure", nil, errors.New("reload failed"), nil}, | |
| 113 | + | {"manifest metadata mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration { | |
| 114 | + | g.Generation++ | |
| 115 | + | return g | |
| 116 | + | }}, | |
| 117 | + | {"object bytes mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration { | |
| 118 | + | for digest, data := range g.Objects { | |
| 119 | + | data[len(data)-1] ^= 1 | |
| 120 | + | g.Objects[digest] = data | |
| 121 | + | break | |
| 122 | + | } | |
| 123 | + | return g | |
| 124 | + | }}, | |
| 125 | + | } { | |
| 126 | + | t.Run(test.name, func(t *testing.T) { | |
| 127 | + | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration), publishErr: test.publish, loadErr: test.load, loadMutate: test.mutate} | |
| 128 | + | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}} | |
| 129 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 130 | + | WithSnapshotRepository(repository, legacy)(d) | |
| 131 | + | d.importLegacySnapshots(context.Background()) | |
| 132 | + | require.Empty(t, legacy.deleteCalls) | |
| 133 | + | require.Len(t, legacy.blobs, 1) | |
| 134 | + | }) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) { | |
| 139 | + | snapshot := restoreAcceptanceSession(t, "retry-delete") | |
| 140 | + | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | |
| 141 | + | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}, deleteErr: errors.New("sync failed")} | |
| 142 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 143 | + | WithSnapshotRepository(repository, legacy)(d) | |
| 144 | + | ||
| 145 | + | d.importLegacySnapshots(context.Background()) | |
| 146 | + | legacy.deleteErr = nil | |
| 147 | + | d.importLegacySnapshots(context.Background()) | |
| 148 | + | ||
| 149 | + | require.Len(t, repository.publishes, 1, "a retry must only retry source deletion") | |
| 150 | + | require.Equal(t, []string{snapshot.Name, snapshot.Name}, legacy.deleteCalls) | |
| 151 | + | require.Empty(t, legacy.blobs) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestImportLegacyCancellationAndPerSessionContinuation(t *testing.T) { | |
| 155 | + | bad := restoreAcceptanceSession(t, "bad") | |
| 156 | + | good := restoreAcceptanceSession(t, "good") | |
| 157 | + | badBlob := legacyAcceptanceBlob(t, bad) | |
| 158 | + | badBlob.Data = []byte("not-a-v3-snapshot") | |
| 159 | + | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | |
| 160 | + | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{badBlob, legacyAcceptanceBlob(t, good)}} | |
| 161 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 162 | + | WithSnapshotRepository(repository, legacy)(d) | |
| 163 | + | ||
| 164 | + | d.importLegacySnapshots(context.Background()) | |
| 165 | + | require.Len(t, repository.publishes, 1, "a corrupt session must not block later sessions") | |
| 166 | + | require.Equal(t, []string{good.Name}, legacy.deleteCalls) | |
| 167 | + | ||
| 168 | + | cancelled, cancel := context.WithCancel(context.Background()) | |
| 169 | + | cancel() | |
| 170 | + | d.importLegacySnapshots(cancelled) | |
| 171 | + | require.Equal(t, 1, legacy.loadCalls, "a canceled import must not touch the legacy source") | |
| 172 | + | } | |
| @@ -0,0 +1,172 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type snapshotAcceptanceLegacySource struct { | |||
| 15 | + | blobs []ports.LegacySnapshot | |||
| 16 | + | loadCalls int | |||
| 17 | + | deleteCalls []string | |||
| 18 | + | deleteErr error | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (s *snapshotAcceptanceLegacySource) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, error) { | |||
| 22 | + | if err := ctx.Err(); err != nil { | |||
| 23 | + | return nil, err | |||
| 24 | + | } | |||
| 25 | + | s.loadCalls++ | |||
| 26 | + | out := make([]ports.LegacySnapshot, len(s.blobs)) | |||
| 27 | + | for i, blob := range s.blobs { | |||
| 28 | + | out[i] = ports.LegacySnapshot{Name: blob.Name, Data: append([]byte(nil), blob.Data...)} | |||
| 29 | + | } | |||
| 30 | + | return out, nil | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name string) error { | |||
| 34 | + | if err := ctx.Err(); err != nil { | |||
| 35 | + | return err | |||
| 36 | + | } | |||
| 37 | + | s.deleteCalls = append(s.deleteCalls, name) | |||
| 38 | + | if s.deleteErr != nil { | |||
| 39 | + | return s.deleteErr | |||
| 40 | + | } | |||
| 41 | + | for i, blob := range s.blobs { | |||
| 42 | + | if blob.Name == name { | |||
| 43 | + | s.blobs = append(s.blobs[:i], s.blobs[i+1:]...) | |||
| 44 | + | break | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | return nil | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func legacyAcceptanceBlob(t *testing.T, snapshot snapcodec.Session) ports.LegacySnapshot { | |||
| 51 | + | t.Helper() | |||
| 52 | + | data, err := snapcodec.Marshal(snapshot) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | return ports.LegacySnapshot{Name: snapshot.Name, Data: data} | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.T) { | |||
| 58 | + | snapshot := restoreAcceptanceSession(t, "legacy") | |||
| 59 | + | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | |||
| 60 | + | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}} | |||
| 61 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 62 | + | WithSnapshotRepository(repository, legacy)(d) | |||
| 63 | + | expected, verifyErr := legacyPublication(snapshot) | |||
| 64 | + | require.NoError(t, verifyErr) | |||
| 65 | + | _, verifyErr = snapcodec.Unmarshal(legacy.blobs[0].Data) | |||
| 66 | + | require.NoError(t, verifyErr) | |||
| 67 | + | require.NoError(t, verifyLegacyImportGeneration(acceptanceGeneration(t, snapshot, 1), expected)) | |||
| 68 | + | ||||
| 69 | + | d.importLegacySnapshots(context.Background()) | |||
| 70 | + | ||||
| 71 | + | require.Len(t, repository.publishes, 1) | |||
| 72 | + | publication := repository.publishes[0] | |||
| 73 | + | require.Equal(t, snapshot.Name, publication.Name) | |||
| 74 | + | require.EqualValues(t, 1, publication.Generation) | |||
| 75 | + | manifest, err := snapcodec.UnmarshalManifest(publication.Manifest) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | require.Equal(t, uint64(1), manifest.Generation) | |||
| 78 | + | require.Len(t, manifest.Tabs[0].Panes[0].Sealed, 2) | |||
| 79 | + | for _, object := range publication.Objects { | |||
| 80 | + | kind, _, err := snapcodec.UnmarshalObject(object.Data) | |||
| 81 | + | require.NoError(t, err) | |||
| 82 | + | require.Contains(t, []snapcodec.ObjectKind{snapcodec.HistoryChunk, snapcodec.HistoryTail, snapcodec.Visible}, kind) | |||
| 83 | + | require.Equal(t, object.Digest, snapcodec.ManifestDigest(object.Data)) | |||
| 84 | + | } | |||
| 85 | + | require.Equal(t, []string{snapshot.Name}, legacy.deleteCalls) | |||
| 86 | + | require.Empty(t, legacy.blobs) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) { | |||
| 90 | + | snapshot := restoreAcceptanceSession(t, "already-incremental") | |||
| 91 | + | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: make(map[string]ports.SnapshotGeneration)} | |||
| 92 | + | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}} | |||
| 93 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 94 | + | WithSnapshotRepository(repository, legacy)(d) | |||
| 95 | + | ||||
| 96 | + | d.importLegacySnapshots(context.Background()) | |||
| 97 | + | ||||
| 98 | + | require.Empty(t, repository.publishes) | |||
| 99 | + | require.Empty(t, legacy.deleteCalls) | |||
| 100 | + | require.Len(t, legacy.blobs, 1) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) { | |||
| 104 | + | snapshot := restoreAcceptanceSession(t, "retain") | |||
| 105 | + | for _, test := range []struct { | |||
| 106 | + | name string | |||
| 107 | + | publish error | |||
| 108 | + | load error | |||
| 109 | + | mutate func(ports.SnapshotGeneration, ports.SnapshotPublication) ports.SnapshotGeneration | |||
| 110 | + | }{ | |||
| 111 | + | {"publish failure", errors.New("publish failed"), nil, nil}, | |||
| 112 | + | {"reload failure", nil, errors.New("reload failed"), nil}, | |||
| 113 | + | {"manifest metadata mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration { | |||
| 114 | + | g.Generation++ | |||
| 115 | + | return g | |||
| 116 | + | }}, | |||
| 117 | + | {"object bytes mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration { | |||
| 118 | + | for digest, data := range g.Objects { | |||
| 119 | + | data[len(data)-1] ^= 1 | |||
| 120 | + | g.Objects[digest] = data | |||
| 121 | + | break | |||
| 122 | + | } | |||
| 123 | + | return g | |||
| 124 | + | }}, | |||
| 125 | + | } { | |||
| 126 | + | t.Run(test.name, func(t *testing.T) { | |||
| 127 | + | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration), publishErr: test.publish, loadErr: test.load, loadMutate: test.mutate} | |||
| 128 | + | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}} | |||
| 129 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 130 | + | WithSnapshotRepository(repository, legacy)(d) | |||
| 131 | + | d.importLegacySnapshots(context.Background()) | |||
| 132 | + | require.Empty(t, legacy.deleteCalls) | |||
| 133 | + | require.Len(t, legacy.blobs, 1) | |||
| 134 | + | }) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) { | |||
| 139 | + | snapshot := restoreAcceptanceSession(t, "retry-delete") | |||
| 140 | + | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | |||
| 141 | + | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}, deleteErr: errors.New("sync failed")} | |||
| 142 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 143 | + | WithSnapshotRepository(repository, legacy)(d) | |||
| 144 | + | ||||
| 145 | + | d.importLegacySnapshots(context.Background()) | |||
| 146 | + | legacy.deleteErr = nil | |||
| 147 | + | d.importLegacySnapshots(context.Background()) | |||
| 148 | + | ||||
| 149 | + | require.Len(t, repository.publishes, 1, "a retry must only retry source deletion") | |||
| 150 | + | require.Equal(t, []string{snapshot.Name, snapshot.Name}, legacy.deleteCalls) | |||
| 151 | + | require.Empty(t, legacy.blobs) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestImportLegacyCancellationAndPerSessionContinuation(t *testing.T) { | |||
| 155 | + | bad := restoreAcceptanceSession(t, "bad") | |||
| 156 | + | good := restoreAcceptanceSession(t, "good") | |||
| 157 | + | badBlob := legacyAcceptanceBlob(t, bad) | |||
| 158 | + | badBlob.Data = []byte("not-a-v3-snapshot") | |||
| 159 | + | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | |||
| 160 | + | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{badBlob, legacyAcceptanceBlob(t, good)}} | |||
| 161 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 162 | + | WithSnapshotRepository(repository, legacy)(d) | |||
| 163 | + | ||||
| 164 | + | d.importLegacySnapshots(context.Background()) | |||
| 165 | + | require.Len(t, repository.publishes, 1, "a corrupt session must not block later sessions") | |||
| 166 | + | require.Equal(t, []string{good.Name}, legacy.deleteCalls) | |||
| 167 | + | ||||
| 168 | + | cancelled, cancel := context.WithCancel(context.Background()) | |||
| 169 | + | cancel() | |||
| 170 | + | d.importLegacySnapshots(cancelled) | |||
| 171 | + | require.Equal(t, 1, legacy.loadCalls, "a canceled import must not touch the legacy source") | |||
| 172 | + | } | |||
M internal/usecase/daemon/snapshot_legacy_import.go +0 -7
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | 4 | "fmt" | |
| 6 | 5 | ||
| 7 | 6 | "github.com/bnema/vev/internal/ports" | |
| @@ -43,9 +42,3 @@ func legacyPublication(snapshot snapcodec.Session) (ports.SnapshotPublication, e | |||
| 43 | 42 | } | |
| 44 | 43 | return ports.SnapshotPublication{Name: snapshot.Name, Generation: 1, Manifest: encoded, Objects: objects}, nil | |
| 45 | 44 | } | |
| 46 | - | ||
| 47 | - | func sameSnapshotSession(left, right snapcodec.Session) bool { | |
| 48 | - | leftBytes, leftErr := snapcodec.Marshal(left) | |
| 49 | - | rightBytes, rightErr := snapcodec.Marshal(right) | |
| 50 | - | return leftErr == nil && rightErr == nil && bytes.Equal(leftBytes, rightBytes) | |
| 51 | - | } | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "bytes" | |||
| 5 | "fmt" | 4 | "fmt" | ||
| 6 | 5 | ||||
| 7 | "github.com/bnema/vev/internal/ports" | 6 | "github.com/bnema/vev/internal/ports" | ||
| @@ -43,9 +42,3 @@ func legacyPublication(snapshot snapcodec.Session) (ports.SnapshotPublication, e | |||||
| 43 | } | 42 | } | ||
| 44 | return ports.SnapshotPublication{Name: snapshot.Name, Generation: 1, Manifest: encoded, Objects: objects}, nil | 43 | return ports.SnapshotPublication{Name: snapshot.Name, Generation: 1, Manifest: encoded, Objects: objects}, nil | ||
| 45 | } | 44 | } | ||
| 46 | - | ||||
| 47 | - | func sameSnapshotSession(left, right snapcodec.Session) bool { | |||
| 48 | - | leftBytes, leftErr := snapcodec.Marshal(left) | |||
| 49 | - | rightBytes, rightErr := snapcodec.Marshal(right) | |||
| 50 | - | return leftErr == nil && rightErr == nil && bytes.Equal(leftBytes, rightBytes) | |||
| 51 | - | } | |||
M internal/usecase/daemon/snapshot_restore_incremental.go +66 -7
| @@ -1,7 +1,9 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 6 | + | "crypto/sha256" | |
| 5 | 7 | "fmt" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/vev/internal/domain" | |
| @@ -88,7 +90,7 @@ func generationObject(generation ports.SnapshotGeneration, ref snapcodec.ObjectR | |||
| 88 | 90 | return nil, fmt.Errorf("snapshot: object kind mismatch") | |
| 89 | 91 | } | |
| 90 | 92 | data, ok := generation.Objects[ref.Digest] | |
| 91 | - | if !ok || uint32(len(data)) != ref.Size { | |
| 93 | + | if !ok || uint32(len(data)) != ref.Size || sha256.Sum256(data) != ref.Digest { | |
| 92 | 94 | return nil, fmt.Errorf("snapshot: missing object") | |
| 93 | 95 | } | |
| 94 | 96 | gotKind, payload, err := snapcodec.UnmarshalObject(data) | |
| @@ -102,7 +104,7 @@ func generationObject(generation ports.SnapshotGeneration, ref snapcodec.ObjectR | |||
| 102 | 104 | // a legacy blob only after publication and a full repository reload verify the | |
| 103 | 105 | // exact converted session, leaving failures retryable on the next startup. | |
| 104 | 106 | func (d *Daemon) importLegacySnapshots(ctx context.Context) { | |
| 105 | - | if d.legacySnapshots == nil || d.snapshotRepository == nil { | |
| 107 | + | if d.legacySnapshots == nil || d.snapshotRepository == nil || ctx.Err() != nil { | |
| 106 | 108 | return | |
| 107 | 109 | } | |
| 108 | 110 | existing, err := d.snapshotRepository.List(ctx) | |
| @@ -120,7 +122,11 @@ func (d *Daemon) importLegacySnapshots(ctx context.Context) { | |||
| 120 | 122 | return | |
| 121 | 123 | } | |
| 122 | 124 | for _, blob := range legacy { | |
| 125 | + | if ctx.Err() != nil { | |
| 126 | + | return | |
| 127 | + | } | |
| 123 | 128 | if _, ok := present[blob.Name]; ok { | |
| 129 | + | d.retryLegacyDelete(ctx, blob) | |
| 124 | 130 | continue | |
| 125 | 131 | } | |
| 126 | 132 | snapshot, err := snapcodec.Unmarshal(blob.Data) | |
| @@ -136,21 +142,74 @@ func (d *Daemon) importLegacySnapshots(ctx context.Context) { | |||
| 136 | 142 | var generation ports.SnapshotGeneration | |
| 137 | 143 | generation, err = d.snapshotRepository.Load(ctx, snapshot.Name) | |
| 138 | 144 | if err == nil { | |
| 139 | - | var restored snapcodec.Session | |
| 140 | - | restored, err = sessionFromGeneration(generation) | |
| 141 | - | if err == nil && !sameSnapshotSession(snapshot, restored) { | |
| 142 | - | err = fmt.Errorf("snapshot: legacy import verification mismatch") | |
| 143 | - | } | |
| 145 | + | err = verifyLegacyImportGeneration(generation, publication) | |
| 144 | 146 | } | |
| 145 | 147 | } | |
| 146 | 148 | if err != nil { | |
| 147 | 149 | d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err) | |
| 148 | 150 | continue | |
| 149 | 151 | } | |
| 152 | + | d.rememberLegacyDelete(blob) | |
| 150 | 153 | if err := d.legacySnapshots.DeleteLegacy(ctx, blob.Name); err != nil { | |
| 151 | 154 | d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err) | |
| 152 | 155 | continue | |
| 153 | 156 | } | |
| 157 | + | d.forgetLegacyDelete(blob.Name) | |
| 154 | 158 | present[blob.Name] = struct{}{} | |
| 155 | 159 | } | |
| 156 | 160 | } | |
| 161 | + | ||
| 162 | + | func verifyLegacyImportGeneration(generation ports.SnapshotGeneration, publication ports.SnapshotPublication) error { | |
| 163 | + | if generation.Name != publication.Name || generation.Generation != publication.Generation || !bytes.Equal(generation.Manifest, publication.Manifest) { | |
| 164 | + | return fmt.Errorf("snapshot: legacy import verification mismatch") | |
| 165 | + | } | |
| 166 | + | expected := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |
| 167 | + | for _, object := range publication.Objects { | |
| 168 | + | if sha256.Sum256(object.Data) != object.Digest { | |
| 169 | + | return fmt.Errorf("snapshot: legacy import publication digest mismatch") | |
| 170 | + | } | |
| 171 | + | expected[object.Digest] = object.Data | |
| 172 | + | } | |
| 173 | + | if len(generation.Objects) != len(expected) { | |
| 174 | + | return fmt.Errorf("snapshot: legacy import verification mismatch") | |
| 175 | + | } | |
| 176 | + | for digest, expectedData := range expected { | |
| 177 | + | actual, ok := generation.Objects[digest] | |
| 178 | + | if !ok || !bytes.Equal(actual, expectedData) || sha256.Sum256(actual) != digest { | |
| 179 | + | return fmt.Errorf("snapshot: legacy import verification mismatch") | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | return nil | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (d *Daemon) rememberLegacyDelete(blob ports.LegacySnapshot) { | |
| 186 | + | d.legacyImportMu.Lock() | |
| 187 | + | if d.legacyImportPending == nil { | |
| 188 | + | d.legacyImportPending = make(map[string][]byte) | |
| 189 | + | } | |
| 190 | + | d.legacyImportPending[blob.Name] = append([]byte(nil), blob.Data...) | |
| 191 | + | d.legacyImportMu.Unlock() | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func (d *Daemon) forgetLegacyDelete(name string) { | |
| 195 | + | d.legacyImportMu.Lock() | |
| 196 | + | delete(d.legacyImportPending, name) | |
| 197 | + | d.legacyImportMu.Unlock() | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // retryLegacyDelete only acts on a blob which this daemon has already | |
| 201 | + | // published and exactly reloaded. An unrelated pre-existing generation must | |
| 202 | + | // continue to leave its legacy counterpart untouched. | |
| 203 | + | func (d *Daemon) retryLegacyDelete(ctx context.Context, blob ports.LegacySnapshot) { | |
| 204 | + | d.legacyImportMu.Lock() | |
| 205 | + | pending, ok := d.legacyImportPending[blob.Name] | |
| 206 | + | d.legacyImportMu.Unlock() | |
| 207 | + | if !ok || !bytes.Equal(pending, blob.Data) { | |
| 208 | + | return | |
| 209 | + | } | |
| 210 | + | if err := d.legacySnapshots.DeleteLegacy(ctx, blob.Name); err != nil { | |
| 211 | + | d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err) | |
| 212 | + | return | |
| 213 | + | } | |
| 214 | + | d.forgetLegacyDelete(blob.Name) | |
| 215 | + | } | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "context" | 5 | "context" | ||
| 6 | + | "crypto/sha256" | |||
| 5 | "fmt" | 7 | "fmt" | ||
| 6 | 8 | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| @@ -88,7 +90,7 @@ func generationObject(generation ports.SnapshotGeneration, ref snapcodec.ObjectR | |||||
| 88 | return nil, fmt.Errorf("snapshot: object kind mismatch") | 90 | return nil, fmt.Errorf("snapshot: object kind mismatch") | ||
| 89 | } | 91 | } | ||
| 90 | data, ok := generation.Objects[ref.Digest] | 92 | data, ok := generation.Objects[ref.Digest] | ||
| 91 | - | if !ok || uint32(len(data)) != ref.Size { | 93 | + | if !ok || uint32(len(data)) != ref.Size || sha256.Sum256(data) != ref.Digest { |
| 92 | return nil, fmt.Errorf("snapshot: missing object") | 94 | return nil, fmt.Errorf("snapshot: missing object") | ||
| 93 | } | 95 | } | ||
| 94 | gotKind, payload, err := snapcodec.UnmarshalObject(data) | 96 | gotKind, payload, err := snapcodec.UnmarshalObject(data) | ||
| @@ -102,7 +104,7 @@ func generationObject(generation ports.SnapshotGeneration, ref snapcodec.ObjectR | |||||
| 102 | // a legacy blob only after publication and a full repository reload verify the | 104 | // a legacy blob only after publication and a full repository reload verify the | ||
| 103 | // exact converted session, leaving failures retryable on the next startup. | 105 | // exact converted session, leaving failures retryable on the next startup. | ||
| 104 | func (d *Daemon) importLegacySnapshots(ctx context.Context) { | 106 | func (d *Daemon) importLegacySnapshots(ctx context.Context) { | ||
| 105 | - | if d.legacySnapshots == nil || d.snapshotRepository == nil { | 107 | + | if d.legacySnapshots == nil || d.snapshotRepository == nil || ctx.Err() != nil { |
| 106 | return | 108 | return | ||
| 107 | } | 109 | } | ||
| 108 | existing, err := d.snapshotRepository.List(ctx) | 110 | existing, err := d.snapshotRepository.List(ctx) | ||
| @@ -120,7 +122,11 @@ func (d *Daemon) importLegacySnapshots(ctx context.Context) { | |||||
| 120 | return | 122 | return | ||
| 121 | } | 123 | } | ||
| 122 | for _, blob := range legacy { | 124 | for _, blob := range legacy { | ||
| 125 | + | if ctx.Err() != nil { | |||
| 126 | + | return | |||
| 127 | + | } | |||
| 123 | if _, ok := present[blob.Name]; ok { | 128 | if _, ok := present[blob.Name]; ok { | ||
| 129 | + | d.retryLegacyDelete(ctx, blob) | |||
| 124 | continue | 130 | continue | ||
| 125 | } | 131 | } | ||
| 126 | snapshot, err := snapcodec.Unmarshal(blob.Data) | 132 | snapshot, err := snapcodec.Unmarshal(blob.Data) | ||
| @@ -136,21 +142,74 @@ func (d *Daemon) importLegacySnapshots(ctx context.Context) { | |||||
| 136 | var generation ports.SnapshotGeneration | 142 | var generation ports.SnapshotGeneration | ||
| 137 | generation, err = d.snapshotRepository.Load(ctx, snapshot.Name) | 143 | generation, err = d.snapshotRepository.Load(ctx, snapshot.Name) | ||
| 138 | if err == nil { | 144 | if err == nil { | ||
| 139 | - | var restored snapcodec.Session | 145 | + | err = verifyLegacyImportGeneration(generation, publication) |
| 140 | - | restored, err = sessionFromGeneration(generation) | |||
| 141 | - | if err == nil && !sameSnapshotSession(snapshot, restored) { | |||
| 142 | - | err = fmt.Errorf("snapshot: legacy import verification mismatch") | |||
| 143 | - | } | |||
| 144 | } | 146 | } | ||
| 145 | } | 147 | } | ||
| 146 | if err != nil { | 148 | if err != nil { | ||
| 147 | d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err) | 149 | d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err) | ||
| 148 | continue | 150 | continue | ||
| 149 | } | 151 | } | ||
| 152 | + | d.rememberLegacyDelete(blob) | |||
| 150 | if err := d.legacySnapshots.DeleteLegacy(ctx, blob.Name); err != nil { | 153 | if err := d.legacySnapshots.DeleteLegacy(ctx, blob.Name); err != nil { | ||
| 151 | d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err) | 154 | d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err) | ||
| 152 | continue | 155 | continue | ||
| 153 | } | 156 | } | ||
| 157 | + | d.forgetLegacyDelete(blob.Name) | |||
| 154 | present[blob.Name] = struct{}{} | 158 | present[blob.Name] = struct{}{} | ||
| 155 | } | 159 | } | ||
| 156 | } | 160 | } | ||
| 161 | + | ||||
| 162 | + | func verifyLegacyImportGeneration(generation ports.SnapshotGeneration, publication ports.SnapshotPublication) error { | |||
| 163 | + | if generation.Name != publication.Name || generation.Generation != publication.Generation || !bytes.Equal(generation.Manifest, publication.Manifest) { | |||
| 164 | + | return fmt.Errorf("snapshot: legacy import verification mismatch") | |||
| 165 | + | } | |||
| 166 | + | expected := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |||
| 167 | + | for _, object := range publication.Objects { | |||
| 168 | + | if sha256.Sum256(object.Data) != object.Digest { | |||
| 169 | + | return fmt.Errorf("snapshot: legacy import publication digest mismatch") | |||
| 170 | + | } | |||
| 171 | + | expected[object.Digest] = object.Data | |||
| 172 | + | } | |||
| 173 | + | if len(generation.Objects) != len(expected) { | |||
| 174 | + | return fmt.Errorf("snapshot: legacy import verification mismatch") | |||
| 175 | + | } | |||
| 176 | + | for digest, expectedData := range expected { | |||
| 177 | + | actual, ok := generation.Objects[digest] | |||
| 178 | + | if !ok || !bytes.Equal(actual, expectedData) || sha256.Sum256(actual) != digest { | |||
| 179 | + | return fmt.Errorf("snapshot: legacy import verification mismatch") | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | return nil | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func (d *Daemon) rememberLegacyDelete(blob ports.LegacySnapshot) { | |||
| 186 | + | d.legacyImportMu.Lock() | |||
| 187 | + | if d.legacyImportPending == nil { | |||
| 188 | + | d.legacyImportPending = make(map[string][]byte) | |||
| 189 | + | } | |||
| 190 | + | d.legacyImportPending[blob.Name] = append([]byte(nil), blob.Data...) | |||
| 191 | + | d.legacyImportMu.Unlock() | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func (d *Daemon) forgetLegacyDelete(name string) { | |||
| 195 | + | d.legacyImportMu.Lock() | |||
| 196 | + | delete(d.legacyImportPending, name) | |||
| 197 | + | d.legacyImportMu.Unlock() | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // retryLegacyDelete only acts on a blob which this daemon has already | |||
| 201 | + | // published and exactly reloaded. An unrelated pre-existing generation must | |||
| 202 | + | // continue to leave its legacy counterpart untouched. | |||
| 203 | + | func (d *Daemon) retryLegacyDelete(ctx context.Context, blob ports.LegacySnapshot) { | |||
| 204 | + | d.legacyImportMu.Lock() | |||
| 205 | + | pending, ok := d.legacyImportPending[blob.Name] | |||
| 206 | + | d.legacyImportMu.Unlock() | |||
| 207 | + | if !ok || !bytes.Equal(pending, blob.Data) { | |||
| 208 | + | return | |||
| 209 | + | } | |||
| 210 | + | if err := d.legacySnapshots.DeleteLegacy(ctx, blob.Name); err != nil { | |||
| 211 | + | d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err) | |||
| 212 | + | return | |||
| 213 | + | } | |||
| 214 | + | d.forgetLegacyDelete(blob.Name) | |||
| 215 | + | } | |||
A internal/usecase/daemon/snapshot_restore_test.go +250 -0
| @@ -0,0 +1,250 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 15 | + | "github.com/bnema/vev/pkg/renderer" | |
| 16 | + | "github.com/bnema/vev/pkg/vt" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type snapshotAcceptanceRepository struct { | |
| 20 | + | mu sync.Mutex | |
| 21 | + | names []string | |
| 22 | + | generations map[string]ports.SnapshotGeneration | |
| 23 | + | publishErr error | |
| 24 | + | listErr error | |
| 25 | + | loadErr error | |
| 26 | + | publishes []ports.SnapshotPublication | |
| 27 | + | loadMutate func(ports.SnapshotGeneration, ports.SnapshotPublication) ports.SnapshotGeneration | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.SnapshotPublication) error { | |
| 31 | + | if err := ctx.Err(); err != nil { | |
| 32 | + | return err | |
| 33 | + | } | |
| 34 | + | r.mu.Lock() | |
| 35 | + | defer r.mu.Unlock() | |
| 36 | + | r.publishes = append(r.publishes, p) | |
| 37 | + | if r.publishErr != nil { | |
| 38 | + | return r.publishErr | |
| 39 | + | } | |
| 40 | + | objects := make(map[ports.SnapshotDigest][]byte, len(p.Objects)) | |
| 41 | + | for _, object := range p.Objects { | |
| 42 | + | objects[object.Digest] = append([]byte(nil), object.Data...) | |
| 43 | + | } | |
| 44 | + | r.generations[p.Name] = ports.SnapshotGeneration{Name: p.Name, Generation: p.Generation, Manifest: append([]byte(nil), p.Manifest...), Objects: objects} | |
| 45 | + | found := false | |
| 46 | + | for _, name := range r.names { | |
| 47 | + | found = found || name == p.Name | |
| 48 | + | } | |
| 49 | + | if !found { | |
| 50 | + | r.names = append(r.names, p.Name) | |
| 51 | + | } | |
| 52 | + | return nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (r *snapshotAcceptanceRepository) List(ctx context.Context) ([]string, error) { | |
| 56 | + | if err := ctx.Err(); err != nil { | |
| 57 | + | return nil, err | |
| 58 | + | } | |
| 59 | + | r.mu.Lock() | |
| 60 | + | defer r.mu.Unlock() | |
| 61 | + | if r.listErr != nil { | |
| 62 | + | return nil, r.listErr | |
| 63 | + | } | |
| 64 | + | return append([]string(nil), r.names...), nil | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (r *snapshotAcceptanceRepository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) { | |
| 68 | + | if err := ctx.Err(); err != nil { | |
| 69 | + | return ports.SnapshotGeneration{}, err | |
| 70 | + | } | |
| 71 | + | r.mu.Lock() | |
| 72 | + | defer r.mu.Unlock() | |
| 73 | + | if r.loadErr != nil { | |
| 74 | + | return ports.SnapshotGeneration{}, r.loadErr | |
| 75 | + | } | |
| 76 | + | generation, ok := r.generations[name] | |
| 77 | + | if !ok { | |
| 78 | + | return ports.SnapshotGeneration{}, errors.New("missing generation") | |
| 79 | + | } | |
| 80 | + | if r.loadMutate != nil && len(r.publishes) > 0 { | |
| 81 | + | generation = r.loadMutate(generation, r.publishes[len(r.publishes)-1]) | |
| 82 | + | } | |
| 83 | + | return cloneAcceptanceGeneration(generation), nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (*snapshotAcceptanceRepository) Delete(context.Context, string) error { return nil } | |
| 87 | + | func (*snapshotAcceptanceRepository) Maintain(context.Context) error { return nil } | |
| 88 | + | ||
| 89 | + | func cloneAcceptanceGeneration(g ports.SnapshotGeneration) ports.SnapshotGeneration { | |
| 90 | + | out := g | |
| 91 | + | out.Manifest = append([]byte(nil), g.Manifest...) | |
| 92 | + | out.Objects = make(map[ports.SnapshotDigest][]byte, len(g.Objects)) | |
| 93 | + | for digest, data := range g.Objects { | |
| 94 | + | out.Objects[digest] = append([]byte(nil), data...) | |
| 95 | + | } | |
| 96 | + | return out | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session { | |
| 100 | + | t.Helper() | |
| 101 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 8 * 293, ChunkRows: 2}) | |
| 102 | + | for _, ch := range []rune{'a', 'b', 'c', 'd', 'e'} { | |
| 103 | + | row := make([]renderer.Cell, 293) | |
| 104 | + | for i := range row { | |
| 105 | + | row[i] = renderer.Cell{Rune: ch} | |
| 106 | + | } | |
| 107 | + | require.NoError(t, history.Append(row)) | |
| 108 | + | } | |
| 109 | + | view := history.SnapshotView() | |
| 110 | + | sealed := make([][]byte, view.ChunkCount()) | |
| 111 | + | for i := range sealed { | |
| 112 | + | var err error | |
| 113 | + | sealed[i], err = vt.MarshalHistoryChunk(view.Chunk(i)) | |
| 114 | + | require.NoError(t, err) | |
| 115 | + | } | |
| 116 | + | tail, err := vt.MarshalHistoryTail(view) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | ||
| 119 | + | screen := vt.NewScreen(293, 4) | |
| 120 | + | screen.Write([]byte("primary-visible")) | |
| 121 | + | screen.Write([]byte("\x1b[?1049h")) | |
| 122 | + | screen.Write([]byte("alternate-active")) | |
| 123 | + | visible, err := screen.MarshalPrimaryVisible() | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | ||
| 126 | + | paneID := layout.PaneID("pane-7") | |
| 127 | + | return snapcodec.Session{ | |
| 128 | + | Name: name, CreatedAt: 77, Active: 0, | |
| 129 | + | Tabs: []snapcodec.Tab{{ | |
| 130 | + | StableID: "tab-stable", Cols: 293, Rows: 4, NextPaneID: 8, Focus: paneID, | |
| 131 | + | Tree: &layout.Tree{Root: layout.NewLeaf(paneID), Focus: paneID}, | |
| 132 | + | Panes: []snapcodec.Pane{{ | |
| 133 | + | ID: paneID, StableID: "pane-stable", Cwd: "/snapshot/cwd", SealedChunks: sealed, Tail: tail, Visible: visible, | |
| 134 | + | Process: &snapcodec.Process{Argv: []string{"sleep", "99"}, Strategy: "argv"}, | |
| 135 | + | }}, | |
| 136 | + | }}, | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation uint64) ports.SnapshotGeneration { | |
| 141 | + | t.Helper() | |
| 142 | + | publication, err := legacyPublication(snapshot) | |
| 143 | + | require.NoError(t, err) | |
| 144 | + | manifest, err := snapcodec.UnmarshalManifest(publication.Manifest) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | manifest.Generation = generation | |
| 147 | + | encoded, err := snapcodec.MarshalManifest(manifest) | |
| 148 | + | require.NoError(t, err) | |
| 149 | + | objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |
| 150 | + | for _, object := range publication.Objects { | |
| 151 | + | objects[object.Digest] = append([]byte(nil), object.Data...) | |
| 152 | + | } | |
| 153 | + | return ports.SnapshotGeneration{Name: snapshot.Name, Generation: generation, Manifest: encoded, Objects: objects} | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |
| 157 | + | snapshot := restoreAcceptanceSession(t, "restored") | |
| 158 | + | generation := acceptanceGeneration(t, snapshot, 9) | |
| 159 | + | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}} | |
| 160 | + | pty, release := newBlockingPTY(t) | |
| 161 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 162 | + | store, _ := newMockStore(t) | |
| 163 | + | WithStore(store)(d) | |
| 164 | + | WithSnapshotRepository(repository, nil)(d) | |
| 165 | + | t.Cleanup(func() { release(); d.sessWg.Wait() }) | |
| 166 | + | ||
| 167 | + | d.restoreIncrementalSnapshots(context.Background()) | |
| 168 | + | ||
| 169 | + | d.mu.Lock() | |
| 170 | + | restored := d.findByNameLocked(snapshot.Name) | |
| 171 | + | d.mu.Unlock() | |
| 172 | + | require.NotNil(t, restored) | |
| 173 | + | require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean") | |
| 174 | + | require.Equal(t, 0, restored.active) | |
| 175 | + | require.Equal(t, "/snapshot/cwd", restored.cwd) | |
| 176 | + | require.Len(t, restored.tabs, 1) | |
| 177 | + | tab := restored.tabs[0] | |
| 178 | + | require.Equal(t, 293, tab.size.Cols) | |
| 179 | + | require.Equal(t, layout.PaneID("pane-7"), tab.tree.Focus) | |
| 180 | + | pane := tab.panes[layout.PaneID("pane-7")] | |
| 181 | + | pane.mu.Lock() | |
| 182 | + | require.Equal(t, "primary-visible", string([]rune{pane.screen.Frame.Row(0)[0].Rune, pane.screen.Frame.Row(0)[1].Rune, pane.screen.Frame.Row(0)[2].Rune, pane.screen.Frame.Row(0)[3].Rune, pane.screen.Frame.Row(0)[4].Rune, pane.screen.Frame.Row(0)[5].Rune, pane.screen.Frame.Row(0)[6].Rune, pane.screen.Frame.Row(0)[7].Rune, pane.screen.Frame.Row(0)[8].Rune, pane.screen.Frame.Row(0)[9].Rune, pane.screen.Frame.Row(0)[10].Rune, pane.screen.Frame.Row(0)[11].Rune, pane.screen.Frame.Row(0)[12].Rune, pane.screen.Frame.Row(0)[13].Rune, pane.screen.Frame.Row(0)[14].Rune})) | |
| 183 | + | require.Equal(t, 5, pane.history.Len()) | |
| 184 | + | require.Equal(t, 5*293, pane.history.Cells()) | |
| 185 | + | require.Equal(t, defaultScrollbackRows, pane.history.Cap()) | |
| 186 | + | require.Equal(t, defaultScrollbackCells, pane.history.CellCap()) | |
| 187 | + | pane.mu.Unlock() | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) { | |
| 191 | + | snapshot := restoreAcceptanceSession(t, "fallback") | |
| 192 | + | valid := acceptanceGeneration(t, snapshot, 3) | |
| 193 | + | valid.Fallback = true | |
| 194 | + | pty, release := newBlockingPTY(t) | |
| 195 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 196 | + | store, _ := newMockStore(t) | |
| 197 | + | WithStore(store)(d) | |
| 198 | + | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: valid}} | |
| 199 | + | WithSnapshotRepository(repository, nil)(d) | |
| 200 | + | t.Cleanup(func() { release(); d.sessWg.Wait() }) | |
| 201 | + | d.restoreIncrementalSnapshots(context.Background()) | |
| 202 | + | require.Len(t, d.notices.history(), 1) | |
| 203 | + | require.Equal(t, domain.NoticeWarn, d.notices.history()[0].Severity) | |
| 204 | + | ||
| 205 | + | for _, mutate := range []struct { | |
| 206 | + | name string | |
| 207 | + | fn func(*ports.SnapshotGeneration) | |
| 208 | + | }{ | |
| 209 | + | {"missing", func(g *ports.SnapshotGeneration) { | |
| 210 | + | for digest := range g.Objects { | |
| 211 | + | delete(g.Objects, digest) | |
| 212 | + | break | |
| 213 | + | } | |
| 214 | + | }}, | |
| 215 | + | {"wrong kind", func(g *ports.SnapshotGeneration) { | |
| 216 | + | for digest, data := range g.Objects { | |
| 217 | + | kind, payload, err := snapcodec.UnmarshalObject(data) | |
| 218 | + | require.NoError(t, err) | |
| 219 | + | if kind == snapcodec.HistoryTail { | |
| 220 | + | replacement, err := snapcodec.MarshalObject(snapcodec.Visible, payload) | |
| 221 | + | require.NoError(t, err) | |
| 222 | + | delete(g.Objects, digest) | |
| 223 | + | g.Objects[digest] = replacement.Data | |
| 224 | + | break | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | }}, | |
| 228 | + | {"wrong digest", func(g *ports.SnapshotGeneration) { | |
| 229 | + | for digest, data := range g.Objects { | |
| 230 | + | kind, payload, err := snapcodec.UnmarshalObject(data) | |
| 231 | + | require.NoError(t, err) | |
| 232 | + | payload = append([]byte(nil), payload...) | |
| 233 | + | payload[len(payload)-1] ^= 1 | |
| 234 | + | replacement, err := snapcodec.MarshalObject(kind, payload) | |
| 235 | + | require.NoError(t, err) | |
| 236 | + | require.Equal(t, len(data), len(replacement.Data)) | |
| 237 | + | g.Objects[digest] = replacement.Data | |
| 238 | + | break | |
| 239 | + | } | |
| 240 | + | }}, | |
| 241 | + | } { | |
| 242 | + | t.Run(mutate.name, func(t *testing.T) { | |
| 243 | + | generation := cloneAcceptanceGeneration(valid) | |
| 244 | + | generation.Fallback = false | |
| 245 | + | mutate.fn(&generation) | |
| 246 | + | _, err := sessionFromGeneration(generation) | |
| 247 | + | require.Error(t, err) | |
| 248 | + | }) | |
| 249 | + | } | |
| 250 | + | } | |
| @@ -0,0 +1,250 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 14 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 15 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 16 | + | "github.com/bnema/vev/pkg/vt" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | type snapshotAcceptanceRepository struct { | |||
| 20 | + | mu sync.Mutex | |||
| 21 | + | names []string | |||
| 22 | + | generations map[string]ports.SnapshotGeneration | |||
| 23 | + | publishErr error | |||
| 24 | + | listErr error | |||
| 25 | + | loadErr error | |||
| 26 | + | publishes []ports.SnapshotPublication | |||
| 27 | + | loadMutate func(ports.SnapshotGeneration, ports.SnapshotPublication) ports.SnapshotGeneration | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.SnapshotPublication) error { | |||
| 31 | + | if err := ctx.Err(); err != nil { | |||
| 32 | + | return err | |||
| 33 | + | } | |||
| 34 | + | r.mu.Lock() | |||
| 35 | + | defer r.mu.Unlock() | |||
| 36 | + | r.publishes = append(r.publishes, p) | |||
| 37 | + | if r.publishErr != nil { | |||
| 38 | + | return r.publishErr | |||
| 39 | + | } | |||
| 40 | + | objects := make(map[ports.SnapshotDigest][]byte, len(p.Objects)) | |||
| 41 | + | for _, object := range p.Objects { | |||
| 42 | + | objects[object.Digest] = append([]byte(nil), object.Data...) | |||
| 43 | + | } | |||
| 44 | + | r.generations[p.Name] = ports.SnapshotGeneration{Name: p.Name, Generation: p.Generation, Manifest: append([]byte(nil), p.Manifest...), Objects: objects} | |||
| 45 | + | found := false | |||
| 46 | + | for _, name := range r.names { | |||
| 47 | + | found = found || name == p.Name | |||
| 48 | + | } | |||
| 49 | + | if !found { | |||
| 50 | + | r.names = append(r.names, p.Name) | |||
| 51 | + | } | |||
| 52 | + | return nil | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (r *snapshotAcceptanceRepository) List(ctx context.Context) ([]string, error) { | |||
| 56 | + | if err := ctx.Err(); err != nil { | |||
| 57 | + | return nil, err | |||
| 58 | + | } | |||
| 59 | + | r.mu.Lock() | |||
| 60 | + | defer r.mu.Unlock() | |||
| 61 | + | if r.listErr != nil { | |||
| 62 | + | return nil, r.listErr | |||
| 63 | + | } | |||
| 64 | + | return append([]string(nil), r.names...), nil | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (r *snapshotAcceptanceRepository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) { | |||
| 68 | + | if err := ctx.Err(); err != nil { | |||
| 69 | + | return ports.SnapshotGeneration{}, err | |||
| 70 | + | } | |||
| 71 | + | r.mu.Lock() | |||
| 72 | + | defer r.mu.Unlock() | |||
| 73 | + | if r.loadErr != nil { | |||
| 74 | + | return ports.SnapshotGeneration{}, r.loadErr | |||
| 75 | + | } | |||
| 76 | + | generation, ok := r.generations[name] | |||
| 77 | + | if !ok { | |||
| 78 | + | return ports.SnapshotGeneration{}, errors.New("missing generation") | |||
| 79 | + | } | |||
| 80 | + | if r.loadMutate != nil && len(r.publishes) > 0 { | |||
| 81 | + | generation = r.loadMutate(generation, r.publishes[len(r.publishes)-1]) | |||
| 82 | + | } | |||
| 83 | + | return cloneAcceptanceGeneration(generation), nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (*snapshotAcceptanceRepository) Delete(context.Context, string) error { return nil } | |||
| 87 | + | func (*snapshotAcceptanceRepository) Maintain(context.Context) error { return nil } | |||
| 88 | + | ||||
| 89 | + | func cloneAcceptanceGeneration(g ports.SnapshotGeneration) ports.SnapshotGeneration { | |||
| 90 | + | out := g | |||
| 91 | + | out.Manifest = append([]byte(nil), g.Manifest...) | |||
| 92 | + | out.Objects = make(map[ports.SnapshotDigest][]byte, len(g.Objects)) | |||
| 93 | + | for digest, data := range g.Objects { | |||
| 94 | + | out.Objects[digest] = append([]byte(nil), data...) | |||
| 95 | + | } | |||
| 96 | + | return out | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session { | |||
| 100 | + | t.Helper() | |||
| 101 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 8 * 293, ChunkRows: 2}) | |||
| 102 | + | for _, ch := range []rune{'a', 'b', 'c', 'd', 'e'} { | |||
| 103 | + | row := make([]renderer.Cell, 293) | |||
| 104 | + | for i := range row { | |||
| 105 | + | row[i] = renderer.Cell{Rune: ch} | |||
| 106 | + | } | |||
| 107 | + | require.NoError(t, history.Append(row)) | |||
| 108 | + | } | |||
| 109 | + | view := history.SnapshotView() | |||
| 110 | + | sealed := make([][]byte, view.ChunkCount()) | |||
| 111 | + | for i := range sealed { | |||
| 112 | + | var err error | |||
| 113 | + | sealed[i], err = vt.MarshalHistoryChunk(view.Chunk(i)) | |||
| 114 | + | require.NoError(t, err) | |||
| 115 | + | } | |||
| 116 | + | tail, err := vt.MarshalHistoryTail(view) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | ||||
| 119 | + | screen := vt.NewScreen(293, 4) | |||
| 120 | + | screen.Write([]byte("primary-visible")) | |||
| 121 | + | screen.Write([]byte("\x1b[?1049h")) | |||
| 122 | + | screen.Write([]byte("alternate-active")) | |||
| 123 | + | visible, err := screen.MarshalPrimaryVisible() | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | ||||
| 126 | + | paneID := layout.PaneID("pane-7") | |||
| 127 | + | return snapcodec.Session{ | |||
| 128 | + | Name: name, CreatedAt: 77, Active: 0, | |||
| 129 | + | Tabs: []snapcodec.Tab{{ | |||
| 130 | + | StableID: "tab-stable", Cols: 293, Rows: 4, NextPaneID: 8, Focus: paneID, | |||
| 131 | + | Tree: &layout.Tree{Root: layout.NewLeaf(paneID), Focus: paneID}, | |||
| 132 | + | Panes: []snapcodec.Pane{{ | |||
| 133 | + | ID: paneID, StableID: "pane-stable", Cwd: "/snapshot/cwd", SealedChunks: sealed, Tail: tail, Visible: visible, | |||
| 134 | + | Process: &snapcodec.Process{Argv: []string{"sleep", "99"}, Strategy: "argv"}, | |||
| 135 | + | }}, | |||
| 136 | + | }}, | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation uint64) ports.SnapshotGeneration { | |||
| 141 | + | t.Helper() | |||
| 142 | + | publication, err := legacyPublication(snapshot) | |||
| 143 | + | require.NoError(t, err) | |||
| 144 | + | manifest, err := snapcodec.UnmarshalManifest(publication.Manifest) | |||
| 145 | + | require.NoError(t, err) | |||
| 146 | + | manifest.Generation = generation | |||
| 147 | + | encoded, err := snapcodec.MarshalManifest(manifest) | |||
| 148 | + | require.NoError(t, err) | |||
| 149 | + | objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |||
| 150 | + | for _, object := range publication.Objects { | |||
| 151 | + | objects[object.Digest] = append([]byte(nil), object.Data...) | |||
| 152 | + | } | |||
| 153 | + | return ports.SnapshotGeneration{Name: snapshot.Name, Generation: generation, Manifest: encoded, Objects: objects} | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||
| 157 | + | snapshot := restoreAcceptanceSession(t, "restored") | |||
| 158 | + | generation := acceptanceGeneration(t, snapshot, 9) | |||
| 159 | + | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}} | |||
| 160 | + | pty, release := newBlockingPTY(t) | |||
| 161 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 162 | + | store, _ := newMockStore(t) | |||
| 163 | + | WithStore(store)(d) | |||
| 164 | + | WithSnapshotRepository(repository, nil)(d) | |||
| 165 | + | t.Cleanup(func() { release(); d.sessWg.Wait() }) | |||
| 166 | + | ||||
| 167 | + | d.restoreIncrementalSnapshots(context.Background()) | |||
| 168 | + | ||||
| 169 | + | d.mu.Lock() | |||
| 170 | + | restored := d.findByNameLocked(snapshot.Name) | |||
| 171 | + | d.mu.Unlock() | |||
| 172 | + | require.NotNil(t, restored) | |||
| 173 | + | require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean") | |||
| 174 | + | require.Equal(t, 0, restored.active) | |||
| 175 | + | require.Equal(t, "/snapshot/cwd", restored.cwd) | |||
| 176 | + | require.Len(t, restored.tabs, 1) | |||
| 177 | + | tab := restored.tabs[0] | |||
| 178 | + | require.Equal(t, 293, tab.size.Cols) | |||
| 179 | + | require.Equal(t, layout.PaneID("pane-7"), tab.tree.Focus) | |||
| 180 | + | pane := tab.panes[layout.PaneID("pane-7")] | |||
| 181 | + | pane.mu.Lock() | |||
| 182 | + | require.Equal(t, "primary-visible", string([]rune{pane.screen.Frame.Row(0)[0].Rune, pane.screen.Frame.Row(0)[1].Rune, pane.screen.Frame.Row(0)[2].Rune, pane.screen.Frame.Row(0)[3].Rune, pane.screen.Frame.Row(0)[4].Rune, pane.screen.Frame.Row(0)[5].Rune, pane.screen.Frame.Row(0)[6].Rune, pane.screen.Frame.Row(0)[7].Rune, pane.screen.Frame.Row(0)[8].Rune, pane.screen.Frame.Row(0)[9].Rune, pane.screen.Frame.Row(0)[10].Rune, pane.screen.Frame.Row(0)[11].Rune, pane.screen.Frame.Row(0)[12].Rune, pane.screen.Frame.Row(0)[13].Rune, pane.screen.Frame.Row(0)[14].Rune})) | |||
| 183 | + | require.Equal(t, 5, pane.history.Len()) | |||
| 184 | + | require.Equal(t, 5*293, pane.history.Cells()) | |||
| 185 | + | require.Equal(t, defaultScrollbackRows, pane.history.Cap()) | |||
| 186 | + | require.Equal(t, defaultScrollbackCells, pane.history.CellCap()) | |||
| 187 | + | pane.mu.Unlock() | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) { | |||
| 191 | + | snapshot := restoreAcceptanceSession(t, "fallback") | |||
| 192 | + | valid := acceptanceGeneration(t, snapshot, 3) | |||
| 193 | + | valid.Fallback = true | |||
| 194 | + | pty, release := newBlockingPTY(t) | |||
| 195 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 196 | + | store, _ := newMockStore(t) | |||
| 197 | + | WithStore(store)(d) | |||
| 198 | + | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: valid}} | |||
| 199 | + | WithSnapshotRepository(repository, nil)(d) | |||
| 200 | + | t.Cleanup(func() { release(); d.sessWg.Wait() }) | |||
| 201 | + | d.restoreIncrementalSnapshots(context.Background()) | |||
| 202 | + | require.Len(t, d.notices.history(), 1) | |||
| 203 | + | require.Equal(t, domain.NoticeWarn, d.notices.history()[0].Severity) | |||
| 204 | + | ||||
| 205 | + | for _, mutate := range []struct { | |||
| 206 | + | name string | |||
| 207 | + | fn func(*ports.SnapshotGeneration) | |||
| 208 | + | }{ | |||
| 209 | + | {"missing", func(g *ports.SnapshotGeneration) { | |||
| 210 | + | for digest := range g.Objects { | |||
| 211 | + | delete(g.Objects, digest) | |||
| 212 | + | break | |||
| 213 | + | } | |||
| 214 | + | }}, | |||
| 215 | + | {"wrong kind", func(g *ports.SnapshotGeneration) { | |||
| 216 | + | for digest, data := range g.Objects { | |||
| 217 | + | kind, payload, err := snapcodec.UnmarshalObject(data) | |||
| 218 | + | require.NoError(t, err) | |||
| 219 | + | if kind == snapcodec.HistoryTail { | |||
| 220 | + | replacement, err := snapcodec.MarshalObject(snapcodec.Visible, payload) | |||
| 221 | + | require.NoError(t, err) | |||
| 222 | + | delete(g.Objects, digest) | |||
| 223 | + | g.Objects[digest] = replacement.Data | |||
| 224 | + | break | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | }}, | |||
| 228 | + | {"wrong digest", func(g *ports.SnapshotGeneration) { | |||
| 229 | + | for digest, data := range g.Objects { | |||
| 230 | + | kind, payload, err := snapcodec.UnmarshalObject(data) | |||
| 231 | + | require.NoError(t, err) | |||
| 232 | + | payload = append([]byte(nil), payload...) | |||
| 233 | + | payload[len(payload)-1] ^= 1 | |||
| 234 | + | replacement, err := snapcodec.MarshalObject(kind, payload) | |||
| 235 | + | require.NoError(t, err) | |||
| 236 | + | require.Equal(t, len(data), len(replacement.Data)) | |||
| 237 | + | g.Objects[digest] = replacement.Data | |||
| 238 | + | break | |||
| 239 | + | } | |||
| 240 | + | }}, | |||
| 241 | + | } { | |||
| 242 | + | t.Run(mutate.name, func(t *testing.T) { | |||
| 243 | + | generation := cloneAcceptanceGeneration(valid) | |||
| 244 | + | generation.Fallback = false | |||
| 245 | + | mutate.fn(&generation) | |||
| 246 | + | _, err := sessionFromGeneration(generation) | |||
| 247 | + | require.Error(t, err) | |||
| 248 | + | }) | |||
| 249 | + | } | |||
| 250 | + | } | |||
M internal/usecase/snapshot/preflight.go +4 -1
| @@ -214,7 +214,10 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin | |||
| 214 | 214 | if err != nil { | |
| 215 | 215 | return err | |
| 216 | 216 | } | |
| 217 | - | if tail.Chunks != 0 || tail.Rows != 0 || tail.Cells != 0 { | |
| 217 | + | // A capture keeps the live mutable tail separate from immutable sealed | |
| 218 | + | // chunks. It may therefore contain one partial chunk (or the canonical | |
| 219 | + | // empty tail), but never a sequence of sealed chunks. | |
| 220 | + | if tail.Chunks > 1 { | |
| 218 | 221 | return fmt.Errorf("%w: tail blob role", ErrInvalidData) | |
| 219 | 222 | } | |
| 220 | 223 | visible, err := preflightBlob(r, totals, blobs, false, budget) | |
| @@ -214,7 +214,10 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin | |||||
| 214 | if err != nil { | 214 | if err != nil { | ||
| 215 | return err | 215 | return err | ||
| 216 | } | 216 | } | ||
| 217 | - | if tail.Chunks != 0 || tail.Rows != 0 || tail.Cells != 0 { | 217 | + | // A capture keeps the live mutable tail separate from immutable sealed |
| 218 | + | // chunks. It may therefore contain one partial chunk (or the canonical | |||
| 219 | + | // empty tail), but never a sequence of sealed chunks. | |||
| 220 | + | if tail.Chunks > 1 { | |||
| 218 | return fmt.Errorf("%w: tail blob role", ErrInvalidData) | 221 | return fmt.Errorf("%w: tail blob role", ErrInvalidData) | ||
| 219 | } | 222 | } | ||
| 220 | visible, err := preflightBlob(r, totals, blobs, false, budget) | 223 | visible, err := preflightBlob(r, totals, blobs, false, budget) | ||