detached 23c31e3

refactor(persist): replace migration with fail-closed catalogue open

commit
23c31e39a646a575630ae8b1e200a7478edcf254
author
brice · · 2026-07-26 10:27 UTC
parent
2b0e9f7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +135 -1819
D internal/adapters/snapshot/migration_v0.go +0 -296
@@ -1,296 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "crypto/sha256"
6- "encoding/binary"
7- "encoding/hex"
8- "errors"
9- "fmt"
10- "hash/crc32"
11- "io"
12- "os"
13- "path/filepath"
14- "strings"
15-
16- "github.com/bnema/vev/internal/domain"
17- "github.com/bnema/vev/internal/ports"
18- codec "github.com/bnema/vev/internal/usecase/snapshot"
19-)
20-
21-var ErrLegacyTraversalBudgetExceeded = errors.New("snapshot: legacy presence traversal budget exceeded")
22-
23-const (
24- legacyPresenceEntryLimit = 4096
25- legacyIncrementalSessionsDir = "sessions"
26- legacyManifestHeaderSize = 16
27-)
28-
29-type legacyManifestV1 struct {
30- Name string
31- Generation uint64
32- Objects []legacyObjectV1
33-}
34-
35-type legacyObjectV1 struct {
36- Kind uint8
37- Digest ports.SnapshotDigest
38- Size uint64
39-}
40-
41-func isLegacyIncrementalSessionNamespace(name string) bool { return canonicalSessionKey(name) }
42-
43-func decodeManifestV1(data []byte) (legacyManifestV1, error) {
44- manifest, _, err := upgradeManifestV1(data, domain.IncarnationID{1})
45- if err != nil {
46- return legacyManifestV1{}, err
47- }
48- return legacyObjectsOf(manifest), nil
49-}
50-
51-func legacyObjectsOf(manifest codec.Manifest) legacyManifestV1 {
52- seen := make(map[ports.SnapshotDigest]struct{})
53- objects := make([]legacyObjectV1, 0)
54- add := func(ref codec.ObjectRef) {
55- if _, ok := seen[ref.Digest]; ok {
56- return
57- }
58- seen[ref.Digest] = struct{}{}
59- objects = append(objects, legacyObjectV1{Kind: uint8(ref.Kind), Digest: ref.Digest, Size: uint64(ref.Size)})
60- }
61- for _, tab := range manifest.Tabs {
62- for _, pane := range tab.Panes {
63- for _, ref := range pane.Sealed {
64- add(ref)
65- }
66- add(pane.Tail)
67- add(pane.Visible)
68- }
69- }
70- return legacyManifestV1{Name: manifest.Name, Generation: manifest.Generation, Objects: objects}
71-}
72-
73-func upgradeManifestV1(data []byte, id domain.IncarnationID) (codec.Manifest, []byte, error) {
74- if len(data) < legacyManifestHeaderSize {
75- return codec.Manifest{}, nil, errors.New("snapshot: short legacy manifest")
76- }
77- if string(data[:4]) != "VEVM" || binary.BigEndian.Uint16(data[4:6]) != 1 || binary.BigEndian.Uint16(data[6:8]) != 0 {
78- return codec.Manifest{}, nil, errors.New("snapshot: invalid legacy manifest header")
79- }
80- n := binary.BigEndian.Uint32(data[8:12])
81- if uint64(len(data)) != legacyManifestHeaderSize+uint64(n) {
82- return codec.Manifest{}, nil, errors.New("snapshot: invalid legacy manifest length")
83- }
84- body := data[legacyManifestHeaderSize:]
85- if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(data[12:16]) || len(body) < 8 {
86- return codec.Manifest{}, nil, errors.New("snapshot: invalid legacy manifest checksum")
87- }
88- upgradedBody := make([]byte, 0, len(body)+17)
89- upgradedBody = append(upgradedBody, body[:8]...)
90- upgradedBody = append(upgradedBody, id[:]...)
91- upgradedBody = append(upgradedBody, 0)
92- upgradedBody = append(upgradedBody, body[8:]...)
93- upgraded := make([]byte, legacyManifestHeaderSize, legacyManifestHeaderSize+len(upgradedBody))
94- copy(upgraded, "VEVM")
95- binary.BigEndian.PutUint16(upgraded[4:6], codec.ManifestVersion)
96- binary.BigEndian.PutUint32(upgraded[8:12], uint32(len(upgradedBody)))
97- binary.BigEndian.PutUint32(upgraded[12:16], crc32.ChecksumIEEE(upgradedBody))
98- upgraded = append(upgraded, upgradedBody...)
99- manifest, err := codec.UnmarshalManifest(upgraded)
100- if err != nil {
101- return codec.Manifest{}, nil, fmt.Errorf("snapshot: decode legacy manifest: %w", err)
102- }
103- return manifest, upgraded, nil
104-}
105-
106-func (r *Repository) HasLegacyState(ctx context.Context) (found bool, err error) {
107- visited := 0
108- visit := func(dir string, recognize func(os.DirEntry, os.FileInfo) bool) (found bool, err error) {
109- f, err := r.openLegacyDirectory(dir)
110- if errors.Is(err, os.ErrNotExist) {
111- return false, nil
112- }
113- if err != nil {
114- return false, err
115- }
116- defer func() {
117- if closeErr := f.Close(); err == nil && closeErr != nil {
118- err = closeErr
119- }
120- }()
121- for {
122- if err := ctx.Err(); err != nil {
123- return false, err
124- }
125- entries, readErr := f.ReadDir(directoryTraversalBatch)
126- for _, entry := range entries {
127- if visited >= legacyPresenceEntryLimit {
128- return false, ErrLegacyTraversalBudgetExceeded
129- }
130- visited++
131- info, infoErr := entry.Info()
132- if infoErr != nil {
133- return false, infoErr
134- }
135- if recognize(entry, info) {
136- return true, nil
137- }
138- }
139- if errors.Is(readErr, io.EOF) {
140- return false, nil
141- }
142- if readErr != nil {
143- return false, readErr
144- }
145- }
146- }
147- found, err = visit(r.dir, func(e os.DirEntry, info os.FileInfo) bool {
148- return info.Mode().IsRegular() && isLegacyBlobName(e.Name())
149- })
150- if found || err != nil {
151- return found, err
152- }
153- return visit(filepath.Join(r.dir, legacyIncrementalSessionsDir), func(e os.DirEntry, info os.FileInfo) bool {
154- return info.IsDir() && isLegacyIncrementalSessionNamespace(e.Name())
155- })
156-}
157-
158-func isLegacyBlobName(name string) bool {
159- if !strings.HasSuffix(name, ".snap") {
160- return false
161- }
162- key := strings.TrimSuffix(name, ".snap")
163- if safeNameRE.MatchString(key) && key != "." && key != ".." {
164- return true
165- }
166- if len(key) != 41 || key[0] != '@' || strings.ToLower(key) != key {
167- return false
168- }
169- _, err := hex.DecodeString(key[1:])
170- return err == nil
171-}
172-
173-func (r *Repository) ReadLegacyHEAD(ctx context.Context, name string) (domain.CheckpointRef, error) {
174- if err := ctx.Err(); err != nil {
175- return domain.CheckpointRef{}, err
176- }
177- key := sessionKey(name)
178- generation, digest, err := r.readLegacyHead(key)
179- if err != nil {
180- if errors.Is(err, os.ErrNotExist) || errors.Is(err, ErrInvalidHEAD) {
181- return domain.CheckpointRef{}, uncertainLegacyError("read HEAD", err)
182- }
183- return domain.CheckpointRef{}, fmt.Errorf("snapshot: read legacy HEAD: %w", err)
184- }
185- data, err := r.readBounded(r.legacyManifestPath(key, generation))
186- if err != nil {
187- if errors.Is(err, os.ErrNotExist) {
188- return domain.CheckpointRef{}, uncertainLegacyError("read referenced manifest", err)
189- }
190- return domain.CheckpointRef{}, err
191- }
192- if sha256.Sum256(data) != digest {
193- return domain.CheckpointRef{}, uncertainLegacyError("HEAD digest mismatch", nil)
194- }
195- legacy, err := decodeManifestV1(data)
196- if err != nil {
197- return domain.CheckpointRef{}, uncertainLegacyError("decode referenced manifest", err)
198- }
199- if legacy.Name != name || legacy.Generation != generation {
200- return domain.CheckpointRef{}, uncertainLegacyError("manifest identity mismatch", nil)
201- }
202- return domain.CheckpointRef{Generation: generation, ManifestDigest: digest}, nil
203-}
204-
205-func uncertainLegacyError(operation string, cause error) error {
206- if cause == nil {
207- return fmt.Errorf("%w: %s", ports.ErrLegacySnapshotUncertain, operation)
208- }
209- return fmt.Errorf("%w: %s: %w", ports.ErrLegacySnapshotUncertain, operation, cause)
210-}
211-
212-// MigrateV1Checkpoint is the migration-only admission path for an authoritative
213-// legacy checkpoint whose v1 format has no parent link. Runtime snapshot
214-// publication must use Publish and cannot bypass its parent-chain validation.
215-func (r *Repository) MigrateV1Checkpoint(ctx context.Context, req ports.SnapshotMigrationRequest) (domain.CheckpointRef, error) {
216- if err := ctx.Err(); err != nil {
217- return domain.CheckpointRef{}, err
218- }
219- if req.IncarnationID == (domain.IncarnationID{}) || req.LegacyRef.Generation == 0 {
220- return domain.CheckpointRef{}, errors.New("snapshot: invalid migration request")
221- }
222- legacyKey := sessionKey(req.LegacyName)
223- data, err := r.readBounded(r.legacyManifestPath(legacyKey, req.LegacyRef.Generation))
224- if err != nil {
225- if errors.Is(err, os.ErrNotExist) {
226- return domain.CheckpointRef{}, uncertainLegacyError("referenced manifest missing", err)
227- }
228- return domain.CheckpointRef{}, err
229- }
230- if sha256.Sum256(data) != req.LegacyRef.ManifestDigest {
231- return domain.CheckpointRef{}, uncertainLegacyError("manifest digest mismatch", nil)
232- }
233- manifest, upgraded, err := upgradeManifestV1(data, req.IncarnationID)
234- if err != nil {
235- return domain.CheckpointRef{}, uncertainLegacyError("decode manifest", err)
236- }
237- legacy := legacyObjectsOf(manifest)
238- if manifest.Name != req.LegacyName || manifest.Generation != req.LegacyRef.Generation {
239- return domain.CheckpointRef{}, uncertainLegacyError("manifest identity mismatch", nil)
240- }
241- key, err := incarnationKey(req.IncarnationID)
242- if err != nil {
243- return domain.CheckpointRef{}, err
244- }
245- lock := r.lockSession(key)
246- defer r.unlockSession(lock)
247- if err := r.ensureSession(req.IncarnationID); err != nil {
248- return domain.CheckpointRef{}, err
249- }
250- for _, ref := range legacy.Objects {
251- if err := ctx.Err(); err != nil {
252- return domain.CheckpointRef{}, err
253- }
254- object, err := r.readBounded(r.legacyObjectPath(legacyKey, ref.Digest))
255- if err != nil {
256- if errors.Is(err, os.ErrNotExist) {
257- return domain.CheckpointRef{}, uncertainLegacyError("referenced object missing", err)
258- }
259- return domain.CheckpointRef{}, err
260- }
261- if sha256.Sum256(object) != ref.Digest || uint64(len(object)) != ref.Size {
262- return domain.CheckpointRef{}, uncertainLegacyError("invalid object", nil)
263- }
264- kind, _, err := codec.UnmarshalObject(object)
265- if err != nil {
266- return domain.CheckpointRef{}, uncertainLegacyError("decode referenced object", err)
267- }
268- if uint8(kind) != ref.Kind {
269- return domain.CheckpointRef{}, uncertainLegacyError("referenced object kind mismatch", nil)
270- }
271- if err := r.writeImmutable(r.objectPath(req.IncarnationID, ref.Digest), object, func(existing []byte) error {
272- if sha256.Sum256(existing) != ref.Digest {
273- return uncertainLegacyError("migrated object conflict", nil)
274- }
275- return nil
276- }); err != nil {
277- return domain.CheckpointRef{}, err
278- }
279- }
280- path := r.manifestPath(req.IncarnationID, manifest.Generation)
281- if err := r.writeImmutable(path, upgraded, func(existing []byte) error {
282- if sha256.Sum256(existing) != sha256.Sum256(upgraded) {
283- return uncertainLegacyError("migrated manifest conflict", nil)
284- }
285- return nil
286- }); err != nil {
287- return domain.CheckpointRef{}, err
288- }
289- ref := domain.CheckpointRef{Generation: manifest.Generation, ManifestDigest: sha256.Sum256(upgraded)}
290- if err := r.writeMutable(r.headPath(req.IncarnationID), marshalHead(ref.Generation, ref.ManifestDigest)); err != nil {
291- return domain.CheckpointRef{}, err
292- }
293- return ref, nil
294-}
295-
296-var _ ports.SnapshotMigration = (*Repository)(nil)
@@ -1,296 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "crypto/sha256"
6- "encoding/binary"
7- "encoding/hex"
8- "errors"
9- "fmt"
10- "hash/crc32"
11- "io"
12- "os"
13- "path/filepath"
14- "strings"
15-
16- "github.com/bnema/vev/internal/domain"
17- "github.com/bnema/vev/internal/ports"
18- codec "github.com/bnema/vev/internal/usecase/snapshot"
19-)
20-
21-var ErrLegacyTraversalBudgetExceeded = errors.New("snapshot: legacy presence traversal budget exceeded")
22-
23-const (
24- legacyPresenceEntryLimit = 4096
25- legacyIncrementalSessionsDir = "sessions"
26- legacyManifestHeaderSize = 16
27-)
28-
29-type legacyManifestV1 struct {
30- Name string
31- Generation uint64
32- Objects []legacyObjectV1
33-}
34-
35-type legacyObjectV1 struct {
36- Kind uint8
37- Digest ports.SnapshotDigest
38- Size uint64
39-}
40-
41-func isLegacyIncrementalSessionNamespace(name string) bool { return canonicalSessionKey(name) }
42-
43-func decodeManifestV1(data []byte) (legacyManifestV1, error) {
44- manifest, _, err := upgradeManifestV1(data, domain.IncarnationID{1})
45- if err != nil {
46- return legacyManifestV1{}, err
47- }
48- return legacyObjectsOf(manifest), nil
49-}
50-
51-func legacyObjectsOf(manifest codec.Manifest) legacyManifestV1 {
52- seen := make(map[ports.SnapshotDigest]struct{})
53- objects := make([]legacyObjectV1, 0)
54- add := func(ref codec.ObjectRef) {
55- if _, ok := seen[ref.Digest]; ok {
56- return
57- }
58- seen[ref.Digest] = struct{}{}
59- objects = append(objects, legacyObjectV1{Kind: uint8(ref.Kind), Digest: ref.Digest, Size: uint64(ref.Size)})
60- }
61- for _, tab := range manifest.Tabs {
62- for _, pane := range tab.Panes {
63- for _, ref := range pane.Sealed {
64- add(ref)
65- }
66- add(pane.Tail)
67- add(pane.Visible)
68- }
69- }
70- return legacyManifestV1{Name: manifest.Name, Generation: manifest.Generation, Objects: objects}
71-}
72-
73-func upgradeManifestV1(data []byte, id domain.IncarnationID) (codec.Manifest, []byte, error) {
74- if len(data) < legacyManifestHeaderSize {
75- return codec.Manifest{}, nil, errors.New("snapshot: short legacy manifest")
76- }
77- if string(data[:4]) != "VEVM" || binary.BigEndian.Uint16(data[4:6]) != 1 || binary.BigEndian.Uint16(data[6:8]) != 0 {
78- return codec.Manifest{}, nil, errors.New("snapshot: invalid legacy manifest header")
79- }
80- n := binary.BigEndian.Uint32(data[8:12])
81- if uint64(len(data)) != legacyManifestHeaderSize+uint64(n) {
82- return codec.Manifest{}, nil, errors.New("snapshot: invalid legacy manifest length")
83- }
84- body := data[legacyManifestHeaderSize:]
85- if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(data[12:16]) || len(body) < 8 {
86- return codec.Manifest{}, nil, errors.New("snapshot: invalid legacy manifest checksum")
87- }
88- upgradedBody := make([]byte, 0, len(body)+17)
89- upgradedBody = append(upgradedBody, body[:8]...)
90- upgradedBody = append(upgradedBody, id[:]...)
91- upgradedBody = append(upgradedBody, 0)
92- upgradedBody = append(upgradedBody, body[8:]...)
93- upgraded := make([]byte, legacyManifestHeaderSize, legacyManifestHeaderSize+len(upgradedBody))
94- copy(upgraded, "VEVM")
95- binary.BigEndian.PutUint16(upgraded[4:6], codec.ManifestVersion)
96- binary.BigEndian.PutUint32(upgraded[8:12], uint32(len(upgradedBody)))
97- binary.BigEndian.PutUint32(upgraded[12:16], crc32.ChecksumIEEE(upgradedBody))
98- upgraded = append(upgraded, upgradedBody...)
99- manifest, err := codec.UnmarshalManifest(upgraded)
100- if err != nil {
101- return codec.Manifest{}, nil, fmt.Errorf("snapshot: decode legacy manifest: %w", err)
102- }
103- return manifest, upgraded, nil
104-}
105-
106-func (r *Repository) HasLegacyState(ctx context.Context) (found bool, err error) {
107- visited := 0
108- visit := func(dir string, recognize func(os.DirEntry, os.FileInfo) bool) (found bool, err error) {
109- f, err := r.openLegacyDirectory(dir)
110- if errors.Is(err, os.ErrNotExist) {
111- return false, nil
112- }
113- if err != nil {
114- return false, err
115- }
116- defer func() {
117- if closeErr := f.Close(); err == nil && closeErr != nil {
118- err = closeErr
119- }
120- }()
121- for {
122- if err := ctx.Err(); err != nil {
123- return false, err
124- }
125- entries, readErr := f.ReadDir(directoryTraversalBatch)
126- for _, entry := range entries {
127- if visited >= legacyPresenceEntryLimit {
128- return false, ErrLegacyTraversalBudgetExceeded
129- }
130- visited++
131- info, infoErr := entry.Info()
132- if infoErr != nil {
133- return false, infoErr
134- }
135- if recognize(entry, info) {
136- return true, nil
137- }
138- }
139- if errors.Is(readErr, io.EOF) {
140- return false, nil
141- }
142- if readErr != nil {
143- return false, readErr
144- }
145- }
146- }
147- found, err = visit(r.dir, func(e os.DirEntry, info os.FileInfo) bool {
148- return info.Mode().IsRegular() && isLegacyBlobName(e.Name())
149- })
150- if found || err != nil {
151- return found, err
152- }
153- return visit(filepath.Join(r.dir, legacyIncrementalSessionsDir), func(e os.DirEntry, info os.FileInfo) bool {
154- return info.IsDir() && isLegacyIncrementalSessionNamespace(e.Name())
155- })
156-}
157-
158-func isLegacyBlobName(name string) bool {
159- if !strings.HasSuffix(name, ".snap") {
160- return false
161- }
162- key := strings.TrimSuffix(name, ".snap")
163- if safeNameRE.MatchString(key) && key != "." && key != ".." {
164- return true
165- }
166- if len(key) != 41 || key[0] != '@' || strings.ToLower(key) != key {
167- return false
168- }
169- _, err := hex.DecodeString(key[1:])
170- return err == nil
171-}
172-
173-func (r *Repository) ReadLegacyHEAD(ctx context.Context, name string) (domain.CheckpointRef, error) {
174- if err := ctx.Err(); err != nil {
175- return domain.CheckpointRef{}, err
176- }
177- key := sessionKey(name)
178- generation, digest, err := r.readLegacyHead(key)
179- if err != nil {
180- if errors.Is(err, os.ErrNotExist) || errors.Is(err, ErrInvalidHEAD) {
181- return domain.CheckpointRef{}, uncertainLegacyError("read HEAD", err)
182- }
183- return domain.CheckpointRef{}, fmt.Errorf("snapshot: read legacy HEAD: %w", err)
184- }
185- data, err := r.readBounded(r.legacyManifestPath(key, generation))
186- if err != nil {
187- if errors.Is(err, os.ErrNotExist) {
188- return domain.CheckpointRef{}, uncertainLegacyError("read referenced manifest", err)
189- }
190- return domain.CheckpointRef{}, err
191- }
192- if sha256.Sum256(data) != digest {
193- return domain.CheckpointRef{}, uncertainLegacyError("HEAD digest mismatch", nil)
194- }
195- legacy, err := decodeManifestV1(data)
196- if err != nil {
197- return domain.CheckpointRef{}, uncertainLegacyError("decode referenced manifest", err)
198- }
199- if legacy.Name != name || legacy.Generation != generation {
200- return domain.CheckpointRef{}, uncertainLegacyError("manifest identity mismatch", nil)
201- }
202- return domain.CheckpointRef{Generation: generation, ManifestDigest: digest}, nil
203-}
204-
205-func uncertainLegacyError(operation string, cause error) error {
206- if cause == nil {
207- return fmt.Errorf("%w: %s", ports.ErrLegacySnapshotUncertain, operation)
208- }
209- return fmt.Errorf("%w: %s: %w", ports.ErrLegacySnapshotUncertain, operation, cause)
210-}
211-
212-// MigrateV1Checkpoint is the migration-only admission path for an authoritative
213-// legacy checkpoint whose v1 format has no parent link. Runtime snapshot
214-// publication must use Publish and cannot bypass its parent-chain validation.
215-func (r *Repository) MigrateV1Checkpoint(ctx context.Context, req ports.SnapshotMigrationRequest) (domain.CheckpointRef, error) {
216- if err := ctx.Err(); err != nil {
217- return domain.CheckpointRef{}, err
218- }
219- if req.IncarnationID == (domain.IncarnationID{}) || req.LegacyRef.Generation == 0 {
220- return domain.CheckpointRef{}, errors.New("snapshot: invalid migration request")
221- }
222- legacyKey := sessionKey(req.LegacyName)
223- data, err := r.readBounded(r.legacyManifestPath(legacyKey, req.LegacyRef.Generation))
224- if err != nil {
225- if errors.Is(err, os.ErrNotExist) {
226- return domain.CheckpointRef{}, uncertainLegacyError("referenced manifest missing", err)
227- }
228- return domain.CheckpointRef{}, err
229- }
230- if sha256.Sum256(data) != req.LegacyRef.ManifestDigest {
231- return domain.CheckpointRef{}, uncertainLegacyError("manifest digest mismatch", nil)
232- }
233- manifest, upgraded, err := upgradeManifestV1(data, req.IncarnationID)
234- if err != nil {
235- return domain.CheckpointRef{}, uncertainLegacyError("decode manifest", err)
236- }
237- legacy := legacyObjectsOf(manifest)
238- if manifest.Name != req.LegacyName || manifest.Generation != req.LegacyRef.Generation {
239- return domain.CheckpointRef{}, uncertainLegacyError("manifest identity mismatch", nil)
240- }
241- key, err := incarnationKey(req.IncarnationID)
242- if err != nil {
243- return domain.CheckpointRef{}, err
244- }
245- lock := r.lockSession(key)
246- defer r.unlockSession(lock)
247- if err := r.ensureSession(req.IncarnationID); err != nil {
248- return domain.CheckpointRef{}, err
249- }
250- for _, ref := range legacy.Objects {
251- if err := ctx.Err(); err != nil {
252- return domain.CheckpointRef{}, err
253- }
254- object, err := r.readBounded(r.legacyObjectPath(legacyKey, ref.Digest))
255- if err != nil {
256- if errors.Is(err, os.ErrNotExist) {
257- return domain.CheckpointRef{}, uncertainLegacyError("referenced object missing", err)
258- }
259- return domain.CheckpointRef{}, err
260- }
261- if sha256.Sum256(object) != ref.Digest || uint64(len(object)) != ref.Size {
262- return domain.CheckpointRef{}, uncertainLegacyError("invalid object", nil)
263- }
264- kind, _, err := codec.UnmarshalObject(object)
265- if err != nil {
266- return domain.CheckpointRef{}, uncertainLegacyError("decode referenced object", err)
267- }
268- if uint8(kind) != ref.Kind {
269- return domain.CheckpointRef{}, uncertainLegacyError("referenced object kind mismatch", nil)
270- }
271- if err := r.writeImmutable(r.objectPath(req.IncarnationID, ref.Digest), object, func(existing []byte) error {
272- if sha256.Sum256(existing) != ref.Digest {
273- return uncertainLegacyError("migrated object conflict", nil)
274- }
275- return nil
276- }); err != nil {
277- return domain.CheckpointRef{}, err
278- }
279- }
280- path := r.manifestPath(req.IncarnationID, manifest.Generation)
281- if err := r.writeImmutable(path, upgraded, func(existing []byte) error {
282- if sha256.Sum256(existing) != sha256.Sum256(upgraded) {
283- return uncertainLegacyError("migrated manifest conflict", nil)
284- }
285- return nil
286- }); err != nil {
287- return domain.CheckpointRef{}, err
288- }
289- ref := domain.CheckpointRef{Generation: manifest.Generation, ManifestDigest: sha256.Sum256(upgraded)}
290- if err := r.writeMutable(r.headPath(req.IncarnationID), marshalHead(ref.Generation, ref.ManifestDigest)); err != nil {
291- return domain.CheckpointRef{}, err
292- }
293- return ref, nil
294-}
295-
296-var _ ports.SnapshotMigration = (*Repository)(nil)
D internal/adapters/snapshot/migration_v0_test.go +0 -196
@@ -1,196 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "crypto/sha256"
6- "encoding/binary"
7- "errors"
8- "fmt"
9- "hash/crc32"
10- "os"
11- "path/filepath"
12- "testing"
13-
14- "github.com/bnema/vev/internal/domain"
15- "github.com/bnema/vev/internal/ports"
16- codec "github.com/bnema/vev/internal/usecase/snapshot"
17- "github.com/stretchr/testify/require"
18-)
19-
20-func legacyManifestBytes(name string, generation uint64) []byte {
21- body := binary.BigEndian.AppendUint64(nil, generation)
22- body = binary.BigEndian.AppendUint16(body, uint16(len(name)))
23- body = append(body, name...)
24- body = binary.BigEndian.AppendUint64(body, 1)
25- body = binary.BigEndian.AppendUint16(body, 0)
26- body = binary.BigEndian.AppendUint16(body, 0)
27- return legacyEnvelope(body)
28-}
29-
30-func legacyBodyFromModern(modern []byte) []byte {
31- body := append([]byte(nil), modern[legacyManifestHeaderSize:]...)
32- const generationSize = 8
33- modernOnly := generationSize + len(domain.IncarnationID{}) + 1 // incarnation and parent flag follow generation in v2.
34- return append(body[:generationSize], body[modernOnly:]...)
35-}
36-
37-func legacyEnvelope(body []byte) []byte {
38- out := make([]byte, legacyManifestHeaderSize, legacyManifestHeaderSize+len(body))
39- copy(out, "VEVM")
40- binary.BigEndian.PutUint16(out[4:6], 1)
41- binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
42- binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
43- return append(out, body...)
44-}
45-
46-func TestLegacyManifestV1(t *testing.T) {
47- encoded := legacyManifestBytes("work", 7)
48- got, err := decodeManifestV1(encoded)
49- require.NoError(t, err)
50- require.Equal(t, "work", got.Name)
51- require.Equal(t, uint64(7), got.Generation)
52- for size := range len(encoded) {
53- _, err := decodeManifestV1(encoded[:size])
54- require.Errorf(t, err, "prefix length %d", size)
55- }
56- _, err = decodeManifestV1(append(append([]byte(nil), encoded...), 0))
57- require.Error(t, err)
58-}
59-
60-func TestLegacyManifestV1DeduplicatesObjectsPreservingFirst(t *testing.T) {
61- chunk, err := codec.MarshalObject(codec.HistoryChunk, []byte("chunk"))
62- require.NoError(t, err)
63- tail, err := codec.MarshalObject(codec.HistoryTail, []byte("tail"))
64- require.NoError(t, err)
65- visible, err := codec.MarshalObject(codec.Visible, []byte("visible"))
66- require.NoError(t, err)
67- chunkRef := codec.ObjectRef{Kind: codec.HistoryChunk, Digest: chunk.Digest, Size: uint32(len(chunk.Data))}
68- manifest := codec.Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "work", Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: []codec.ObjectRef{chunkRef, chunkRef}, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}}
69- modern, err := codec.MarshalManifest(manifest)
70- require.NoError(t, err)
71- legacy := legacyEnvelope(legacyBodyFromModern(modern))
72-
73- decoded, err := decodeManifestV1(legacy)
74- require.NoError(t, err)
75- require.Len(t, decoded.Objects, 3)
76- require.Equal(t, chunk.Digest, decoded.Objects[0].Digest)
77-}
78-
79-func TestUncertainLegacyErrorWrapsSentinelAndCause(t *testing.T) {
80- cause := errors.New("legacy cause")
81- err := uncertainLegacyError("read", cause)
82- require.ErrorIs(t, err, ports.ErrLegacySnapshotUncertain)
83- require.ErrorIs(t, err, cause)
84-}
85-
86-func TestHasLegacyState(t *testing.T) {
87- for _, tc := range []struct {
88- name string
89- setup func(string)
90- want bool
91- budget bool
92- }{
93- {"absence", func(string) {}, false, false},
94- {"top level blob", func(dir string) {
95- require.NoError(t, os.WriteFile(filepath.Join(dir, "work.snap"), []byte("x"), 0o600))
96- }, true, false},
97- {"incremental child", func(dir string) { require.NoError(t, os.MkdirAll(filepath.Join(dir, "sessions", "work"), 0o700)) }, true, false},
98- {"budget exhaustion", func(dir string) {
99- for i := 0; i <= legacyPresenceEntryLimit; i++ {
100- require.NoError(t, os.WriteFile(filepath.Join(dir, fmt.Sprintf("x-%04d", i)), nil, 0o600))
101- }
102- }, false, true},
103- } {
104- t.Run(tc.name, func(t *testing.T) {
105- dir := privateDir(t)
106- require.NoError(t, NewRepository(dir).ensurePrivateDirectory(dir))
107- tc.setup(dir)
108- got, err := NewRepository(dir).HasLegacyState(context.Background())
109- if tc.budget {
110- require.ErrorIs(t, err, ErrLegacyTraversalBudgetExceeded)
111- } else {
112- require.NoError(t, err)
113- require.Equal(t, tc.want, got)
114- }
115- })
116- }
117-}
118-
119-func TestMigrateV1CheckpointValidatesObjectEnvelopeAndKind(t *testing.T) {
120- for _, tc := range []struct {
121- name string
122- object func(t *testing.T) ports.SnapshotObject
123- }{
124- {"malformed envelope", func(t *testing.T) ports.SnapshotObject {
125- object, err := codec.MarshalObject(codec.HistoryTail, []byte("payload"))
126- require.NoError(t, err)
127- object.Data[0] = 'X'
128- object.Digest = sha256.Sum256(object.Data)
129- return object
130- }},
131- {"kind mismatch", func(t *testing.T) ports.SnapshotObject {
132- object, err := codec.MarshalObject(codec.Visible, []byte("wrong kind"))
133- require.NoError(t, err)
134- return object
135- }},
136- } {
137- t.Run(tc.name, func(t *testing.T) {
138- dir := privateDir(t)
139- repo := NewRepository(dir)
140- object := tc.object(t)
141- visible, err := codec.MarshalObject(codec.Visible, []byte("visible"))
142- require.NoError(t, err)
143- manifest := codec.Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "work", Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: object.Digest, Size: uint32(len(object.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}}
144- modern, err := codec.MarshalManifest(manifest)
145- require.NoError(t, err)
146- legacyManifest := legacyEnvelope(legacyBodyFromModern(modern))
147- key := sessionKey("work")
148- for _, stored := range []ports.SnapshotObject{object, visible} {
149- require.NoError(t, os.MkdirAll(filepath.Dir(repo.legacyObjectPath(key, stored.Digest)), 0o700))
150- require.NoError(t, os.WriteFile(repo.legacyObjectPath(key, stored.Digest), stored.Data, 0o600))
151- }
152- manifestPath := repo.legacyManifestPath(key, 1)
153- require.NoError(t, os.MkdirAll(filepath.Dir(manifestPath), 0o700))
154- require.NoError(t, os.WriteFile(manifestPath, legacyManifest, 0o600))
155- legacyRef := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(legacyManifest)}
156-
157- _, err = repo.MigrateV1Checkpoint(context.Background(), ports.SnapshotMigrationRequest{LegacyName: "work", IncarnationID: domain.IncarnationID{9}, LegacyRef: legacyRef})
158- require.ErrorIs(t, err, ports.ErrLegacySnapshotUncertain)
159- require.FileExists(t, manifestPath)
160- })
161- }
162-}
163-
164-func TestMigrateV1CheckpointAcceptsLegacyNilParentBeyondGenerationOne(t *testing.T) {
165- dir := privateDir(t)
166- repo := NewRepository(dir)
167- require.NoError(t, repo.ensurePrivateDirectory(dir))
168- name := "work"
169- key := sessionKey(name)
170- generations := filepath.Join(dir, "sessions", key, "generations")
171- require.NoError(t, os.MkdirAll(generations, 0o700))
172- // Place the authoritative generation just beyond the bounded legacy scan.
173- highestGeneration := maxDirectoryTraversalEntries + 1
174- for i := 1; i < highestGeneration; i++ {
175- require.NoError(t, os.WriteFile(filepath.Join(generations, generationFilename(uint64(i))), []byte("stale"), 0o600))
176- }
177- legacy := legacyManifestBytes(name, uint64(highestGeneration))
178- legacyDigest := sha256.Sum256(legacy)
179- require.NoError(t, os.WriteFile(filepath.Join(generations, generationFilename(uint64(highestGeneration))), legacy, 0o600))
180- require.NoError(t, os.WriteFile(filepath.Join(dir, "sessions", key, "HEAD"), marshalHead(uint64(highestGeneration), legacyDigest), 0o600))
181- ref, err := repo.ReadLegacyHEAD(context.Background(), name)
182- require.NoError(t, err)
183- require.Equal(t, uint64(highestGeneration), ref.Generation)
184- id := domain.IncarnationID{1}
185- migrated, err := repo.MigrateV1Checkpoint(context.Background(), ports.SnapshotMigrationRequest{LegacyName: name, IncarnationID: id, LegacyRef: ref})
186- require.NoError(t, err)
187- got, err := repo.LoadCheckpoint(context.Background(), id, name, migrated)
188- require.NoError(t, err)
189- manifest, err := codec.UnmarshalManifest(got.Manifest)
190- require.NoError(t, err)
191- require.Equal(t, id, manifest.IncarnationID)
192- require.Nil(t, manifest.ParentCheckpoint)
193- for i := 1; i < highestGeneration; i++ {
194- require.FileExists(t, filepath.Join(generations, generationFilename(uint64(i))))
195- }
196-}
@@ -1,196 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "crypto/sha256"
6- "encoding/binary"
7- "errors"
8- "fmt"
9- "hash/crc32"
10- "os"
11- "path/filepath"
12- "testing"
13-
14- "github.com/bnema/vev/internal/domain"
15- "github.com/bnema/vev/internal/ports"
16- codec "github.com/bnema/vev/internal/usecase/snapshot"
17- "github.com/stretchr/testify/require"
18-)
19-
20-func legacyManifestBytes(name string, generation uint64) []byte {
21- body := binary.BigEndian.AppendUint64(nil, generation)
22- body = binary.BigEndian.AppendUint16(body, uint16(len(name)))
23- body = append(body, name...)
24- body = binary.BigEndian.AppendUint64(body, 1)
25- body = binary.BigEndian.AppendUint16(body, 0)
26- body = binary.BigEndian.AppendUint16(body, 0)
27- return legacyEnvelope(body)
28-}
29-
30-func legacyBodyFromModern(modern []byte) []byte {
31- body := append([]byte(nil), modern[legacyManifestHeaderSize:]...)
32- const generationSize = 8
33- modernOnly := generationSize + len(domain.IncarnationID{}) + 1 // incarnation and parent flag follow generation in v2.
34- return append(body[:generationSize], body[modernOnly:]...)
35-}
36-
37-func legacyEnvelope(body []byte) []byte {
38- out := make([]byte, legacyManifestHeaderSize, legacyManifestHeaderSize+len(body))
39- copy(out, "VEVM")
40- binary.BigEndian.PutUint16(out[4:6], 1)
41- binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
42- binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
43- return append(out, body...)
44-}
45-
46-func TestLegacyManifestV1(t *testing.T) {
47- encoded := legacyManifestBytes("work", 7)
48- got, err := decodeManifestV1(encoded)
49- require.NoError(t, err)
50- require.Equal(t, "work", got.Name)
51- require.Equal(t, uint64(7), got.Generation)
52- for size := range len(encoded) {
53- _, err := decodeManifestV1(encoded[:size])
54- require.Errorf(t, err, "prefix length %d", size)
55- }
56- _, err = decodeManifestV1(append(append([]byte(nil), encoded...), 0))
57- require.Error(t, err)
58-}
59-
60-func TestLegacyManifestV1DeduplicatesObjectsPreservingFirst(t *testing.T) {
61- chunk, err := codec.MarshalObject(codec.HistoryChunk, []byte("chunk"))
62- require.NoError(t, err)
63- tail, err := codec.MarshalObject(codec.HistoryTail, []byte("tail"))
64- require.NoError(t, err)
65- visible, err := codec.MarshalObject(codec.Visible, []byte("visible"))
66- require.NoError(t, err)
67- chunkRef := codec.ObjectRef{Kind: codec.HistoryChunk, Digest: chunk.Digest, Size: uint32(len(chunk.Data))}
68- manifest := codec.Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "work", Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: []codec.ObjectRef{chunkRef, chunkRef}, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}}
69- modern, err := codec.MarshalManifest(manifest)
70- require.NoError(t, err)
71- legacy := legacyEnvelope(legacyBodyFromModern(modern))
72-
73- decoded, err := decodeManifestV1(legacy)
74- require.NoError(t, err)
75- require.Len(t, decoded.Objects, 3)
76- require.Equal(t, chunk.Digest, decoded.Objects[0].Digest)
77-}
78-
79-func TestUncertainLegacyErrorWrapsSentinelAndCause(t *testing.T) {
80- cause := errors.New("legacy cause")
81- err := uncertainLegacyError("read", cause)
82- require.ErrorIs(t, err, ports.ErrLegacySnapshotUncertain)
83- require.ErrorIs(t, err, cause)
84-}
85-
86-func TestHasLegacyState(t *testing.T) {
87- for _, tc := range []struct {
88- name string
89- setup func(string)
90- want bool
91- budget bool
92- }{
93- {"absence", func(string) {}, false, false},
94- {"top level blob", func(dir string) {
95- require.NoError(t, os.WriteFile(filepath.Join(dir, "work.snap"), []byte("x"), 0o600))
96- }, true, false},
97- {"incremental child", func(dir string) { require.NoError(t, os.MkdirAll(filepath.Join(dir, "sessions", "work"), 0o700)) }, true, false},
98- {"budget exhaustion", func(dir string) {
99- for i := 0; i <= legacyPresenceEntryLimit; i++ {
100- require.NoError(t, os.WriteFile(filepath.Join(dir, fmt.Sprintf("x-%04d", i)), nil, 0o600))
101- }
102- }, false, true},
103- } {
104- t.Run(tc.name, func(t *testing.T) {
105- dir := privateDir(t)
106- require.NoError(t, NewRepository(dir).ensurePrivateDirectory(dir))
107- tc.setup(dir)
108- got, err := NewRepository(dir).HasLegacyState(context.Background())
109- if tc.budget {
110- require.ErrorIs(t, err, ErrLegacyTraversalBudgetExceeded)
111- } else {
112- require.NoError(t, err)
113- require.Equal(t, tc.want, got)
114- }
115- })
116- }
117-}
118-
119-func TestMigrateV1CheckpointValidatesObjectEnvelopeAndKind(t *testing.T) {
120- for _, tc := range []struct {
121- name string
122- object func(t *testing.T) ports.SnapshotObject
123- }{
124- {"malformed envelope", func(t *testing.T) ports.SnapshotObject {
125- object, err := codec.MarshalObject(codec.HistoryTail, []byte("payload"))
126- require.NoError(t, err)
127- object.Data[0] = 'X'
128- object.Digest = sha256.Sum256(object.Data)
129- return object
130- }},
131- {"kind mismatch", func(t *testing.T) ports.SnapshotObject {
132- object, err := codec.MarshalObject(codec.Visible, []byte("wrong kind"))
133- require.NoError(t, err)
134- return object
135- }},
136- } {
137- t.Run(tc.name, func(t *testing.T) {
138- dir := privateDir(t)
139- repo := NewRepository(dir)
140- object := tc.object(t)
141- visible, err := codec.MarshalObject(codec.Visible, []byte("visible"))
142- require.NoError(t, err)
143- manifest := codec.Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "work", Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: object.Digest, Size: uint32(len(object.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}}
144- modern, err := codec.MarshalManifest(manifest)
145- require.NoError(t, err)
146- legacyManifest := legacyEnvelope(legacyBodyFromModern(modern))
147- key := sessionKey("work")
148- for _, stored := range []ports.SnapshotObject{object, visible} {
149- require.NoError(t, os.MkdirAll(filepath.Dir(repo.legacyObjectPath(key, stored.Digest)), 0o700))
150- require.NoError(t, os.WriteFile(repo.legacyObjectPath(key, stored.Digest), stored.Data, 0o600))
151- }
152- manifestPath := repo.legacyManifestPath(key, 1)
153- require.NoError(t, os.MkdirAll(filepath.Dir(manifestPath), 0o700))
154- require.NoError(t, os.WriteFile(manifestPath, legacyManifest, 0o600))
155- legacyRef := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(legacyManifest)}
156-
157- _, err = repo.MigrateV1Checkpoint(context.Background(), ports.SnapshotMigrationRequest{LegacyName: "work", IncarnationID: domain.IncarnationID{9}, LegacyRef: legacyRef})
158- require.ErrorIs(t, err, ports.ErrLegacySnapshotUncertain)
159- require.FileExists(t, manifestPath)
160- })
161- }
162-}
163-
164-func TestMigrateV1CheckpointAcceptsLegacyNilParentBeyondGenerationOne(t *testing.T) {
165- dir := privateDir(t)
166- repo := NewRepository(dir)
167- require.NoError(t, repo.ensurePrivateDirectory(dir))
168- name := "work"
169- key := sessionKey(name)
170- generations := filepath.Join(dir, "sessions", key, "generations")
171- require.NoError(t, os.MkdirAll(generations, 0o700))
172- // Place the authoritative generation just beyond the bounded legacy scan.
173- highestGeneration := maxDirectoryTraversalEntries + 1
174- for i := 1; i < highestGeneration; i++ {
175- require.NoError(t, os.WriteFile(filepath.Join(generations, generationFilename(uint64(i))), []byte("stale"), 0o600))
176- }
177- legacy := legacyManifestBytes(name, uint64(highestGeneration))
178- legacyDigest := sha256.Sum256(legacy)
179- require.NoError(t, os.WriteFile(filepath.Join(generations, generationFilename(uint64(highestGeneration))), legacy, 0o600))
180- require.NoError(t, os.WriteFile(filepath.Join(dir, "sessions", key, "HEAD"), marshalHead(uint64(highestGeneration), legacyDigest), 0o600))
181- ref, err := repo.ReadLegacyHEAD(context.Background(), name)
182- require.NoError(t, err)
183- require.Equal(t, uint64(highestGeneration), ref.Generation)
184- id := domain.IncarnationID{1}
185- migrated, err := repo.MigrateV1Checkpoint(context.Background(), ports.SnapshotMigrationRequest{LegacyName: name, IncarnationID: id, LegacyRef: ref})
186- require.NoError(t, err)
187- got, err := repo.LoadCheckpoint(context.Background(), id, name, migrated)
188- require.NoError(t, err)
189- manifest, err := codec.UnmarshalManifest(got.Manifest)
190- require.NoError(t, err)
191- require.Equal(t, id, manifest.IncarnationID)
192- require.Nil(t, manifest.ParentCheckpoint)
193- for i := 1; i < highestGeneration; i++ {
194- require.FileExists(t, filepath.Join(generations, generationFilename(uint64(i))))
195- }
196-}
M internal/app/daemon_integration_test.go +4 -20
@@ -685,11 +685,7 @@ func TestLifecycleOwnershipOutlivesRestorationWriter(t *testing.T) {
685685 go func() {
686686 wrapperReturned <- runWithLifecycleOwner(ctx, runtimeDir, stateDir, func(ctx context.Context) error {
687687 defer close(callbackReturned)
688- opened, err := persist.OpenOrMigrate(ctx, persist.OpenDeps{
689- StateDir: stateDir,
690- Random: rand.Reader,
691- SnapshotMigration: repository,
692- })
688+ opened, err := persist.OpenOrCreate(stateDir)
693689 if err != nil {
694690 return err
695691 }
@@ -745,11 +741,7 @@ func publishRestorableCheckpoint(t *testing.T, stateDir string, repository *snap
745741 ctx, cancel := context.WithCancel(context.Background())
746742 defer cancel()
747743
748- opened, err := persist.OpenOrMigrate(ctx, persist.OpenDeps{
749- StateDir: stateDir,
750- Random: rand.Reader,
751- SnapshotMigration: repository,
752- })
744+ opened, err := persist.OpenOrCreate(stateDir)
753745 require.NoError(t, err)
754746 coordinator := recovery.NewCoordinator(opened.Catalogue, repository, recoveryfs.New(stateDir), rand.Reader)
755747 require.NoError(t, coordinator.Recover(ctx))
@@ -1007,11 +999,7 @@ func TestLifecycleSocketCloseCatalogueRace(t *testing.T) {
1007999
10081000 oldOwner, err := lifecycle.TryAcquire(runtimeDir)
10091001 require.NoError(t, err)
1010- oldOpened, err := persist.OpenOrMigrate(context.Background(), persist.OpenDeps{
1011- StateDir: stateDir,
1012- Random: rand.Reader,
1013- SnapshotMigration: snapshotRepository,
1014- })
1002+ oldOpened, err := persist.OpenOrCreate(stateDir)
10151003 require.NoError(t, err)
10161004 oldListener, err := ipc.Listen(runtimeDir)
10171005 require.NoError(t, err)
@@ -1062,11 +1050,7 @@ func TestLifecycleSocketCloseCatalogueRace(t *testing.T) {
10621050 go func() {
10631051 newDone <- runWithLifecycleOwnerDeps(context.Background(), runtimeDir, stateDir, func(ctx context.Context) error {
10641052 close(newDurableOpen)
1065- opened, err := persist.OpenOrMigrate(ctx, persist.OpenDeps{
1066- StateDir: stateDir,
1067- Random: rand.Reader,
1068- SnapshotMigration: snapshotRepository,
1069- })
1053+ opened, err := persist.OpenOrCreate(stateDir)
10701054 if err != nil {
10711055 return err
10721056 }
@@ -685,11 +685,7 @@ func TestLifecycleOwnershipOutlivesRestorationWriter(t *testing.T) {
685 go func() {685 go func() {
686 wrapperReturned <- runWithLifecycleOwner(ctx, runtimeDir, stateDir, func(ctx context.Context) error {686 wrapperReturned <- runWithLifecycleOwner(ctx, runtimeDir, stateDir, func(ctx context.Context) error {
687 defer close(callbackReturned)687 defer close(callbackReturned)
688- opened, err := persist.OpenOrMigrate(ctx, persist.OpenDeps{688+ opened, err := persist.OpenOrCreate(stateDir)
689- StateDir: stateDir,
690- Random: rand.Reader,
691- SnapshotMigration: repository,
692- })
693 if err != nil {689 if err != nil {
694 return err690 return err
695 }691 }
@@ -745,11 +741,7 @@ func publishRestorableCheckpoint(t *testing.T, stateDir string, repository *snap
745 ctx, cancel := context.WithCancel(context.Background())741 ctx, cancel := context.WithCancel(context.Background())
746 defer cancel()742 defer cancel()
747 743
748- opened, err := persist.OpenOrMigrate(ctx, persist.OpenDeps{744+ opened, err := persist.OpenOrCreate(stateDir)
749- StateDir: stateDir,
750- Random: rand.Reader,
751- SnapshotMigration: repository,
752- })
753 require.NoError(t, err)745 require.NoError(t, err)
754 coordinator := recovery.NewCoordinator(opened.Catalogue, repository, recoveryfs.New(stateDir), rand.Reader)746 coordinator := recovery.NewCoordinator(opened.Catalogue, repository, recoveryfs.New(stateDir), rand.Reader)
755 require.NoError(t, coordinator.Recover(ctx))747 require.NoError(t, coordinator.Recover(ctx))
@@ -1007,11 +999,7 @@ func TestLifecycleSocketCloseCatalogueRace(t *testing.T) {
1007 999
1008 oldOwner, err := lifecycle.TryAcquire(runtimeDir)1000 oldOwner, err := lifecycle.TryAcquire(runtimeDir)
1009 require.NoError(t, err)1001 require.NoError(t, err)
1010- oldOpened, err := persist.OpenOrMigrate(context.Background(), persist.OpenDeps{1002+ oldOpened, err := persist.OpenOrCreate(stateDir)
1011- StateDir: stateDir,
1012- Random: rand.Reader,
1013- SnapshotMigration: snapshotRepository,
1014- })
1015 require.NoError(t, err)1003 require.NoError(t, err)
1016 oldListener, err := ipc.Listen(runtimeDir)1004 oldListener, err := ipc.Listen(runtimeDir)
1017 require.NoError(t, err)1005 require.NoError(t, err)
@@ -1062,11 +1050,7 @@ func TestLifecycleSocketCloseCatalogueRace(t *testing.T) {
1062 go func() {1050 go func() {
1063 newDone <- runWithLifecycleOwnerDeps(context.Background(), runtimeDir, stateDir, func(ctx context.Context) error {1051 newDone <- runWithLifecycleOwnerDeps(context.Background(), runtimeDir, stateDir, func(ctx context.Context) error {
1064 close(newDurableOpen)1052 close(newDurableOpen)
1065- opened, err := persist.OpenOrMigrate(ctx, persist.OpenDeps{1053+ opened, err := persist.OpenOrCreate(stateDir)
1066- StateDir: stateDir,
1067- Random: rand.Reader,
1068- SnapshotMigration: snapshotRepository,
1069- })
1070 if err != nil {1054 if err != nil {
1071 return err1055 return err
1072 }1056 }
M internal/app/offline_kill_retry_test.go +5 -7
@@ -14,7 +14,7 @@ import (
1414 "github.com/bnema/vev/internal/persist"
1515 )
1616
17-func TestRunOfflineNamedKillUsesOpenOrMigrateSeam(t *testing.T) {
17+func TestRunOfflineNamedKillUsesOpenCatalogueSeam(t *testing.T) {
1818 stateRoot := t.TempDir()
1919 t.Setenv("XDG_STATE_HOME", stateRoot)
2020 stateDir := filepath.Join(stateRoot, "vev")
@@ -24,15 +24,13 @@ func TestRunOfflineNamedKillUsesOpenOrMigrateSeam(t *testing.T) {
2424 require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}))
2525 require.NoError(t, p.Close())
2626
27- original := openOrMigrate
28- t.Cleanup(func() { openOrMigrate = original })
27+ original := openCatalogue
28+ t.Cleanup(func() { openCatalogue = original })
2929 wantErr := errors.New("open seam called")
3030 called := false
31- openOrMigrate = func(_ context.Context, deps persist.OpenDeps) (persist.OpenResult, error) {
31+ openCatalogue = func(gotStateDir string) (persist.OpenResult, error) {
3232 called = true
33- require.Equal(t, stateDir, deps.StateDir)
34- require.NotNil(t, deps.Random)
35- require.NotNil(t, deps.SnapshotMigration)
33+ require.Equal(t, stateDir, gotStateDir)
3634 return persist.OpenResult{}, wantErr
3735 }
3836
@@ -14,7 +14,7 @@ import (
14 "github.com/bnema/vev/internal/persist"14 "github.com/bnema/vev/internal/persist"
15 )15 )
16 16
17-func TestRunOfflineNamedKillUsesOpenOrMigrateSeam(t *testing.T) {17+func TestRunOfflineNamedKillUsesOpenCatalogueSeam(t *testing.T) {
18 stateRoot := t.TempDir()18 stateRoot := t.TempDir()
19 t.Setenv("XDG_STATE_HOME", stateRoot)19 t.Setenv("XDG_STATE_HOME", stateRoot)
20 stateDir := filepath.Join(stateRoot, "vev")20 stateDir := filepath.Join(stateRoot, "vev")
@@ -24,15 +24,13 @@ func TestRunOfflineNamedKillUsesOpenOrMigrateSeam(t *testing.T) {
24 require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}))24 require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}))
25 require.NoError(t, p.Close())25 require.NoError(t, p.Close())
26 26
27- original := openOrMigrate27+ original := openCatalogue
28- t.Cleanup(func() { openOrMigrate = original })28+ t.Cleanup(func() { openCatalogue = original })
29 wantErr := errors.New("open seam called")29 wantErr := errors.New("open seam called")
30 called := false30 called := false
31- openOrMigrate = func(_ context.Context, deps persist.OpenDeps) (persist.OpenResult, error) {31+ openCatalogue = func(gotStateDir string) (persist.OpenResult, error) {
32 called = true32 called = true
33- require.Equal(t, stateDir, deps.StateDir)33+ require.Equal(t, stateDir, gotStateDir)
34- require.NotNil(t, deps.Random)
35- require.NotNil(t, deps.SnapshotMigration)
36 return persist.OpenResult{}, wantErr34 return persist.OpenResult{}, wantErr
37 }35 }
38 36
M internal/app/run.go +21 -25
@@ -118,7 +118,7 @@ var (
118118 commit = "none"
119119 date = "unknown"
120120
121- openOrMigrate = persist.OpenOrMigrate
121+ openCatalogue = persist.OpenOrCreate
122122 listenDaemon = func(dir string, observer ports.SerializedRuntimeObserver) (ports.Listener, error) {
123123 return ipc.Listen(dir, ipc.WithRuntimeObserver(observer))
124124 }
@@ -723,21 +723,19 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
723723 daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository))
724724 noticeStore := noticefile.New(platform.StateDir())
725725 daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticeStore))
726- storePath := persist.StorePath(platform.StateDir())
727- opened, err := openOrMigrate(ctx, persist.OpenDeps{
728- StateDir: platform.StateDir(),
729- Random: rand.Reader,
730- SnapshotMigration: snapshotRepository,
731- })
726+ stateDir := platform.StateDir()
727+ storePath := persist.StorePath(stateDir)
728+ opened, err := openCatalogue(stateDir)
732729 if err != nil {
733730 log.Error("catalogue_validation_failed", "path", storePath, "reason_code", "open-failed")
734- return fmt.Errorf("vev: open or migrate durable session state %s: %w", storePath, err)
731+ if errors.Is(err, persist.ErrCatalogueUnreadable) {
732+ return unreadableCatalogueError(stateDir)
733+ }
734+ return fmt.Errorf("vev: open durable session state %s: %w", storePath, err)
735735 }
736736 recoveryMode := "current"
737737 if opened.NewInstall {
738738 recoveryMode = "new-install"
739- } else if opened.Migrated {
740- recoveryMode = "migrated"
741739 }
742740 logCatalogueRecovery(log, opened.Records, recoveryMode)
743741 journal := recoveryfs.New(platform.StateDir())
@@ -1541,18 +1539,17 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
15411539 _ = tw.Flush()
15421540 }
15431541
1542+func unreadableCatalogueError(stateDir string) error {
1543+ return fmt.Errorf("vev: durable session state at %s cannot be read and was left untouched.\n"+
1544+ "vev does not erase it automatically. To start fresh, remove it:\n"+
1545+ " rm -rf %s", stateDir, stateDir)
1546+}
1547+
15441548 func runOfflineNamedKill(ctx context.Context, name string) (retErr error) {
1545- // LoadReadOnly already distinguishes a genuinely absent catalogue (empty
1546- // slice, nil error) from a real IO error, and recovers the same
1547- // fixed-path crash states LoadCatalogueReadOnly does, so a stray .next or
1548- // .prev left by a crash mid-compaction no longer wrongly denies a session
1549- // that still exists. A separate os.Stat precheck here was both redundant
1550- // with that recovery and a regression of the same bug class: it treated
1551- // "no sessions.kv" as "no such session" even when a recoverable .next or
1552- // .prev candidate was present.
1553- records, err := persist.LoadReadOnly(platform.StateDir())
1549+ stateDir := platform.StateDir()
1550+ records, err := persist.LoadReadOnly(stateDir)
15541551 if err != nil {
1555- return fmt.Errorf("vev: reading stored sessions: %w", err)
1552+ return unreadableCatalogueError(stateDir)
15561553 }
15571554 found := false
15581555 for _, record := range records {
@@ -1566,12 +1563,11 @@ func runOfflineNamedKill(ctx context.Context, name string) (retErr error) {
15661563 }
15671564
15681565 repository := snapshotadapter.NewRepository(snapshotDir())
1569- opened, err := openOrMigrate(ctx, persist.OpenDeps{
1570- StateDir: platform.StateDir(),
1571- Random: rand.Reader,
1572- SnapshotMigration: repository,
1573- })
1566+ opened, err := openCatalogue(stateDir)
15741567 if err != nil {
1568+ if errors.Is(err, persist.ErrCatalogueUnreadable) {
1569+ return unreadableCatalogueError(stateDir)
1570+ }
15751571 return fmt.Errorf("vev: opening stored sessions: %w", err)
15761572 }
15771573 defer func() { retErr = errors.Join(retErr, opened.Catalogue.Close()) }()
@@ -118,7 +118,7 @@ var (
118 commit = "none"118 commit = "none"
119 date = "unknown"119 date = "unknown"
120 120
121- openOrMigrate = persist.OpenOrMigrate121+ openCatalogue = persist.OpenOrCreate
122 listenDaemon = func(dir string, observer ports.SerializedRuntimeObserver) (ports.Listener, error) {122 listenDaemon = func(dir string, observer ports.SerializedRuntimeObserver) (ports.Listener, error) {
123 return ipc.Listen(dir, ipc.WithRuntimeObserver(observer))123 return ipc.Listen(dir, ipc.WithRuntimeObserver(observer))
124 }124 }
@@ -723,21 +723,19 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
723 daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository))723 daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository))
724 noticeStore := noticefile.New(platform.StateDir())724 noticeStore := noticefile.New(platform.StateDir())
725 daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticeStore))725 daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticeStore))
726- storePath := persist.StorePath(platform.StateDir())726+ stateDir := platform.StateDir()
727- opened, err := openOrMigrate(ctx, persist.OpenDeps{727+ storePath := persist.StorePath(stateDir)
728- StateDir: platform.StateDir(),728+ opened, err := openCatalogue(stateDir)
729- Random: rand.Reader,
730- SnapshotMigration: snapshotRepository,
731- })
732 if err != nil {729 if err != nil {
733 log.Error("catalogue_validation_failed", "path", storePath, "reason_code", "open-failed")730 log.Error("catalogue_validation_failed", "path", storePath, "reason_code", "open-failed")
734- return fmt.Errorf("vev: open or migrate durable session state %s: %w", storePath, err)731+ if errors.Is(err, persist.ErrCatalogueUnreadable) {
732+ return unreadableCatalogueError(stateDir)
733+ }
734+ return fmt.Errorf("vev: open durable session state %s: %w", storePath, err)
735 }735 }
736 recoveryMode := "current"736 recoveryMode := "current"
737 if opened.NewInstall {737 if opened.NewInstall {
738 recoveryMode = "new-install"738 recoveryMode = "new-install"
739- } else if opened.Migrated {
740- recoveryMode = "migrated"
741 }739 }
742 logCatalogueRecovery(log, opened.Records, recoveryMode)740 logCatalogueRecovery(log, opened.Records, recoveryMode)
743 journal := recoveryfs.New(platform.StateDir())741 journal := recoveryfs.New(platform.StateDir())
@@ -1541,18 +1539,17 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
1541 _ = tw.Flush()1539 _ = tw.Flush()
1542 }1540 }
1543 1541
1542+func unreadableCatalogueError(stateDir string) error {
1543+ return fmt.Errorf("vev: durable session state at %s cannot be read and was left untouched.\n"+
1544+ "vev does not erase it automatically. To start fresh, remove it:\n"+
1545+ " rm -rf %s", stateDir, stateDir)
1546+}
1547+
1544 func runOfflineNamedKill(ctx context.Context, name string) (retErr error) {1548 func runOfflineNamedKill(ctx context.Context, name string) (retErr error) {
1545- // LoadReadOnly already distinguishes a genuinely absent catalogue (empty1549+ stateDir := platform.StateDir()
1546- // slice, nil error) from a real IO error, and recovers the same1550+ records, err := persist.LoadReadOnly(stateDir)
1547- // fixed-path crash states LoadCatalogueReadOnly does, so a stray .next or
1548- // .prev left by a crash mid-compaction no longer wrongly denies a session
1549- // that still exists. A separate os.Stat precheck here was both redundant
1550- // with that recovery and a regression of the same bug class: it treated
1551- // "no sessions.kv" as "no such session" even when a recoverable .next or
1552- // .prev candidate was present.
1553- records, err := persist.LoadReadOnly(platform.StateDir())
1554 if err != nil {1551 if err != nil {
1555- return fmt.Errorf("vev: reading stored sessions: %w", err)1552+ return unreadableCatalogueError(stateDir)
1556 }1553 }
1557 found := false1554 found := false
1558 for _, record := range records {1555 for _, record := range records {
@@ -1566,12 +1563,11 @@ func runOfflineNamedKill(ctx context.Context, name string) (retErr error) {
1566 }1563 }
1567 1564
1568 repository := snapshotadapter.NewRepository(snapshotDir())1565 repository := snapshotadapter.NewRepository(snapshotDir())
1569- opened, err := openOrMigrate(ctx, persist.OpenDeps{1566+ opened, err := openCatalogue(stateDir)
1570- StateDir: platform.StateDir(),
1571- Random: rand.Reader,
1572- SnapshotMigration: repository,
1573- })
1574 if err != nil {1567 if err != nil {
1568+ if errors.Is(err, persist.ErrCatalogueUnreadable) {
1569+ return unreadableCatalogueError(stateDir)
1570+ }
1575 return fmt.Errorf("vev: opening stored sessions: %w", err)1571 return fmt.Errorf("vev: opening stored sessions: %w", err)
1576 }1572 }
1577 defer func() { retErr = errors.Join(retErr, opened.Catalogue.Close()) }()1573 defer func() { retErr = errors.Join(retErr, opened.Catalogue.Close()) }()
M internal/app/run_test.go +11 -5
@@ -370,9 +370,9 @@ func TestOfflineCommandsPropagateLifecycleReleaseErrors(t *testing.T) {
370370 }
371371
372372 func TestCatalogueFailureDoesNotListen(t *testing.T) {
373- originalOpenOrMigrate, originalListenDaemon := openOrMigrate, listenDaemon
373+ originalOpenCatalogue, originalListenDaemon := openCatalogue, listenDaemon
374374 t.Cleanup(func() {
375- openOrMigrate = originalOpenOrMigrate
375+ openCatalogue = originalOpenCatalogue
376376 listenDaemon = originalListenDaemon
377377 })
378378
@@ -385,8 +385,8 @@ func TestCatalogueFailureDoesNotListen(t *testing.T) {
385385 t.Setenv("XDG_STATE_HOME", stateRoot)
386386
387387 listenCalls := 0
388- openOrMigrate = func(_ context.Context, deps persist.OpenDeps) (persist.OpenResult, error) {
389- require.Equal(t, stateDir, deps.StateDir)
388+ openCatalogue = func(gotStateDir string) (persist.OpenResult, error) {
389+ require.Equal(t, stateDir, gotStateDir)
390390 return persist.OpenResult{}, catalogueErr
391391 }
392392 listenDaemon = func(string, ports.SerializedRuntimeObserver) (ports.Listener, error) {
@@ -512,9 +512,15 @@ func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) {
512512
513513 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
514514 t.Setenv("XDG_STATE_HOME", stateRoot)
515+ before, err := os.ReadFile(persist.StorePath(stateDir))
516+ require.NoError(t, err)
515517 err = runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned)
516518 require.Error(t, err)
517- require.Contains(t, err.Error(), "open or migrate durable session state")
519+ require.Contains(t, err.Error(), stateDir)
520+ require.Contains(t, err.Error(), "rm -rf "+stateDir)
521+ after, readErr := os.ReadFile(persist.StorePath(stateDir))
522+ require.NoError(t, readErr)
523+ require.Equal(t, before, after, "failed startup must leave the catalogue untouched")
518524 _, statErr := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
519525 require.ErrorIs(t, statErr, os.ErrNotExist)
520526 })
@@ -370,9 +370,9 @@ func TestOfflineCommandsPropagateLifecycleReleaseErrors(t *testing.T) {
370 }370 }
371 371
372 func TestCatalogueFailureDoesNotListen(t *testing.T) {372 func TestCatalogueFailureDoesNotListen(t *testing.T) {
373- originalOpenOrMigrate, originalListenDaemon := openOrMigrate, listenDaemon373+ originalOpenCatalogue, originalListenDaemon := openCatalogue, listenDaemon
374 t.Cleanup(func() {374 t.Cleanup(func() {
375- openOrMigrate = originalOpenOrMigrate375+ openCatalogue = originalOpenCatalogue
376 listenDaemon = originalListenDaemon376 listenDaemon = originalListenDaemon
377 })377 })
378 378
@@ -385,8 +385,8 @@ func TestCatalogueFailureDoesNotListen(t *testing.T) {
385 t.Setenv("XDG_STATE_HOME", stateRoot)385 t.Setenv("XDG_STATE_HOME", stateRoot)
386 386
387 listenCalls := 0387 listenCalls := 0
388- openOrMigrate = func(_ context.Context, deps persist.OpenDeps) (persist.OpenResult, error) {388+ openCatalogue = func(gotStateDir string) (persist.OpenResult, error) {
389- require.Equal(t, stateDir, deps.StateDir)389+ require.Equal(t, stateDir, gotStateDir)
390 return persist.OpenResult{}, catalogueErr390 return persist.OpenResult{}, catalogueErr
391 }391 }
392 listenDaemon = func(string, ports.SerializedRuntimeObserver) (ports.Listener, error) {392 listenDaemon = func(string, ports.SerializedRuntimeObserver) (ports.Listener, error) {
@@ -512,9 +512,15 @@ func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) {
512 512
513 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)513 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
514 t.Setenv("XDG_STATE_HOME", stateRoot)514 t.Setenv("XDG_STATE_HOME", stateRoot)
515+ before, err := os.ReadFile(persist.StorePath(stateDir))
516+ require.NoError(t, err)
515 err = runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned)517 err = runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned)
516 require.Error(t, err)518 require.Error(t, err)
517- require.Contains(t, err.Error(), "open or migrate durable session state")519+ require.Contains(t, err.Error(), stateDir)
520+ require.Contains(t, err.Error(), "rm -rf "+stateDir)
521+ after, readErr := os.ReadFile(persist.StorePath(stateDir))
522+ require.NoError(t, readErr)
523+ require.Equal(t, before, after, "failed startup must leave the catalogue untouched")
518 _, statErr := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))524 _, statErr := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
519 require.ErrorIs(t, statErr, os.ErrNotExist)525 require.ErrorIs(t, statErr, os.ErrNotExist)
520 })526 })
D internal/persist/migration.go +0 -654
@@ -1,654 +0,0 @@
1-package persist
2-
3-import (
4- "context"
5- "encoding/binary"
6- "errors"
7- "fmt"
8- "io"
9- "log/slog"
10- "os"
11- "path/filepath"
12- "sort"
13-
14- "github.com/bnema/vev/internal/domain"
15- "github.com/bnema/vev/internal/ports"
16- "github.com/bnema/vev/pkg/kv"
17- "github.com/bnema/vev/pkg/safedir"
18-)
19-
20-type MigrationPhase uint8
21-
22-const (
23- MigrationBackedUp MigrationPhase = iota + 1
24- MigrationAssigned
25- MigrationValidated
26- MigrationInstalled
27- MigrationComplete
28-)
29-
30-type MigrationRecord struct {
31- Phase MigrationPhase
32- Assignments map[string]domain.IncarnationID
33- Validated map[string]*domain.CheckpointRef
34- Degraded map[string]string
35-}
36-
37-type OpenDeps struct {
38- StateDir string
39- Random io.Reader
40- SnapshotMigration ports.SnapshotMigration
41- // Logger receives full adapter causes; durable degraded reasons are sanitized.
42- Logger *slog.Logger
43- // Fault is a test-only crash seam called after named durable boundaries.
44- Fault func(string) error
45-}
46-
47-type OpenResult struct {
48- Catalogue *Persister
49- Records []domain.CatalogueRecord
50- NewInstall bool
51- Migrated bool
52-}
53-
54-type legacyCatalogueRecordV0 struct {
55- Name string
56- Cwd string
57- CreatedAt int64
58- UpdatedAt int64
59- LastUsedSeq uint64
60- TabNames []string
61-}
62-
63-const (
64- migrationDirName = "migration"
65- migrationIntentName = "durable-session-v1.intent"
66- migrationCompleteName = "durable-session-v1.complete"
67- migrationBackupName = "legacy-catalogue.backup"
68- catalogueFormatName = "durable-session-v1.format"
69- migrationRecordMagic = "VEVJ"
70- migrationRecordVersion uint16 = 1
71-
72- migrationDegradedLegacySnapshotUncertain = "legacy_snapshot_uncertain"
73- migrationDegradedReasonMaxBytes = 64
74-)
75-
76-func OpenOrMigrate(ctx context.Context, deps OpenDeps) (OpenResult, error) {
77- if err := ctx.Err(); err != nil {
78- return OpenResult{}, err
79- }
80- if deps.StateDir == "" || deps.Random == nil || deps.SnapshotMigration == nil {
81- return OpenResult{}, errors.New("persist: incomplete open or migrate dependencies")
82- }
83- path := StorePath(deps.StateDir)
84- present, err := catalogueCandidatesPresent(path)
85- if err != nil {
86- return OpenResult{}, err
87- }
88- migrationDir := filepath.Join(deps.StateDir, migrationDirName)
89- intentPath := filepath.Join(migrationDir, migrationIntentName)
90- if !present {
91- if _, err := os.Stat(intentPath); err == nil {
92- return resumeLegacyMigration(ctx, deps)
93- } else if !errors.Is(err, os.ErrNotExist) {
94- return OpenResult{}, err
95- }
96- legacy, err := deps.SnapshotMigration.HasLegacyState(ctx)
97- if err != nil {
98- return OpenResult{}, err
99- }
100- if legacy {
101- return OpenResult{}, errors.New("persist: legacy snapshot state exists without a migratable catalogue")
102- }
103- if err := atomicMigrationWrite(filepath.Join(migrationDir, catalogueFormatName), []byte("v1\n")); err != nil {
104- return OpenResult{}, err
105- }
106- catalogue, records, err := openCurrentCatalogue(deps.StateDir, true)
107- if err != nil {
108- return OpenResult{}, err
109- }
110- return OpenResult{Catalogue: catalogue, Records: records, NewInstall: true}, nil
111- }
112- // Opening and closing first applies fixed-path WAL recovery before format detection.
113- store, err := OpenStore(path)
114- if err != nil {
115- return OpenResult{}, err
116- }
117- if err := store.Close(); err != nil {
118- return OpenResult{}, err
119- }
120- if complete, err := completedMigration(migrationDir, intentPath); err != nil {
121- return OpenResult{}, err
122- } else if complete {
123- data, err := kv.Replay(path)
124- if err != nil {
125- return OpenResult{}, err
126- }
127- current, legacy, err := classifyCatalogue(data)
128- if err != nil || !current || legacy {
129- return OpenResult{}, errors.Join(errors.New("persist: completed migration has invalid current catalogue"), err)
130- }
131- catalogue, records, err := openCurrentCatalogue(deps.StateDir, false)
132- return OpenResult{Catalogue: catalogue, Records: records}, err
133- }
134- if _, err := os.Stat(intentPath); err == nil {
135- return resumeLegacyMigration(ctx, deps)
136- } else if !errors.Is(err, os.ErrNotExist) {
137- return OpenResult{}, err
138- }
139- data, err := kv.Replay(path)
140- if err != nil {
141- return OpenResult{}, err
142- }
143- current, legacy, err := classifyCatalogue(data)
144- if err != nil {
145- if len(data) != 0 || !catalogueFormatProven(migrationDir) {
146- return OpenResult{}, err
147- }
148- current = true
149- }
150- if current {
151- if err := atomicMigrationWrite(filepath.Join(migrationDir, catalogueFormatName), []byte("v1\n")); err != nil {
152- return OpenResult{}, err
153- }
154- catalogue, records, err := openCurrentCatalogue(deps.StateDir, false)
155- return OpenResult{Catalogue: catalogue, Records: records}, err
156- }
157- if !legacy {
158- return OpenResult{}, errors.New("persist: ambiguous catalogue format")
159- }
160- return startLegacyMigration(ctx, deps)
161-}
162-
163-func catalogueFormatProven(dir string) bool {
164- data, err := os.ReadFile(filepath.Join(dir, catalogueFormatName))
165- return err == nil && string(data) == "v1\n"
166-}
167-
168-func completedMigration(dir, intentPath string) (bool, error) {
169- completePath := filepath.Join(dir, migrationCompleteName)
170- data, err := os.ReadFile(completePath)
171- if errors.Is(err, os.ErrNotExist) {
172- return false, nil
173- }
174- if err != nil {
175- return false, err
176- }
177- if string(data) != "complete\n" {
178- return false, errors.New("persist: invalid migration completion marker")
179- }
180- record, err := readMigrationRecord(intentPath)
181- if err != nil {
182- return false, err
183- }
184- if record.Phase != MigrationComplete {
185- return false, errors.New("persist: completion marker precedes completed intent")
186- }
187- return true, nil
188-}
189-
190-func catalogueCandidatesPresent(path string) (bool, error) {
191- present := false
192- for _, candidate := range []string{path, path + ".next", path + ".prev"} {
193- _, err := os.Stat(candidate)
194- if err == nil {
195- present = true
196- } else if !errors.Is(err, os.ErrNotExist) {
197- return false, err
198- }
199- }
200- return present, nil
201-}
202-func classifyCatalogue(data map[string][]byte) (current, legacy bool, err error) {
203- if len(data) == 0 {
204- return false, false, errors.New("persist: empty existing catalogue has unknown provenance")
205- }
206- for name, value := range data {
207- if len(value) >= 6 && string(value[:4]) == string(catalogueMagic[:]) {
208- if _, e := decodeRecordValue(name, value); e != nil {
209- return false, false, e
210- }
211- current = true
212- } else {
213- if _, e := decodeLegacyRecordV0(name, value); e != nil {
214- return false, false, e
215- }
216- legacy = true
217- }
218- }
219- if current && legacy {
220- return false, false, errors.New("persist: mixed catalogue formats")
221- }
222- return current, legacy, nil
223-}
224-
225-func startLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, error) {
226- migrationDir := filepath.Join(deps.StateDir, migrationDirName)
227- if err := safedir.EnsurePrivate(migrationDir); err != nil {
228- return OpenResult{}, err
229- }
230- backup := filepath.Join(migrationDir, migrationBackupName)
231- source, err := os.ReadFile(StorePath(deps.StateDir))
232- if err != nil {
233- return OpenResult{}, err
234- }
235- if err := atomicMigrationWrite(backup, source); err != nil {
236- return OpenResult{}, err
237- }
238- if err := migrationBoundary(deps, "backup-sync"); err != nil {
239- return OpenResult{}, err
240- }
241- record := MigrationRecord{Phase: MigrationBackedUp, Assignments: map[string]domain.IncarnationID{}, Validated: map[string]*domain.CheckpointRef{}, Degraded: map[string]string{}}
242- if err := writeMigrationRecord(filepath.Join(migrationDir, migrationIntentName), record); err != nil {
243- return OpenResult{}, err
244- }
245- if err := migrationBoundary(deps, "intent-sync"); err != nil {
246- return OpenResult{}, err
247- }
248- return resumeLegacyMigration(ctx, deps)
249-}
250-
251-func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, error) {
252- migrationDir := filepath.Join(deps.StateDir, migrationDirName)
253- intent := filepath.Join(migrationDir, migrationIntentName)
254- record, err := readMigrationRecord(intent)
255- if err != nil {
256- return OpenResult{}, err
257- }
258- backup := filepath.Join(migrationDir, migrationBackupName)
259- legacyData, err := kv.Replay(backup)
260- if err != nil {
261- return OpenResult{}, fmt.Errorf("persist: replay legacy backup: %w", err)
262- }
263- legacyRecords, err := decodeLegacyMap(legacyData)
264- if err != nil {
265- return OpenResult{}, err
266- }
267- if record.Phase < MigrationAssigned {
268- for _, legacy := range legacyRecords {
269- if _, ok := record.Assignments[legacy.Name]; ok {
270- continue
271- }
272- id, err := domain.NewIncarnationID(deps.Random)
273- if err != nil {
274- return OpenResult{}, err
275- }
276- record.Assignments[legacy.Name] = id
277- if err := writeMigrationRecord(intent, record); err != nil {
278- return OpenResult{}, err
279- }
280- if err := migrationBoundary(deps, "identity-sync"); err != nil {
281- return OpenResult{}, err
282- }
283- }
284- record.Phase = MigrationAssigned
285- if err := writeMigrationRecord(intent, record); err != nil {
286- return OpenResult{}, err
287- }
288- }
289- if record.Phase < MigrationValidated {
290- for _, legacy := range legacyRecords {
291- if _, ok := record.Validated[legacy.Name]; ok {
292- continue
293- }
294- if _, ok := record.Degraded[legacy.Name]; ok {
295- continue
296- }
297- ref, err := deps.SnapshotMigration.ReadLegacyHEAD(ctx, legacy.Name)
298- if err == nil {
299- var migrated domain.CheckpointRef
300- migrated, err = deps.SnapshotMigration.MigrateV1Checkpoint(ctx, ports.SnapshotMigrationRequest{LegacyName: legacy.Name, IncarnationID: record.Assignments[legacy.Name], LegacyRef: ref})
301- if err == nil {
302- record.Validated[legacy.Name] = &migrated
303- }
304- }
305- if err != nil {
306- if !errors.Is(err, ports.ErrLegacySnapshotUncertain) {
307- return OpenResult{}, fmt.Errorf("persist: migrate legacy snapshot %q: %w", legacy.Name, err)
308- }
309- migrationLogger(deps).Error("legacy snapshot migration uncertain", "session", legacy.Name, "err", err)
310- if ref.Generation != 0 && ref.ManifestDigest != ([32]byte{}) {
311- legacyRef := ref
312- record.Validated[legacy.Name] = &legacyRef
313- }
314- record.Degraded[legacy.Name] = migrationDegradedLegacySnapshotUncertain
315- }
316- if err := writeMigrationRecord(intent, record); err != nil {
317- return OpenResult{}, err
318- }
319- if err := migrationBoundary(deps, "head-validation"); err != nil {
320- return OpenResult{}, err
321- }
322- }
323- record.Phase = MigrationValidated
324- if err := writeMigrationRecord(intent, record); err != nil {
325- return OpenResult{}, err
326- }
327- }
328- records := make([]domain.CatalogueRecord, 0, len(legacyRecords))
329- for _, legacy := range legacyRecords {
330- next := domain.CatalogueRecord{Name: legacy.Name, IncarnationID: record.Assignments[legacy.Name], Cwd: legacy.Cwd, CreatedAt: legacy.CreatedAt, UpdatedAt: legacy.UpdatedAt, LastUsedSeq: legacy.LastUsedSeq, TabNames: append([]string(nil), legacy.TabNames...)}
331- if ref := record.Validated[legacy.Name]; ref != nil {
332- copyRef := *ref
333- next.Committed = &copyRef
334- }
335- if reason := record.Degraded[legacy.Name]; reason != "" {
336- next.RecoveryState = domain.RecoveryDegraded
337- next.DegradedReason = reason
338- } else {
339- next.RecoveryState = domain.RecoveryHealthy
340- }
341- records = append(records, next)
342- }
343- if record.Phase < MigrationInstalled {
344- if err := installMigratedCatalogue(deps.StateDir, records); err != nil {
345- return OpenResult{}, err
346- }
347- if err := migrationBoundary(deps, "catalogue-sync"); err != nil {
348- return OpenResult{}, err
349- }
350- record.Phase = MigrationInstalled
351- if err := writeMigrationRecord(intent, record); err != nil {
352- return OpenResult{}, err
353- }
354- if err := migrationBoundary(deps, "receipt-sync"); err != nil {
355- return OpenResult{}, err
356- }
357- }
358- catalogue, opened, err := openCurrentCatalogue(deps.StateDir, false)
359- if err != nil {
360- return OpenResult{}, err
361- }
362- if !catalogueRecordsEqual(records, opened) {
363- _ = catalogue.Close()
364- return OpenResult{}, errors.New("persist: installed catalogue validation mismatch")
365- }
366- if record.Phase < MigrationComplete {
367- record.Phase = MigrationComplete
368- if err := writeMigrationRecord(intent, record); err != nil {
369- _ = catalogue.Close()
370- return OpenResult{}, err
371- }
372- if err := atomicMigrationWrite(filepath.Join(migrationDir, migrationCompleteName), []byte("complete\n")); err != nil {
373- _ = catalogue.Close()
374- return OpenResult{}, err
375- }
376- if err := migrationBoundary(deps, "complete-sync"); err != nil {
377- _ = catalogue.Close()
378- return OpenResult{}, err
379- }
380- }
381- return OpenResult{Catalogue: catalogue, Records: opened, Migrated: true}, nil
382-}
383-
384-func migrationLogger(deps OpenDeps) *slog.Logger {
385- if deps.Logger != nil {
386- return deps.Logger
387- }
388- return slog.Default()
389-}
390-
391-func migrationBoundary(deps OpenDeps, name string) error {
392- if deps.Fault != nil {
393- return deps.Fault(name)
394- }
395- return nil
396-}
397-
398-func installMigratedCatalogue(stateDir string, records []domain.CatalogueRecord) error {
399- stage := filepath.Join(stateDir, migrationDirName, "install")
400- _ = os.RemoveAll(stage)
401- if err := safedir.EnsurePrivate(stage); err != nil {
402- return err
403- }
404- p, _, err := openCurrentCatalogue(stage, true)
405- if err != nil {
406- return err
407- }
408- changes := make(map[string]*domain.CatalogueRecord, len(records))
409- for i := range records {
410- r := records[i]
411- changes[r.Name] = &r
412- }
413- if len(changes) > 0 {
414- if err := p.Apply(changes); err != nil {
415- _ = p.Close()
416- return err
417- }
418- }
419- if err := p.Close(); err != nil {
420- return err
421- }
422- staged, opened, err := openCurrentCatalogue(stage, false)
423- if err != nil {
424- return err
425- }
426- if !catalogueRecordsEqual(records, opened) {
427- _ = staged.Close()
428- return errors.New("persist: staged catalogue validation mismatch")
429- }
430- if err := staged.Close(); err != nil {
431- return err
432- }
433- source := StorePath(stage)
434- target := StorePath(stateDir)
435- if err := os.Rename(source, target); err != nil {
436- return err
437- }
438- if err := syncMigrationDir(stateDir); err != nil {
439- return err
440- }
441- return atomicMigrationWrite(filepath.Join(stateDir, migrationDirName, catalogueFormatName), []byte("v1\n"))
442-}
443-
444-func decodeLegacyMap(data map[string][]byte) ([]legacyCatalogueRecordV0, error) {
445- out := make([]legacyCatalogueRecordV0, 0, len(data))
446- for name, value := range data {
447- record, err := decodeLegacyRecordV0(name, value)
448- if err != nil {
449- return nil, err
450- }
451- out = append(out, record)
452- }
453- sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
454- return out, nil
455-}
456-func decodeLegacyRecordV0(name string, value []byte) (legacyCatalogueRecordV0, error) {
457- if err := domain.ValidateSessionName(name); err != nil {
458- return legacyCatalogueRecordV0{}, err
459- }
460- r := valueReader{data: value}
461- cwd, ok := r.str()
462- if !ok {
463- return legacyCatalogueRecordV0{}, errMalformedRecord
464- }
465- created, ok := r.u64()
466- if !ok {
467- return legacyCatalogueRecordV0{}, errMalformedRecord
468- }
469- updated, ok := r.u64()
470- if !ok {
471- return legacyCatalogueRecordV0{}, errMalformedRecord
472- }
473- record := legacyCatalogueRecordV0{Name: name, Cwd: cwd, CreatedAt: int64(created), UpdatedAt: int64(updated)}
474- if r.remaining() == 0 {
475- return record, nil
476- }
477- if record.LastUsedSeq, ok = r.u64(); !ok {
478- return legacyCatalogueRecordV0{}, errMalformedRecord
479- }
480- if r.remaining() == 0 {
481- return record, nil
482- }
483- count, ok := r.u32()
484- if !ok || uint64(count) > uint64(r.remaining())/4 {
485- return legacyCatalogueRecordV0{}, errMalformedRecord
486- }
487- for range count {
488- tab, ok := r.str()
489- if !ok {
490- return legacyCatalogueRecordV0{}, errMalformedRecord
491- }
492- record.TabNames = append(record.TabNames, tab)
493- }
494- if r.remaining() != 0 {
495- return legacyCatalogueRecordV0{}, errMalformedRecord
496- }
497- return record, nil
498-}
499-
500-func writeMigrationRecord(path string, record MigrationRecord) error {
501- encoded, err := encodeMigrationRecord(record)
502- if err != nil {
503- return err
504- }
505- return atomicMigrationWrite(path, encoded)
506-}
507-func encodeMigrationRecord(record MigrationRecord) ([]byte, error) {
508- out := append([]byte(migrationRecordMagic), byte(migrationRecordVersion>>8), byte(migrationRecordVersion), byte(record.Phase))
509- names := make([]string, 0, len(record.Assignments))
510- for name := range record.Assignments {
511- names = append(names, name)
512- }
513- sort.Strings(names)
514- out = binary.BigEndian.AppendUint32(out, uint32(len(names)))
515- for _, name := range names {
516- var err error
517- out, err = appendCheckedString(out, name)
518- if err != nil {
519- return nil, err
520- }
521- id := record.Assignments[name]
522- out = append(out, id[:]...)
523- if ref := record.Validated[name]; ref != nil {
524- out = append(out, 1)
525- out = binary.BigEndian.AppendUint64(out, ref.Generation)
526- out = append(out, ref.ManifestDigest[:]...)
527- } else {
528- out = append(out, 0)
529- }
530- out, err = appendCheckedString(out, record.Degraded[name])
531- if err != nil {
532- return nil, err
533- }
534- }
535- return out, nil
536-}
537-func readMigrationRecord(path string) (MigrationRecord, error) {
538- data, err := os.ReadFile(path)
539- if err != nil {
540- return MigrationRecord{}, err
541- }
542- if len(data) < 11 || string(data[:4]) != migrationRecordMagic || binary.BigEndian.Uint16(data[4:6]) != migrationRecordVersion {
543- return MigrationRecord{}, errors.New("persist: invalid migration journal")
544- }
545- record := MigrationRecord{Phase: MigrationPhase(data[6]), Assignments: map[string]domain.IncarnationID{}, Validated: map[string]*domain.CheckpointRef{}, Degraded: map[string]string{}}
546- r := valueReader{data: data[7:]}
547- count, ok := r.u32()
548- if !ok {
549- return MigrationRecord{}, errMalformedRecord
550- }
551- for range count {
552- name, ok := r.str()
553- if !ok {
554- return MigrationRecord{}, errMalformedRecord
555- }
556- idBytes, ok := r.take(16)
557- if !ok {
558- return MigrationRecord{}, errMalformedRecord
559- }
560- var id domain.IncarnationID
561- copy(id[:], idBytes)
562- if _, exists := record.Assignments[name]; exists || id == (domain.IncarnationID{}) {
563- return MigrationRecord{}, errMalformedRecord
564- }
565- record.Assignments[name] = id
566- present, ok := r.byte()
567- if !ok {
568- return MigrationRecord{}, errMalformedRecord
569- }
570- if present == 1 {
571- generation, ok := r.u64()
572- if !ok {
573- return MigrationRecord{}, errMalformedRecord
574- }
575- digest, ok := r.take(32)
576- if !ok {
577- return MigrationRecord{}, errMalformedRecord
578- }
579- ref := &domain.CheckpointRef{Generation: generation}
580- copy(ref.ManifestDigest[:], digest)
581- record.Validated[name] = ref
582- } else if present != 0 {
583- return MigrationRecord{}, errMalformedRecord
584- }
585- reason, ok := r.str()
586- if !ok {
587- return MigrationRecord{}, errMalformedRecord
588- }
589- if reason != "" {
590- record.Degraded[name] = reason
591- }
592- }
593- if r.remaining() != 0 || record.Phase < MigrationBackedUp || record.Phase > MigrationComplete {
594- return MigrationRecord{}, errMalformedRecord
595- }
596- return record, nil
597-}
598-func atomicMigrationWrite(path string, data []byte) (retErr error) {
599- dir := filepath.Dir(path)
600- if err := safedir.EnsurePrivate(dir); err != nil {
601- return err
602- }
603- tmp, err := os.CreateTemp(dir, ".migration-*")
604- if err != nil {
605- return err
606- }
607- tmpName := tmp.Name()
608- published := false
609- defer func() {
610- if !published {
611- // A failed publication must not leave a replayable partial intent.
612- retErr = errors.Join(retErr, os.Remove(tmpName))
613- }
614- }()
615- if err := tmp.Chmod(0o600); err != nil {
616- return errors.Join(err, tmp.Close())
617- }
618- if _, err := tmp.Write(data); err != nil {
619- return errors.Join(err, tmp.Close())
620- }
621- if err := tmp.Sync(); err != nil {
622- return errors.Join(err, tmp.Close())
623- }
624- if err := tmp.Close(); err != nil {
625- return err
626- }
627- if err := os.Rename(tmpName, path); err != nil {
628- return err
629- }
630- published = true
631- return syncMigrationDir(dir)
632-}
633-func syncMigrationDir(dir string) error {
634- f, err := os.Open(dir)
635- if err != nil {
636- return err
637- }
638- return errors.Join(f.Sync(), f.Close())
639-}
640-func catalogueRecordsEqual(a, b []domain.CatalogueRecord) bool {
641- if len(a) != len(b) {
642- return false
643- }
644- left := append([]domain.CatalogueRecord(nil), a...)
645- right := append([]domain.CatalogueRecord(nil), b...)
646- sort.Slice(left, func(i, j int) bool { return left[i].Name < left[j].Name })
647- sort.Slice(right, func(i, j int) bool { return right[i].Name < right[j].Name })
648- for i := range left {
649- if !left[i].Equal(right[i]) {
650- return false
651- }
652- }
653- return true
654-}
@@ -1,654 +0,0 @@
1-package persist
2-
3-import (
4- "context"
5- "encoding/binary"
6- "errors"
7- "fmt"
8- "io"
9- "log/slog"
10- "os"
11- "path/filepath"
12- "sort"
13-
14- "github.com/bnema/vev/internal/domain"
15- "github.com/bnema/vev/internal/ports"
16- "github.com/bnema/vev/pkg/kv"
17- "github.com/bnema/vev/pkg/safedir"
18-)
19-
20-type MigrationPhase uint8
21-
22-const (
23- MigrationBackedUp MigrationPhase = iota + 1
24- MigrationAssigned
25- MigrationValidated
26- MigrationInstalled
27- MigrationComplete
28-)
29-
30-type MigrationRecord struct {
31- Phase MigrationPhase
32- Assignments map[string]domain.IncarnationID
33- Validated map[string]*domain.CheckpointRef
34- Degraded map[string]string
35-}
36-
37-type OpenDeps struct {
38- StateDir string
39- Random io.Reader
40- SnapshotMigration ports.SnapshotMigration
41- // Logger receives full adapter causes; durable degraded reasons are sanitized.
42- Logger *slog.Logger
43- // Fault is a test-only crash seam called after named durable boundaries.
44- Fault func(string) error
45-}
46-
47-type OpenResult struct {
48- Catalogue *Persister
49- Records []domain.CatalogueRecord
50- NewInstall bool
51- Migrated bool
52-}
53-
54-type legacyCatalogueRecordV0 struct {
55- Name string
56- Cwd string
57- CreatedAt int64
58- UpdatedAt int64
59- LastUsedSeq uint64
60- TabNames []string
61-}
62-
63-const (
64- migrationDirName = "migration"
65- migrationIntentName = "durable-session-v1.intent"
66- migrationCompleteName = "durable-session-v1.complete"
67- migrationBackupName = "legacy-catalogue.backup"
68- catalogueFormatName = "durable-session-v1.format"
69- migrationRecordMagic = "VEVJ"
70- migrationRecordVersion uint16 = 1
71-
72- migrationDegradedLegacySnapshotUncertain = "legacy_snapshot_uncertain"
73- migrationDegradedReasonMaxBytes = 64
74-)
75-
76-func OpenOrMigrate(ctx context.Context, deps OpenDeps) (OpenResult, error) {
77- if err := ctx.Err(); err != nil {
78- return OpenResult{}, err
79- }
80- if deps.StateDir == "" || deps.Random == nil || deps.SnapshotMigration == nil {
81- return OpenResult{}, errors.New("persist: incomplete open or migrate dependencies")
82- }
83- path := StorePath(deps.StateDir)
84- present, err := catalogueCandidatesPresent(path)
85- if err != nil {
86- return OpenResult{}, err
87- }
88- migrationDir := filepath.Join(deps.StateDir, migrationDirName)
89- intentPath := filepath.Join(migrationDir, migrationIntentName)
90- if !present {
91- if _, err := os.Stat(intentPath); err == nil {
92- return resumeLegacyMigration(ctx, deps)
93- } else if !errors.Is(err, os.ErrNotExist) {
94- return OpenResult{}, err
95- }
96- legacy, err := deps.SnapshotMigration.HasLegacyState(ctx)
97- if err != nil {
98- return OpenResult{}, err
99- }
100- if legacy {
101- return OpenResult{}, errors.New("persist: legacy snapshot state exists without a migratable catalogue")
102- }
103- if err := atomicMigrationWrite(filepath.Join(migrationDir, catalogueFormatName), []byte("v1\n")); err != nil {
104- return OpenResult{}, err
105- }
106- catalogue, records, err := openCurrentCatalogue(deps.StateDir, true)
107- if err != nil {
108- return OpenResult{}, err
109- }
110- return OpenResult{Catalogue: catalogue, Records: records, NewInstall: true}, nil
111- }
112- // Opening and closing first applies fixed-path WAL recovery before format detection.
113- store, err := OpenStore(path)
114- if err != nil {
115- return OpenResult{}, err
116- }
117- if err := store.Close(); err != nil {
118- return OpenResult{}, err
119- }
120- if complete, err := completedMigration(migrationDir, intentPath); err != nil {
121- return OpenResult{}, err
122- } else if complete {
123- data, err := kv.Replay(path)
124- if err != nil {
125- return OpenResult{}, err
126- }
127- current, legacy, err := classifyCatalogue(data)
128- if err != nil || !current || legacy {
129- return OpenResult{}, errors.Join(errors.New("persist: completed migration has invalid current catalogue"), err)
130- }
131- catalogue, records, err := openCurrentCatalogue(deps.StateDir, false)
132- return OpenResult{Catalogue: catalogue, Records: records}, err
133- }
134- if _, err := os.Stat(intentPath); err == nil {
135- return resumeLegacyMigration(ctx, deps)
136- } else if !errors.Is(err, os.ErrNotExist) {
137- return OpenResult{}, err
138- }
139- data, err := kv.Replay(path)
140- if err != nil {
141- return OpenResult{}, err
142- }
143- current, legacy, err := classifyCatalogue(data)
144- if err != nil {
145- if len(data) != 0 || !catalogueFormatProven(migrationDir) {
146- return OpenResult{}, err
147- }
148- current = true
149- }
150- if current {
151- if err := atomicMigrationWrite(filepath.Join(migrationDir, catalogueFormatName), []byte("v1\n")); err != nil {
152- return OpenResult{}, err
153- }
154- catalogue, records, err := openCurrentCatalogue(deps.StateDir, false)
155- return OpenResult{Catalogue: catalogue, Records: records}, err
156- }
157- if !legacy {
158- return OpenResult{}, errors.New("persist: ambiguous catalogue format")
159- }
160- return startLegacyMigration(ctx, deps)
161-}
162-
163-func catalogueFormatProven(dir string) bool {
164- data, err := os.ReadFile(filepath.Join(dir, catalogueFormatName))
165- return err == nil && string(data) == "v1\n"
166-}
167-
168-func completedMigration(dir, intentPath string) (bool, error) {
169- completePath := filepath.Join(dir, migrationCompleteName)
170- data, err := os.ReadFile(completePath)
171- if errors.Is(err, os.ErrNotExist) {
172- return false, nil
173- }
174- if err != nil {
175- return false, err
176- }
177- if string(data) != "complete\n" {
178- return false, errors.New("persist: invalid migration completion marker")
179- }
180- record, err := readMigrationRecord(intentPath)
181- if err != nil {
182- return false, err
183- }
184- if record.Phase != MigrationComplete {
185- return false, errors.New("persist: completion marker precedes completed intent")
186- }
187- return true, nil
188-}
189-
190-func catalogueCandidatesPresent(path string) (bool, error) {
191- present := false
192- for _, candidate := range []string{path, path + ".next", path + ".prev"} {
193- _, err := os.Stat(candidate)
194- if err == nil {
195- present = true
196- } else if !errors.Is(err, os.ErrNotExist) {
197- return false, err
198- }
199- }
200- return present, nil
201-}
202-func classifyCatalogue(data map[string][]byte) (current, legacy bool, err error) {
203- if len(data) == 0 {
204- return false, false, errors.New("persist: empty existing catalogue has unknown provenance")
205- }
206- for name, value := range data {
207- if len(value) >= 6 && string(value[:4]) == string(catalogueMagic[:]) {
208- if _, e := decodeRecordValue(name, value); e != nil {
209- return false, false, e
210- }
211- current = true
212- } else {
213- if _, e := decodeLegacyRecordV0(name, value); e != nil {
214- return false, false, e
215- }
216- legacy = true
217- }
218- }
219- if current && legacy {
220- return false, false, errors.New("persist: mixed catalogue formats")
221- }
222- return current, legacy, nil
223-}
224-
225-func startLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, error) {
226- migrationDir := filepath.Join(deps.StateDir, migrationDirName)
227- if err := safedir.EnsurePrivate(migrationDir); err != nil {
228- return OpenResult{}, err
229- }
230- backup := filepath.Join(migrationDir, migrationBackupName)
231- source, err := os.ReadFile(StorePath(deps.StateDir))
232- if err != nil {
233- return OpenResult{}, err
234- }
235- if err := atomicMigrationWrite(backup, source); err != nil {
236- return OpenResult{}, err
237- }
238- if err := migrationBoundary(deps, "backup-sync"); err != nil {
239- return OpenResult{}, err
240- }
241- record := MigrationRecord{Phase: MigrationBackedUp, Assignments: map[string]domain.IncarnationID{}, Validated: map[string]*domain.CheckpointRef{}, Degraded: map[string]string{}}
242- if err := writeMigrationRecord(filepath.Join(migrationDir, migrationIntentName), record); err != nil {
243- return OpenResult{}, err
244- }
245- if err := migrationBoundary(deps, "intent-sync"); err != nil {
246- return OpenResult{}, err
247- }
248- return resumeLegacyMigration(ctx, deps)
249-}
250-
251-func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, error) {
252- migrationDir := filepath.Join(deps.StateDir, migrationDirName)
253- intent := filepath.Join(migrationDir, migrationIntentName)
254- record, err := readMigrationRecord(intent)
255- if err != nil {
256- return OpenResult{}, err
257- }
258- backup := filepath.Join(migrationDir, migrationBackupName)
259- legacyData, err := kv.Replay(backup)
260- if err != nil {
261- return OpenResult{}, fmt.Errorf("persist: replay legacy backup: %w", err)
262- }
263- legacyRecords, err := decodeLegacyMap(legacyData)
264- if err != nil {
265- return OpenResult{}, err
266- }
267- if record.Phase < MigrationAssigned {
268- for _, legacy := range legacyRecords {
269- if _, ok := record.Assignments[legacy.Name]; ok {
270- continue
271- }
272- id, err := domain.NewIncarnationID(deps.Random)
273- if err != nil {
274- return OpenResult{}, err
275- }
276- record.Assignments[legacy.Name] = id
277- if err := writeMigrationRecord(intent, record); err != nil {
278- return OpenResult{}, err
279- }
280- if err := migrationBoundary(deps, "identity-sync"); err != nil {
281- return OpenResult{}, err
282- }
283- }
284- record.Phase = MigrationAssigned
285- if err := writeMigrationRecord(intent, record); err != nil {
286- return OpenResult{}, err
287- }
288- }
289- if record.Phase < MigrationValidated {
290- for _, legacy := range legacyRecords {
291- if _, ok := record.Validated[legacy.Name]; ok {
292- continue
293- }
294- if _, ok := record.Degraded[legacy.Name]; ok {
295- continue
296- }
297- ref, err := deps.SnapshotMigration.ReadLegacyHEAD(ctx, legacy.Name)
298- if err == nil {
299- var migrated domain.CheckpointRef
300- migrated, err = deps.SnapshotMigration.MigrateV1Checkpoint(ctx, ports.SnapshotMigrationRequest{LegacyName: legacy.Name, IncarnationID: record.Assignments[legacy.Name], LegacyRef: ref})
301- if err == nil {
302- record.Validated[legacy.Name] = &migrated
303- }
304- }
305- if err != nil {
306- if !errors.Is(err, ports.ErrLegacySnapshotUncertain) {
307- return OpenResult{}, fmt.Errorf("persist: migrate legacy snapshot %q: %w", legacy.Name, err)
308- }
309- migrationLogger(deps).Error("legacy snapshot migration uncertain", "session", legacy.Name, "err", err)
310- if ref.Generation != 0 && ref.ManifestDigest != ([32]byte{}) {
311- legacyRef := ref
312- record.Validated[legacy.Name] = &legacyRef
313- }
314- record.Degraded[legacy.Name] = migrationDegradedLegacySnapshotUncertain
315- }
316- if err := writeMigrationRecord(intent, record); err != nil {
317- return OpenResult{}, err
318- }
319- if err := migrationBoundary(deps, "head-validation"); err != nil {
320- return OpenResult{}, err
321- }
322- }
323- record.Phase = MigrationValidated
324- if err := writeMigrationRecord(intent, record); err != nil {
325- return OpenResult{}, err
326- }
327- }
328- records := make([]domain.CatalogueRecord, 0, len(legacyRecords))
329- for _, legacy := range legacyRecords {
330- next := domain.CatalogueRecord{Name: legacy.Name, IncarnationID: record.Assignments[legacy.Name], Cwd: legacy.Cwd, CreatedAt: legacy.CreatedAt, UpdatedAt: legacy.UpdatedAt, LastUsedSeq: legacy.LastUsedSeq, TabNames: append([]string(nil), legacy.TabNames...)}
331- if ref := record.Validated[legacy.Name]; ref != nil {
332- copyRef := *ref
333- next.Committed = &copyRef
334- }
335- if reason := record.Degraded[legacy.Name]; reason != "" {
336- next.RecoveryState = domain.RecoveryDegraded
337- next.DegradedReason = reason
338- } else {
339- next.RecoveryState = domain.RecoveryHealthy
340- }
341- records = append(records, next)
342- }
343- if record.Phase < MigrationInstalled {
344- if err := installMigratedCatalogue(deps.StateDir, records); err != nil {
345- return OpenResult{}, err
346- }
347- if err := migrationBoundary(deps, "catalogue-sync"); err != nil {
348- return OpenResult{}, err
349- }
350- record.Phase = MigrationInstalled
351- if err := writeMigrationRecord(intent, record); err != nil {
352- return OpenResult{}, err
353- }
354- if err := migrationBoundary(deps, "receipt-sync"); err != nil {
355- return OpenResult{}, err
356- }
357- }
358- catalogue, opened, err := openCurrentCatalogue(deps.StateDir, false)
359- if err != nil {
360- return OpenResult{}, err
361- }
362- if !catalogueRecordsEqual(records, opened) {
363- _ = catalogue.Close()
364- return OpenResult{}, errors.New("persist: installed catalogue validation mismatch")
365- }
366- if record.Phase < MigrationComplete {
367- record.Phase = MigrationComplete
368- if err := writeMigrationRecord(intent, record); err != nil {
369- _ = catalogue.Close()
370- return OpenResult{}, err
371- }
372- if err := atomicMigrationWrite(filepath.Join(migrationDir, migrationCompleteName), []byte("complete\n")); err != nil {
373- _ = catalogue.Close()
374- return OpenResult{}, err
375- }
376- if err := migrationBoundary(deps, "complete-sync"); err != nil {
377- _ = catalogue.Close()
378- return OpenResult{}, err
379- }
380- }
381- return OpenResult{Catalogue: catalogue, Records: opened, Migrated: true}, nil
382-}
383-
384-func migrationLogger(deps OpenDeps) *slog.Logger {
385- if deps.Logger != nil {
386- return deps.Logger
387- }
388- return slog.Default()
389-}
390-
391-func migrationBoundary(deps OpenDeps, name string) error {
392- if deps.Fault != nil {
393- return deps.Fault(name)
394- }
395- return nil
396-}
397-
398-func installMigratedCatalogue(stateDir string, records []domain.CatalogueRecord) error {
399- stage := filepath.Join(stateDir, migrationDirName, "install")
400- _ = os.RemoveAll(stage)
401- if err := safedir.EnsurePrivate(stage); err != nil {
402- return err
403- }
404- p, _, err := openCurrentCatalogue(stage, true)
405- if err != nil {
406- return err
407- }
408- changes := make(map[string]*domain.CatalogueRecord, len(records))
409- for i := range records {
410- r := records[i]
411- changes[r.Name] = &r
412- }
413- if len(changes) > 0 {
414- if err := p.Apply(changes); err != nil {
415- _ = p.Close()
416- return err
417- }
418- }
419- if err := p.Close(); err != nil {
420- return err
421- }
422- staged, opened, err := openCurrentCatalogue(stage, false)
423- if err != nil {
424- return err
425- }
426- if !catalogueRecordsEqual(records, opened) {
427- _ = staged.Close()
428- return errors.New("persist: staged catalogue validation mismatch")
429- }
430- if err := staged.Close(); err != nil {
431- return err
432- }
433- source := StorePath(stage)
434- target := StorePath(stateDir)
435- if err := os.Rename(source, target); err != nil {
436- return err
437- }
438- if err := syncMigrationDir(stateDir); err != nil {
439- return err
440- }
441- return atomicMigrationWrite(filepath.Join(stateDir, migrationDirName, catalogueFormatName), []byte("v1\n"))
442-}
443-
444-func decodeLegacyMap(data map[string][]byte) ([]legacyCatalogueRecordV0, error) {
445- out := make([]legacyCatalogueRecordV0, 0, len(data))
446- for name, value := range data {
447- record, err := decodeLegacyRecordV0(name, value)
448- if err != nil {
449- return nil, err
450- }
451- out = append(out, record)
452- }
453- sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
454- return out, nil
455-}
456-func decodeLegacyRecordV0(name string, value []byte) (legacyCatalogueRecordV0, error) {
457- if err := domain.ValidateSessionName(name); err != nil {
458- return legacyCatalogueRecordV0{}, err
459- }
460- r := valueReader{data: value}
461- cwd, ok := r.str()
462- if !ok {
463- return legacyCatalogueRecordV0{}, errMalformedRecord
464- }
465- created, ok := r.u64()
466- if !ok {
467- return legacyCatalogueRecordV0{}, errMalformedRecord
468- }
469- updated, ok := r.u64()
470- if !ok {
471- return legacyCatalogueRecordV0{}, errMalformedRecord
472- }
473- record := legacyCatalogueRecordV0{Name: name, Cwd: cwd, CreatedAt: int64(created), UpdatedAt: int64(updated)}
474- if r.remaining() == 0 {
475- return record, nil
476- }
477- if record.LastUsedSeq, ok = r.u64(); !ok {
478- return legacyCatalogueRecordV0{}, errMalformedRecord
479- }
480- if r.remaining() == 0 {
481- return record, nil
482- }
483- count, ok := r.u32()
484- if !ok || uint64(count) > uint64(r.remaining())/4 {
485- return legacyCatalogueRecordV0{}, errMalformedRecord
486- }
487- for range count {
488- tab, ok := r.str()
489- if !ok {
490- return legacyCatalogueRecordV0{}, errMalformedRecord
491- }
492- record.TabNames = append(record.TabNames, tab)
493- }
494- if r.remaining() != 0 {
495- return legacyCatalogueRecordV0{}, errMalformedRecord
496- }
497- return record, nil
498-}
499-
500-func writeMigrationRecord(path string, record MigrationRecord) error {
501- encoded, err := encodeMigrationRecord(record)
502- if err != nil {
503- return err
504- }
505- return atomicMigrationWrite(path, encoded)
506-}
507-func encodeMigrationRecord(record MigrationRecord) ([]byte, error) {
508- out := append([]byte(migrationRecordMagic), byte(migrationRecordVersion>>8), byte(migrationRecordVersion), byte(record.Phase))
509- names := make([]string, 0, len(record.Assignments))
510- for name := range record.Assignments {
511- names = append(names, name)
512- }
513- sort.Strings(names)
514- out = binary.BigEndian.AppendUint32(out, uint32(len(names)))
515- for _, name := range names {
516- var err error
517- out, err = appendCheckedString(out, name)
518- if err != nil {
519- return nil, err
520- }
521- id := record.Assignments[name]
522- out = append(out, id[:]...)
523- if ref := record.Validated[name]; ref != nil {
524- out = append(out, 1)
525- out = binary.BigEndian.AppendUint64(out, ref.Generation)
526- out = append(out, ref.ManifestDigest[:]...)
527- } else {
528- out = append(out, 0)
529- }
530- out, err = appendCheckedString(out, record.Degraded[name])
531- if err != nil {
532- return nil, err
533- }
534- }
535- return out, nil
536-}
537-func readMigrationRecord(path string) (MigrationRecord, error) {
538- data, err := os.ReadFile(path)
539- if err != nil {
540- return MigrationRecord{}, err
541- }
542- if len(data) < 11 || string(data[:4]) != migrationRecordMagic || binary.BigEndian.Uint16(data[4:6]) != migrationRecordVersion {
543- return MigrationRecord{}, errors.New("persist: invalid migration journal")
544- }
545- record := MigrationRecord{Phase: MigrationPhase(data[6]), Assignments: map[string]domain.IncarnationID{}, Validated: map[string]*domain.CheckpointRef{}, Degraded: map[string]string{}}
546- r := valueReader{data: data[7:]}
547- count, ok := r.u32()
548- if !ok {
549- return MigrationRecord{}, errMalformedRecord
550- }
551- for range count {
552- name, ok := r.str()
553- if !ok {
554- return MigrationRecord{}, errMalformedRecord
555- }
556- idBytes, ok := r.take(16)
557- if !ok {
558- return MigrationRecord{}, errMalformedRecord
559- }
560- var id domain.IncarnationID
561- copy(id[:], idBytes)
562- if _, exists := record.Assignments[name]; exists || id == (domain.IncarnationID{}) {
563- return MigrationRecord{}, errMalformedRecord
564- }
565- record.Assignments[name] = id
566- present, ok := r.byte()
567- if !ok {
568- return MigrationRecord{}, errMalformedRecord
569- }
570- if present == 1 {
571- generation, ok := r.u64()
572- if !ok {
573- return MigrationRecord{}, errMalformedRecord
574- }
575- digest, ok := r.take(32)
576- if !ok {
577- return MigrationRecord{}, errMalformedRecord
578- }
579- ref := &domain.CheckpointRef{Generation: generation}
580- copy(ref.ManifestDigest[:], digest)
581- record.Validated[name] = ref
582- } else if present != 0 {
583- return MigrationRecord{}, errMalformedRecord
584- }
585- reason, ok := r.str()
586- if !ok {
587- return MigrationRecord{}, errMalformedRecord
588- }
589- if reason != "" {
590- record.Degraded[name] = reason
591- }
592- }
593- if r.remaining() != 0 || record.Phase < MigrationBackedUp || record.Phase > MigrationComplete {
594- return MigrationRecord{}, errMalformedRecord
595- }
596- return record, nil
597-}
598-func atomicMigrationWrite(path string, data []byte) (retErr error) {
599- dir := filepath.Dir(path)
600- if err := safedir.EnsurePrivate(dir); err != nil {
601- return err
602- }
603- tmp, err := os.CreateTemp(dir, ".migration-*")
604- if err != nil {
605- return err
606- }
607- tmpName := tmp.Name()
608- published := false
609- defer func() {
610- if !published {
611- // A failed publication must not leave a replayable partial intent.
612- retErr = errors.Join(retErr, os.Remove(tmpName))
613- }
614- }()
615- if err := tmp.Chmod(0o600); err != nil {
616- return errors.Join(err, tmp.Close())
617- }
618- if _, err := tmp.Write(data); err != nil {
619- return errors.Join(err, tmp.Close())
620- }
621- if err := tmp.Sync(); err != nil {
622- return errors.Join(err, tmp.Close())
623- }
624- if err := tmp.Close(); err != nil {
625- return err
626- }
627- if err := os.Rename(tmpName, path); err != nil {
628- return err
629- }
630- published = true
631- return syncMigrationDir(dir)
632-}
633-func syncMigrationDir(dir string) error {
634- f, err := os.Open(dir)
635- if err != nil {
636- return err
637- }
638- return errors.Join(f.Sync(), f.Close())
639-}
640-func catalogueRecordsEqual(a, b []domain.CatalogueRecord) bool {
641- if len(a) != len(b) {
642- return false
643- }
644- left := append([]domain.CatalogueRecord(nil), a...)
645- right := append([]domain.CatalogueRecord(nil), b...)
646- sort.Slice(left, func(i, j int) bool { return left[i].Name < left[j].Name })
647- sort.Slice(right, func(i, j int) bool { return right[i].Name < right[j].Name })
648- for i := range left {
649- if !left[i].Equal(right[i]) {
650- return false
651- }
652- }
653- return true
654-}
D internal/persist/migration_test.go +0 -381
@@ -1,381 +0,0 @@
1-package persist
2-
3-import (
4- "bytes"
5- "context"
6- "crypto/sha256"
7- "encoding/binary"
8- "errors"
9- "io"
10- "log/slog"
11- "os"
12- "path/filepath"
13- "strconv"
14- "strings"
15- "testing"
16-
17- "github.com/bnema/vev/internal/domain"
18- "github.com/bnema/vev/internal/ports"
19- "github.com/bnema/vev/pkg/kv"
20- "github.com/bnema/vev/pkg/safedir"
21- "github.com/stretchr/testify/require"
22-)
23-
24-type migrationStub struct {
25- legacy bool
26- probePaths []string
27- probeErr error
28- heads map[string]domain.CheckpointRef
29- migrateErr map[string]error
30- assignments map[string]domain.IncarnationID
31- sources map[string]string
32-}
33-
34-func (s *migrationStub) HasLegacyState(context.Context) (bool, error) {
35- if s.probeErr != nil || s.legacy {
36- return s.legacy, s.probeErr
37- }
38- for _, path := range s.probePaths {
39- if _, err := os.Stat(path); err == nil {
40- return true, nil
41- } else if !errors.Is(err, os.ErrNotExist) {
42- return false, err
43- }
44- }
45- return false, nil
46-}
47-func (s *migrationStub) ReadLegacyHEAD(_ context.Context, name string) (domain.CheckpointRef, error) {
48- if source := s.sources[name]; source != "" {
49- if _, err := os.Stat(source); err != nil {
50- return domain.CheckpointRef{}, err
51- }
52- }
53- ref, ok := s.heads[name]
54- if !ok {
55- return domain.CheckpointRef{}, errors.New("uncertain legacy checkpoint")
56- }
57- return ref, nil
58-}
59-func (s *migrationStub) MigrateV1Checkpoint(_ context.Context, req ports.SnapshotMigrationRequest) (domain.CheckpointRef, error) {
60- if source := s.sources[req.LegacyName]; source != "" {
61- if _, err := os.Stat(source); err != nil {
62- return domain.CheckpointRef{}, err
63- }
64- }
65- if err := s.migrateErr[req.LegacyName]; err != nil {
66- return domain.CheckpointRef{}, err
67- }
68- if s.assignments == nil {
69- s.assignments = map[string]domain.IncarnationID{}
70- }
71- s.assignments[req.LegacyName] = req.IncarnationID
72- ref := req.LegacyRef
73- ref.ManifestDigest = sha256.Sum256(append(ref.ManifestDigest[:], req.IncarnationID[:]...))
74- return ref, nil
75-}
76-
77-func encodeLegacyRecordV0(r legacyCatalogueRecordV0) []byte {
78- out := binary.BigEndian.AppendUint32(nil, uint32(len(r.Cwd)))
79- out = append(out, r.Cwd...)
80- out = binary.BigEndian.AppendUint64(out, uint64(r.CreatedAt))
81- out = binary.BigEndian.AppendUint64(out, uint64(r.UpdatedAt))
82- out = binary.BigEndian.AppendUint64(out, r.LastUsedSeq)
83- out = binary.BigEndian.AppendUint32(out, uint32(len(r.TabNames)))
84- for _, tab := range r.TabNames {
85- out = binary.BigEndian.AppendUint32(out, uint32(len(tab)))
86- out = append(out, tab...)
87- }
88- return out
89-}
90-
91-func writeLegacyCatalogue(t *testing.T, dir string, records ...legacyCatalogueRecordV0) string {
92- t.Helper()
93- require.NoError(t, os.Chmod(dir, 0o700))
94- require.NoError(t, safedir.EnsurePrivate(dir))
95- store, err := kv.Open(StorePath(dir))
96- require.NoError(t, err)
97- for _, record := range records {
98- require.NoError(t, store.Set([]byte(record.Name), encodeLegacyRecordV0(record)))
99- }
100- require.NoError(t, store.Sync())
101- require.NoError(t, store.Close())
102- return StorePath(dir)
103-}
104-
105-func TestMigrationExistingState(t *testing.T) {
106- dir := t.TempDir()
107- legacy := writeLegacyCatalogue(t, dir,
108- legacyCatalogueRecordV0{Name: "work", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 2, TabNames: []string{"one"}},
109- legacyCatalogueRecordV0{Name: "uncertain", Cwd: "/tmp", CreatedAt: 3, UpdatedAt: 4},
110- )
111- ref := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}
112- migration := &migrationStub{heads: map[string]domain.CheckpointRef{"work": ref, "uncertain": ref}, migrateErr: map[string]error{"uncertain": errors.Join(ports.ErrLegacySnapshotUncertain, errors.New("invalid legacy manifest"))}}
113- result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: migration})
114- require.NoError(t, err)
115- require.True(t, result.Migrated)
116- require.False(t, result.NewInstall)
117- require.FileExists(t, filepath.Join(dir, "migration", "legacy-catalogue.backup"))
118- require.FileExists(t, legacy)
119- require.Len(t, result.Records, 2)
120- require.Equal(t, domain.RecoveryHealthy, recordByName(t, result.Records, "work").RecoveryState)
121- require.Equal(t, domain.RecoveryDegraded, recordByName(t, result.Records, "uncertain").RecoveryState)
122- require.NoError(t, result.Catalogue.Close())
123- reopened, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: io.LimitReader(&countingReader{}, 0), SnapshotMigration: migration})
124- require.NoError(t, err)
125- require.False(t, reopened.Migrated, "completed migration is one-shot")
126- require.NoError(t, reopened.Catalogue.Close())
127-}
128-
129-func TestMigrationPreservesLargeGeneration(t *testing.T) {
130- dir := t.TempDir()
131- writeLegacyCatalogue(t, dir, legacyCatalogueRecordV0{Name: "large", Cwd: "/tmp"})
132- migration := &migrationStub{heads: map[string]domain.CheckpointRef{"large": {Generation: 5001, ManifestDigest: [32]byte{1}}}, migrateErr: map[string]error{}}
133- result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: migration})
134- require.NoError(t, err)
135- require.NoError(t, result.Catalogue.Close())
136- require.Equal(t, uint64(5001), result.Records[0].Committed.Generation)
137-}
138-
139-func TestMigrationResumeMatrix(t *testing.T) {
140- type crashPoint struct {
141- boundary string
142- occurrence int
143- record string
144- }
145- points := []crashPoint{
146- {boundary: "backup-sync", occurrence: 1},
147- {boundary: "intent-sync", occurrence: 1},
148- {boundary: "identity-sync", occurrence: 1, record: "alpha"},
149- {boundary: "identity-sync", occurrence: 2, record: "uncertain"},
150- {boundary: "identity-sync", occurrence: 3, record: "work"},
151- {boundary: "head-validation", occurrence: 1, record: "alpha"},
152- {boundary: "head-validation", occurrence: 2, record: "uncertain"},
153- {boundary: "head-validation", occurrence: 3, record: "work"},
154- {boundary: "catalogue-sync", occurrence: 1},
155- {boundary: "receipt-sync", occurrence: 1},
156- {boundary: "complete-sync", occurrence: 1},
157- }
158- for _, point := range points {
159- name := point.boundary + "-" + strconv.Itoa(point.occurrence)
160- if point.record != "" {
161- name += "-" + point.record
162- }
163- t.Run(name, func(t *testing.T) {
164- dir := t.TempDir()
165- legacyCatalogue := writeLegacyCatalogue(t, dir,
166- legacyCatalogueRecordV0{Name: "alpha", Cwd: "/tmp"},
167- legacyCatalogueRecordV0{Name: "uncertain", Cwd: "/tmp"},
168- legacyCatalogueRecordV0{Name: "work", Cwd: "/tmp"},
169- )
170- ref := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
171- heads := map[string]domain.CheckpointRef{"alpha": ref, "uncertain": ref, "work": ref}
172- migrationErrors := map[string]error{"uncertain": errors.Join(ports.ErrLegacySnapshotUncertain, errors.New("corrupt legacy manifest"))}
173- legacySnapshotDir := filepath.Join(dir, "legacy-snapshots")
174- require.NoError(t, os.Mkdir(legacySnapshotDir, 0o700))
175- legacySnapshotSources := make(map[string]string, 3)
176- for _, recordName := range []string{"alpha", "uncertain", "work"} {
177- path := filepath.Join(legacySnapshotDir, recordName+".snap")
178- require.NoError(t, os.WriteFile(path, []byte("legacy source"), 0o600))
179- legacySnapshotSources[recordName] = path
180- }
181- stub := &migrationStub{heads: heads, migrateErr: migrationErrors, sources: legacySnapshotSources}
182- seen := 0
183- _, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: stub, Fault: func(got string) error {
184- if got != point.boundary {
185- return nil
186- }
187- seen++
188- if seen == point.occurrence {
189- return errors.New("simulated crash")
190- }
191- return nil
192- }})
193- require.Error(t, err)
194- require.Equal(t, point.occurrence, seen)
195-
196- before := map[string]domain.IncarnationID{}
197- intentPath := filepath.Join(dir, migrationDirName, migrationIntentName)
198- if journal, journalErr := readMigrationRecord(intentPath); journalErr == nil {
199- for recordName, id := range journal.Assignments {
200- before[recordName] = id
201- }
202- }
203- restarted := &migrationStub{heads: heads, migrateErr: migrationErrors, sources: legacySnapshotSources}
204- result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{next: 64}, SnapshotMigration: restarted})
205- require.NoError(t, err)
206- completed, err := readMigrationRecord(intentPath)
207- require.NoError(t, err)
208- require.Len(t, completed.Assignments, 3)
209- for recordName, id := range before {
210- require.Equal(t, id, completed.Assignments[recordName], "assigned identities must survive restart")
211- }
212- for _, recordName := range []string{"alpha", "uncertain", "work"} {
213- require.Equal(t, completed.Assignments[recordName], recordByName(t, result.Records, recordName).IncarnationID)
214- require.FileExists(t, legacySnapshotSources[recordName], "legacy snapshot source must remain retained")
215- }
216- uncertain := recordByName(t, result.Records, "uncertain")
217- require.Equal(t, domain.RecoveryDegraded, uncertain.RecoveryState)
218- require.Equal(t, migrationDegradedLegacySnapshotUncertain, uncertain.DegradedReason)
219- require.NoError(t, result.Catalogue.Close())
220-
221- reopened, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: io.LimitReader(&countingReader{}, 0), SnapshotMigration: restarted})
222- require.NoError(t, err)
223- for _, recordName := range []string{"alpha", "uncertain", "work"} {
224- require.Equal(t, completed.Assignments[recordName], recordByName(t, reopened.Records, recordName).IncarnationID)
225- }
226- require.Equal(t, domain.RecoveryDegraded, recordByName(t, reopened.Records, "uncertain").RecoveryState)
227- require.NoError(t, reopened.Catalogue.Close())
228- require.FileExists(t, legacyCatalogue)
229- require.FileExists(t, filepath.Join(dir, migrationDirName, migrationBackupName))
230- require.FileExists(t, filepath.Join(dir, migrationDirName, migrationCompleteName))
231- })
232- }
233-}
234-
235-func TestMigrationPreservesUncertain(t *testing.T) {
236- dir := t.TempDir()
237- legacy := writeLegacyCatalogue(t, dir, legacyCatalogueRecordV0{Name: "broken", Cwd: "/tmp"})
238- ref := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
239- adapterCause := strings.Repeat("sensitive-adapter-detail", 64)
240- var logs bytes.Buffer
241- logger := slog.New(slog.NewTextHandler(&logs, nil))
242- result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: &migrationStub{heads: map[string]domain.CheckpointRef{"broken": ref}, migrateErr: map[string]error{"broken": errors.Join(ports.ErrLegacySnapshotUncertain, errors.New(adapterCause))}}, Logger: logger})
243- require.NoError(t, err)
244- require.NoError(t, result.Catalogue.Close())
245- require.FileExists(t, legacy)
246- require.Equal(t, domain.RecoveryDegraded, result.Records[0].RecoveryState)
247- require.Equal(t, migrationDegradedLegacySnapshotUncertain, result.Records[0].DegradedReason)
248- require.LessOrEqual(t, len(result.Records[0].DegradedReason), migrationDegradedReasonMaxBytes)
249- require.NotContains(t, result.Records[0].DegradedReason, adapterCause)
250- require.Contains(t, logs.String(), adapterCause, "the full adapter cause must remain diagnosable")
251-}
252-
253-func TestMigrationFailsClosedOnSnapshotError(t *testing.T) {
254- for _, tc := range []struct {
255- name string
256- err error
257- }{
258- {name: "repository unavailable", err: errors.New("repository unavailable")},
259- } {
260- t.Run(tc.name, func(t *testing.T) {
261- dir := t.TempDir()
262- writeLegacyCatalogue(t, dir, legacyCatalogueRecordV0{Name: "work", Cwd: "/tmp"})
263- ref := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
264- _, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: &migrationStub{heads: map[string]domain.CheckpointRef{"work": ref}, migrateErr: map[string]error{"work": tc.err}}})
265- require.ErrorContains(t, err, tc.err.Error())
266- data, replayErr := kv.Replay(StorePath(dir))
267- require.NoError(t, replayErr)
268- _, legacyFormat, classifyErr := classifyCatalogue(data)
269- require.NoError(t, classifyErr)
270- require.True(t, legacyFormat, "migration-port failure must not install a new catalogue")
271- })
272- }
273-}
274-
275-func TestOpenOrMigrateLegacyProbe(t *testing.T) {
276- probeErr := errors.New("probe uncertain")
277- for _, tc := range []struct {
278- name string
279- stub *migrationStub
280- arrange func(t *testing.T, dir string, stub *migrationStub)
281- wantNew bool
282- wantLegacy bool
283- wantErr error
284- }{
285- {name: "proven absent", stub: &migrationStub{}, wantNew: true},
286- {name: "legacy blob", stub: &migrationStub{}, wantLegacy: true, arrange: func(t *testing.T, dir string, stub *migrationStub) {
287- path := filepath.Join(dir, "legacy.snapshot")
288- require.NoError(t, os.WriteFile(path, []byte("legacy"), 0o600))
289- stub.probePaths = []string{path}
290- }},
291- {name: "incremental namespace", stub: &migrationStub{}, wantLegacy: true, arrange: func(t *testing.T, dir string, stub *migrationStub) {
292- path := filepath.Join(dir, "snapshots")
293- require.NoError(t, safedir.EnsurePrivate(path))
294- stub.probePaths = []string{path}
295- }},
296- {name: "probe error", stub: &migrationStub{probeErr: probeErr}, wantErr: probeErr},
297- } {
298- t.Run(tc.name, func(t *testing.T) {
299- dir := t.TempDir()
300- require.NoError(t, os.Chmod(dir, 0o700))
301- if tc.arrange != nil {
302- tc.arrange(t, dir, tc.stub)
303- }
304- result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: tc.stub})
305- if tc.wantErr != nil {
306- require.ErrorIs(t, err, tc.wantErr)
307- } else if tc.wantLegacy {
308- require.Error(t, err)
309- } else {
310- require.NoError(t, err)
311- require.Equal(t, tc.wantNew, result.NewInstall)
312- require.NoError(t, result.Catalogue.Close())
313- if tc.wantNew {
314- reopened, reopenErr := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: tc.stub})
315- require.NoError(t, reopenErr)
316- require.False(t, reopened.NewInstall)
317- require.False(t, reopened.Migrated)
318- require.NoError(t, reopened.Catalogue.Close())
319- }
320- }
321- if tc.wantLegacy || tc.wantErr != nil {
322- _, statErr := os.Stat(StorePath(dir))
323- require.ErrorIs(t, statErr, os.ErrNotExist)
324- }
325- })
326- }
327-}
328-
329-func TestCatalogueRecordsEqualIsCompleteAndNonMutating(t *testing.T) {
330- committed := &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}}
331- left := []domain.CatalogueRecord{
332- {Name: "z", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3, TabNames: []string{"shell"}, RecoveryState: domain.RecoveryHealthy, Committed: committed},
333- validRecord("a", 2),
334- }
335- right := []domain.CatalogueRecord{left[1], left[0]}
336- leftOrder, rightOrder := left[0].Name, right[0].Name
337- require.True(t, catalogueRecordsEqual(left, right))
338- require.Equal(t, leftOrder, left[0].Name)
339- require.Equal(t, rightOrder, right[0].Name)
340-
341- right[1].Cwd = "/different"
342- require.False(t, catalogueRecordsEqual(left, right), "installed catalogue validation must compare every record field")
343-}
344-
345-func TestAtomicMigrationWriteRemovesTempAfterRenameFailure(t *testing.T) {
346- dir := t.TempDir()
347- target := filepath.Join(dir, "target")
348- require.NoError(t, os.Mkdir(target, 0o700))
349-
350- err := atomicMigrationWrite(target, []byte("intent"))
351- require.Error(t, err)
352-
353- entries, readErr := os.ReadDir(dir)
354- require.NoError(t, readErr)
355- for _, entry := range entries {
356- require.NotContains(t, entry.Name(), ".migration-", "failed publication must remove its temporary intent")
357- }
358-}
359-
360-type countingReader struct{ next byte }
361-
362-func (r *countingReader) Read(p []byte) (int, error) {
363- for i := range p {
364- r.next++
365- if r.next == 0 {
366- r.next = 1
367- }
368- p[i] = r.next
369- }
370- return len(p), nil
371-}
372-func recordByName(t *testing.T, records []domain.CatalogueRecord, name string) domain.CatalogueRecord {
373- t.Helper()
374- for _, r := range records {
375- if r.Name == name {
376- return r
377- }
378- }
379- t.Fatalf("record %q not found", name)
380- return domain.CatalogueRecord{}
381-}
@@ -1,381 +0,0 @@
1-package persist
2-
3-import (
4- "bytes"
5- "context"
6- "crypto/sha256"
7- "encoding/binary"
8- "errors"
9- "io"
10- "log/slog"
11- "os"
12- "path/filepath"
13- "strconv"
14- "strings"
15- "testing"
16-
17- "github.com/bnema/vev/internal/domain"
18- "github.com/bnema/vev/internal/ports"
19- "github.com/bnema/vev/pkg/kv"
20- "github.com/bnema/vev/pkg/safedir"
21- "github.com/stretchr/testify/require"
22-)
23-
24-type migrationStub struct {
25- legacy bool
26- probePaths []string
27- probeErr error
28- heads map[string]domain.CheckpointRef
29- migrateErr map[string]error
30- assignments map[string]domain.IncarnationID
31- sources map[string]string
32-}
33-
34-func (s *migrationStub) HasLegacyState(context.Context) (bool, error) {
35- if s.probeErr != nil || s.legacy {
36- return s.legacy, s.probeErr
37- }
38- for _, path := range s.probePaths {
39- if _, err := os.Stat(path); err == nil {
40- return true, nil
41- } else if !errors.Is(err, os.ErrNotExist) {
42- return false, err
43- }
44- }
45- return false, nil
46-}
47-func (s *migrationStub) ReadLegacyHEAD(_ context.Context, name string) (domain.CheckpointRef, error) {
48- if source := s.sources[name]; source != "" {
49- if _, err := os.Stat(source); err != nil {
50- return domain.CheckpointRef{}, err
51- }
52- }
53- ref, ok := s.heads[name]
54- if !ok {
55- return domain.CheckpointRef{}, errors.New("uncertain legacy checkpoint")
56- }
57- return ref, nil
58-}
59-func (s *migrationStub) MigrateV1Checkpoint(_ context.Context, req ports.SnapshotMigrationRequest) (domain.CheckpointRef, error) {
60- if source := s.sources[req.LegacyName]; source != "" {
61- if _, err := os.Stat(source); err != nil {
62- return domain.CheckpointRef{}, err
63- }
64- }
65- if err := s.migrateErr[req.LegacyName]; err != nil {
66- return domain.CheckpointRef{}, err
67- }
68- if s.assignments == nil {
69- s.assignments = map[string]domain.IncarnationID{}
70- }
71- s.assignments[req.LegacyName] = req.IncarnationID
72- ref := req.LegacyRef
73- ref.ManifestDigest = sha256.Sum256(append(ref.ManifestDigest[:], req.IncarnationID[:]...))
74- return ref, nil
75-}
76-
77-func encodeLegacyRecordV0(r legacyCatalogueRecordV0) []byte {
78- out := binary.BigEndian.AppendUint32(nil, uint32(len(r.Cwd)))
79- out = append(out, r.Cwd...)
80- out = binary.BigEndian.AppendUint64(out, uint64(r.CreatedAt))
81- out = binary.BigEndian.AppendUint64(out, uint64(r.UpdatedAt))
82- out = binary.BigEndian.AppendUint64(out, r.LastUsedSeq)
83- out = binary.BigEndian.AppendUint32(out, uint32(len(r.TabNames)))
84- for _, tab := range r.TabNames {
85- out = binary.BigEndian.AppendUint32(out, uint32(len(tab)))
86- out = append(out, tab...)
87- }
88- return out
89-}
90-
91-func writeLegacyCatalogue(t *testing.T, dir string, records ...legacyCatalogueRecordV0) string {
92- t.Helper()
93- require.NoError(t, os.Chmod(dir, 0o700))
94- require.NoError(t, safedir.EnsurePrivate(dir))
95- store, err := kv.Open(StorePath(dir))
96- require.NoError(t, err)
97- for _, record := range records {
98- require.NoError(t, store.Set([]byte(record.Name), encodeLegacyRecordV0(record)))
99- }
100- require.NoError(t, store.Sync())
101- require.NoError(t, store.Close())
102- return StorePath(dir)
103-}
104-
105-func TestMigrationExistingState(t *testing.T) {
106- dir := t.TempDir()
107- legacy := writeLegacyCatalogue(t, dir,
108- legacyCatalogueRecordV0{Name: "work", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 2, TabNames: []string{"one"}},
109- legacyCatalogueRecordV0{Name: "uncertain", Cwd: "/tmp", CreatedAt: 3, UpdatedAt: 4},
110- )
111- ref := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}
112- migration := &migrationStub{heads: map[string]domain.CheckpointRef{"work": ref, "uncertain": ref}, migrateErr: map[string]error{"uncertain": errors.Join(ports.ErrLegacySnapshotUncertain, errors.New("invalid legacy manifest"))}}
113- result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: migration})
114- require.NoError(t, err)
115- require.True(t, result.Migrated)
116- require.False(t, result.NewInstall)
117- require.FileExists(t, filepath.Join(dir, "migration", "legacy-catalogue.backup"))
118- require.FileExists(t, legacy)
119- require.Len(t, result.Records, 2)
120- require.Equal(t, domain.RecoveryHealthy, recordByName(t, result.Records, "work").RecoveryState)
121- require.Equal(t, domain.RecoveryDegraded, recordByName(t, result.Records, "uncertain").RecoveryState)
122- require.NoError(t, result.Catalogue.Close())
123- reopened, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: io.LimitReader(&countingReader{}, 0), SnapshotMigration: migration})
124- require.NoError(t, err)
125- require.False(t, reopened.Migrated, "completed migration is one-shot")
126- require.NoError(t, reopened.Catalogue.Close())
127-}
128-
129-func TestMigrationPreservesLargeGeneration(t *testing.T) {
130- dir := t.TempDir()
131- writeLegacyCatalogue(t, dir, legacyCatalogueRecordV0{Name: "large", Cwd: "/tmp"})
132- migration := &migrationStub{heads: map[string]domain.CheckpointRef{"large": {Generation: 5001, ManifestDigest: [32]byte{1}}}, migrateErr: map[string]error{}}
133- result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: migration})
134- require.NoError(t, err)
135- require.NoError(t, result.Catalogue.Close())
136- require.Equal(t, uint64(5001), result.Records[0].Committed.Generation)
137-}
138-
139-func TestMigrationResumeMatrix(t *testing.T) {
140- type crashPoint struct {
141- boundary string
142- occurrence int
143- record string
144- }
145- points := []crashPoint{
146- {boundary: "backup-sync", occurrence: 1},
147- {boundary: "intent-sync", occurrence: 1},
148- {boundary: "identity-sync", occurrence: 1, record: "alpha"},
149- {boundary: "identity-sync", occurrence: 2, record: "uncertain"},
150- {boundary: "identity-sync", occurrence: 3, record: "work"},
151- {boundary: "head-validation", occurrence: 1, record: "alpha"},
152- {boundary: "head-validation", occurrence: 2, record: "uncertain"},
153- {boundary: "head-validation", occurrence: 3, record: "work"},
154- {boundary: "catalogue-sync", occurrence: 1},
155- {boundary: "receipt-sync", occurrence: 1},
156- {boundary: "complete-sync", occurrence: 1},
157- }
158- for _, point := range points {
159- name := point.boundary + "-" + strconv.Itoa(point.occurrence)
160- if point.record != "" {
161- name += "-" + point.record
162- }
163- t.Run(name, func(t *testing.T) {
164- dir := t.TempDir()
165- legacyCatalogue := writeLegacyCatalogue(t, dir,
166- legacyCatalogueRecordV0{Name: "alpha", Cwd: "/tmp"},
167- legacyCatalogueRecordV0{Name: "uncertain", Cwd: "/tmp"},
168- legacyCatalogueRecordV0{Name: "work", Cwd: "/tmp"},
169- )
170- ref := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
171- heads := map[string]domain.CheckpointRef{"alpha": ref, "uncertain": ref, "work": ref}
172- migrationErrors := map[string]error{"uncertain": errors.Join(ports.ErrLegacySnapshotUncertain, errors.New("corrupt legacy manifest"))}
173- legacySnapshotDir := filepath.Join(dir, "legacy-snapshots")
174- require.NoError(t, os.Mkdir(legacySnapshotDir, 0o700))
175- legacySnapshotSources := make(map[string]string, 3)
176- for _, recordName := range []string{"alpha", "uncertain", "work"} {
177- path := filepath.Join(legacySnapshotDir, recordName+".snap")
178- require.NoError(t, os.WriteFile(path, []byte("legacy source"), 0o600))
179- legacySnapshotSources[recordName] = path
180- }
181- stub := &migrationStub{heads: heads, migrateErr: migrationErrors, sources: legacySnapshotSources}
182- seen := 0
183- _, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: stub, Fault: func(got string) error {
184- if got != point.boundary {
185- return nil
186- }
187- seen++
188- if seen == point.occurrence {
189- return errors.New("simulated crash")
190- }
191- return nil
192- }})
193- require.Error(t, err)
194- require.Equal(t, point.occurrence, seen)
195-
196- before := map[string]domain.IncarnationID{}
197- intentPath := filepath.Join(dir, migrationDirName, migrationIntentName)
198- if journal, journalErr := readMigrationRecord(intentPath); journalErr == nil {
199- for recordName, id := range journal.Assignments {
200- before[recordName] = id
201- }
202- }
203- restarted := &migrationStub{heads: heads, migrateErr: migrationErrors, sources: legacySnapshotSources}
204- result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{next: 64}, SnapshotMigration: restarted})
205- require.NoError(t, err)
206- completed, err := readMigrationRecord(intentPath)
207- require.NoError(t, err)
208- require.Len(t, completed.Assignments, 3)
209- for recordName, id := range before {
210- require.Equal(t, id, completed.Assignments[recordName], "assigned identities must survive restart")
211- }
212- for _, recordName := range []string{"alpha", "uncertain", "work"} {
213- require.Equal(t, completed.Assignments[recordName], recordByName(t, result.Records, recordName).IncarnationID)
214- require.FileExists(t, legacySnapshotSources[recordName], "legacy snapshot source must remain retained")
215- }
216- uncertain := recordByName(t, result.Records, "uncertain")
217- require.Equal(t, domain.RecoveryDegraded, uncertain.RecoveryState)
218- require.Equal(t, migrationDegradedLegacySnapshotUncertain, uncertain.DegradedReason)
219- require.NoError(t, result.Catalogue.Close())
220-
221- reopened, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: io.LimitReader(&countingReader{}, 0), SnapshotMigration: restarted})
222- require.NoError(t, err)
223- for _, recordName := range []string{"alpha", "uncertain", "work"} {
224- require.Equal(t, completed.Assignments[recordName], recordByName(t, reopened.Records, recordName).IncarnationID)
225- }
226- require.Equal(t, domain.RecoveryDegraded, recordByName(t, reopened.Records, "uncertain").RecoveryState)
227- require.NoError(t, reopened.Catalogue.Close())
228- require.FileExists(t, legacyCatalogue)
229- require.FileExists(t, filepath.Join(dir, migrationDirName, migrationBackupName))
230- require.FileExists(t, filepath.Join(dir, migrationDirName, migrationCompleteName))
231- })
232- }
233-}
234-
235-func TestMigrationPreservesUncertain(t *testing.T) {
236- dir := t.TempDir()
237- legacy := writeLegacyCatalogue(t, dir, legacyCatalogueRecordV0{Name: "broken", Cwd: "/tmp"})
238- ref := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
239- adapterCause := strings.Repeat("sensitive-adapter-detail", 64)
240- var logs bytes.Buffer
241- logger := slog.New(slog.NewTextHandler(&logs, nil))
242- result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: &migrationStub{heads: map[string]domain.CheckpointRef{"broken": ref}, migrateErr: map[string]error{"broken": errors.Join(ports.ErrLegacySnapshotUncertain, errors.New(adapterCause))}}, Logger: logger})
243- require.NoError(t, err)
244- require.NoError(t, result.Catalogue.Close())
245- require.FileExists(t, legacy)
246- require.Equal(t, domain.RecoveryDegraded, result.Records[0].RecoveryState)
247- require.Equal(t, migrationDegradedLegacySnapshotUncertain, result.Records[0].DegradedReason)
248- require.LessOrEqual(t, len(result.Records[0].DegradedReason), migrationDegradedReasonMaxBytes)
249- require.NotContains(t, result.Records[0].DegradedReason, adapterCause)
250- require.Contains(t, logs.String(), adapterCause, "the full adapter cause must remain diagnosable")
251-}
252-
253-func TestMigrationFailsClosedOnSnapshotError(t *testing.T) {
254- for _, tc := range []struct {
255- name string
256- err error
257- }{
258- {name: "repository unavailable", err: errors.New("repository unavailable")},
259- } {
260- t.Run(tc.name, func(t *testing.T) {
261- dir := t.TempDir()
262- writeLegacyCatalogue(t, dir, legacyCatalogueRecordV0{Name: "work", Cwd: "/tmp"})
263- ref := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
264- _, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: &migrationStub{heads: map[string]domain.CheckpointRef{"work": ref}, migrateErr: map[string]error{"work": tc.err}}})
265- require.ErrorContains(t, err, tc.err.Error())
266- data, replayErr := kv.Replay(StorePath(dir))
267- require.NoError(t, replayErr)
268- _, legacyFormat, classifyErr := classifyCatalogue(data)
269- require.NoError(t, classifyErr)
270- require.True(t, legacyFormat, "migration-port failure must not install a new catalogue")
271- })
272- }
273-}
274-
275-func TestOpenOrMigrateLegacyProbe(t *testing.T) {
276- probeErr := errors.New("probe uncertain")
277- for _, tc := range []struct {
278- name string
279- stub *migrationStub
280- arrange func(t *testing.T, dir string, stub *migrationStub)
281- wantNew bool
282- wantLegacy bool
283- wantErr error
284- }{
285- {name: "proven absent", stub: &migrationStub{}, wantNew: true},
286- {name: "legacy blob", stub: &migrationStub{}, wantLegacy: true, arrange: func(t *testing.T, dir string, stub *migrationStub) {
287- path := filepath.Join(dir, "legacy.snapshot")
288- require.NoError(t, os.WriteFile(path, []byte("legacy"), 0o600))
289- stub.probePaths = []string{path}
290- }},
291- {name: "incremental namespace", stub: &migrationStub{}, wantLegacy: true, arrange: func(t *testing.T, dir string, stub *migrationStub) {
292- path := filepath.Join(dir, "snapshots")
293- require.NoError(t, safedir.EnsurePrivate(path))
294- stub.probePaths = []string{path}
295- }},
296- {name: "probe error", stub: &migrationStub{probeErr: probeErr}, wantErr: probeErr},
297- } {
298- t.Run(tc.name, func(t *testing.T) {
299- dir := t.TempDir()
300- require.NoError(t, os.Chmod(dir, 0o700))
301- if tc.arrange != nil {
302- tc.arrange(t, dir, tc.stub)
303- }
304- result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: tc.stub})
305- if tc.wantErr != nil {
306- require.ErrorIs(t, err, tc.wantErr)
307- } else if tc.wantLegacy {
308- require.Error(t, err)
309- } else {
310- require.NoError(t, err)
311- require.Equal(t, tc.wantNew, result.NewInstall)
312- require.NoError(t, result.Catalogue.Close())
313- if tc.wantNew {
314- reopened, reopenErr := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: tc.stub})
315- require.NoError(t, reopenErr)
316- require.False(t, reopened.NewInstall)
317- require.False(t, reopened.Migrated)
318- require.NoError(t, reopened.Catalogue.Close())
319- }
320- }
321- if tc.wantLegacy || tc.wantErr != nil {
322- _, statErr := os.Stat(StorePath(dir))
323- require.ErrorIs(t, statErr, os.ErrNotExist)
324- }
325- })
326- }
327-}
328-
329-func TestCatalogueRecordsEqualIsCompleteAndNonMutating(t *testing.T) {
330- committed := &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}}
331- left := []domain.CatalogueRecord{
332- {Name: "z", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3, TabNames: []string{"shell"}, RecoveryState: domain.RecoveryHealthy, Committed: committed},
333- validRecord("a", 2),
334- }
335- right := []domain.CatalogueRecord{left[1], left[0]}
336- leftOrder, rightOrder := left[0].Name, right[0].Name
337- require.True(t, catalogueRecordsEqual(left, right))
338- require.Equal(t, leftOrder, left[0].Name)
339- require.Equal(t, rightOrder, right[0].Name)
340-
341- right[1].Cwd = "/different"
342- require.False(t, catalogueRecordsEqual(left, right), "installed catalogue validation must compare every record field")
343-}
344-
345-func TestAtomicMigrationWriteRemovesTempAfterRenameFailure(t *testing.T) {
346- dir := t.TempDir()
347- target := filepath.Join(dir, "target")
348- require.NoError(t, os.Mkdir(target, 0o700))
349-
350- err := atomicMigrationWrite(target, []byte("intent"))
351- require.Error(t, err)
352-
353- entries, readErr := os.ReadDir(dir)
354- require.NoError(t, readErr)
355- for _, entry := range entries {
356- require.NotContains(t, entry.Name(), ".migration-", "failed publication must remove its temporary intent")
357- }
358-}
359-
360-type countingReader struct{ next byte }
361-
362-func (r *countingReader) Read(p []byte) (int, error) {
363- for i := range p {
364- r.next++
365- if r.next == 0 {
366- r.next = 1
367- }
368- p[i] = r.next
369- }
370- return len(p), nil
371-}
372-func recordByName(t *testing.T, records []domain.CatalogueRecord, name string) domain.CatalogueRecord {
373- t.Helper()
374- for _, r := range records {
375- if r.Name == name {
376- return r
377- }
378- }
379- t.Fatalf("record %q not found", name)
380- return domain.CatalogueRecord{}
381-}
M internal/persist/persist.go +56 -0
@@ -3,6 +3,7 @@ package persist
33 import (
44 "errors"
55 "fmt"
6+ "os"
67 "path/filepath"
78 "sort"
89 "sync"
@@ -16,6 +17,17 @@ const filename = "sessions.kv"
1617
1718 var errPersistenceUnavailable = errors.New("persist: catalogue unavailable")
1819
20+// ErrCatalogueUnreadable reports durable state that exists but cannot be
21+// decoded. vev never repairs or erases it; the operator resets explicitly.
22+var ErrCatalogueUnreadable = errors.New("persist: catalogue unreadable")
23+
24+// OpenResult is the outcome of opening durable session state at startup.
25+type OpenResult struct {
26+ Catalogue *Persister
27+ Records []domain.CatalogueRecord
28+ NewInstall bool
29+}
30+
1931 func StorePath(dir string) string { return filepath.Join(dir, filename) }
2032
2133 type Persister struct {
@@ -59,8 +71,52 @@ func Open(dir string) (*Persister, error) {
5971 p, _, err := openCurrentCatalogue(dir, false)
6072 return p, err
6173 }
74+
75+// OpenOrCreate opens the catalogue, or creates a proven-empty one when no
76+// durable state exists. Any state that exists but does not decode is returned
77+// as ErrCatalogueUnreadable and left untouched on disk.
78+func OpenOrCreate(dir string) (OpenResult, error) {
79+ existed := true
80+ if _, err := os.Stat(StorePath(dir)); errors.Is(err, os.ErrNotExist) {
81+ existed = false
82+ }
83+ path := StorePath(dir)
84+ if existed {
85+ raw, err := os.ReadFile(path)
86+ if err != nil {
87+ return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
88+ }
89+ if len(raw) < 4 || string(raw[:4]) != "VEVK" {
90+ return OpenResult{}, fmt.Errorf("%w: %s: unknown catalogue format", ErrCatalogueUnreadable, path)
91+ }
92+ data, err := kv.Replay(path)
93+ if err != nil {
94+ return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
95+ }
96+ if _, err := decodeAll(data); err != nil {
97+ return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
98+ }
99+ }
100+ catalogue, records, err := openCurrentCatalogue(dir, true)
101+ if err != nil {
102+ return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
103+ }
104+ return OpenResult{Catalogue: catalogue, Records: records, NewInstall: !existed}, nil
105+}
106+
62107 func New(store ports.Store) *Persister { return &Persister{store: store} }
63108
109+func catalogueCandidatesPresent(path string) (bool, error) {
110+ for _, candidate := range []string{path, path + ".next", path + ".prev"} {
111+ if _, err := os.Stat(candidate); err == nil {
112+ return true, nil
113+ } else if !errors.Is(err, os.ErrNotExist) {
114+ return false, err
115+ }
116+ }
117+ return false, nil
118+}
119+
64120 func openCurrentCatalogue(dir string, createProvenEmpty bool) (*Persister, []domain.CatalogueRecord, error) {
65121 path := StorePath(dir)
66122 present, err := catalogueCandidatesPresent(path)
@@ -3,6 +3,7 @@ package persist
3 import (3 import (
4 "errors"4 "errors"
5 "fmt"5 "fmt"
6+ "os"
6 "path/filepath"7 "path/filepath"
7 "sort"8 "sort"
8 "sync"9 "sync"
@@ -16,6 +17,17 @@ const filename = "sessions.kv"
16 17
17 var errPersistenceUnavailable = errors.New("persist: catalogue unavailable")18 var errPersistenceUnavailable = errors.New("persist: catalogue unavailable")
18 19
20+// ErrCatalogueUnreadable reports durable state that exists but cannot be
21+// decoded. vev never repairs or erases it; the operator resets explicitly.
22+var ErrCatalogueUnreadable = errors.New("persist: catalogue unreadable")
23+
24+// OpenResult is the outcome of opening durable session state at startup.
25+type OpenResult struct {
26+ Catalogue *Persister
27+ Records []domain.CatalogueRecord
28+ NewInstall bool
29+}
30+
19 func StorePath(dir string) string { return filepath.Join(dir, filename) }31 func StorePath(dir string) string { return filepath.Join(dir, filename) }
20 32
21 type Persister struct {33 type Persister struct {
@@ -59,8 +71,52 @@ func Open(dir string) (*Persister, error) {
59 p, _, err := openCurrentCatalogue(dir, false)71 p, _, err := openCurrentCatalogue(dir, false)
60 return p, err72 return p, err
61 }73 }
74+
75+// OpenOrCreate opens the catalogue, or creates a proven-empty one when no
76+// durable state exists. Any state that exists but does not decode is returned
77+// as ErrCatalogueUnreadable and left untouched on disk.
78+func OpenOrCreate(dir string) (OpenResult, error) {
79+ existed := true
80+ if _, err := os.Stat(StorePath(dir)); errors.Is(err, os.ErrNotExist) {
81+ existed = false
82+ }
83+ path := StorePath(dir)
84+ if existed {
85+ raw, err := os.ReadFile(path)
86+ if err != nil {
87+ return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
88+ }
89+ if len(raw) < 4 || string(raw[:4]) != "VEVK" {
90+ return OpenResult{}, fmt.Errorf("%w: %s: unknown catalogue format", ErrCatalogueUnreadable, path)
91+ }
92+ data, err := kv.Replay(path)
93+ if err != nil {
94+ return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
95+ }
96+ if _, err := decodeAll(data); err != nil {
97+ return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
98+ }
99+ }
100+ catalogue, records, err := openCurrentCatalogue(dir, true)
101+ if err != nil {
102+ return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
103+ }
104+ return OpenResult{Catalogue: catalogue, Records: records, NewInstall: !existed}, nil
105+}
106+
62 func New(store ports.Store) *Persister { return &Persister{store: store} }107 func New(store ports.Store) *Persister { return &Persister{store: store} }
63 108
109+func catalogueCandidatesPresent(path string) (bool, error) {
110+ for _, candidate := range []string{path, path + ".next", path + ".prev"} {
111+ if _, err := os.Stat(candidate); err == nil {
112+ return true, nil
113+ } else if !errors.Is(err, os.ErrNotExist) {
114+ return false, err
115+ }
116+ }
117+ return false, nil
118+}
119+
64 func openCurrentCatalogue(dir string, createProvenEmpty bool) (*Persister, []domain.CatalogueRecord, error) {120 func openCurrentCatalogue(dir string, createProvenEmpty bool) (*Persister, []domain.CatalogueRecord, error) {
65 path := StorePath(dir)121 path := StorePath(dir)
66 present, err := catalogueCandidatesPresent(path)122 present, err := catalogueCandidatesPresent(path)
M internal/persist/persist_test.go +37 -0
@@ -2,6 +2,7 @@ package persist
22
33 import (
44 "errors"
5+ "os"
56 "path/filepath"
67 "testing"
78
@@ -18,6 +19,42 @@ func validRecord(name string, id byte) domain.CatalogueRecord {
1819 return domain.CatalogueRecord{Name: name, IncarnationID: domain.IncarnationID{id}, RecoveryState: domain.RecoveryFresh}
1920 }
2021
22+func TestOpenOrCreate(t *testing.T) {
23+ t.Parallel()
24+ tests := []struct {
25+ name string
26+ write []byte // nil means "no file at all"
27+ wantNew bool
28+ wantErr error
29+ }{
30+ {name: "absent file starts empty", write: nil, wantNew: true},
31+ {name: "garbage fails closed", write: []byte("not a vev catalogue"), wantErr: ErrCatalogueUnreadable},
32+ {name: "legacy headerless record fails closed", write: []byte{0x08, 0x00, 0x00, 0x00, 0xde, 0xad, 0xbe, 0xef}, wantErr: ErrCatalogueUnreadable},
33+ }
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ t.Parallel()
37+ dir := privateDir(t)
38+ if tt.write != nil {
39+ require.NoError(t, os.MkdirAll(dir, 0o700))
40+ require.NoError(t, os.WriteFile(StorePath(dir), tt.write, 0o600))
41+ }
42+ got, err := OpenOrCreate(dir)
43+ if tt.wantErr != nil {
44+ require.ErrorIs(t, err, tt.wantErr)
45+ after, readErr := os.ReadFile(StorePath(dir))
46+ require.NoError(t, readErr)
47+ require.Equal(t, tt.write, after, "unreadable catalogue must remain untouched")
48+ return
49+ }
50+ require.NoError(t, err)
51+ t.Cleanup(func() { _ = got.Catalogue.Close() })
52+ require.Equal(t, tt.wantNew, got.NewInstall)
53+ require.Empty(t, got.Records)
54+ })
55+ }
56+}
57+
2158 func TestCatalogueDecodeAllFailsOnMalformedRecord(t *testing.T) {
2259 good, err := encodeRecordValue(validRecord("good", 1))
2360 require.NoError(t, err)
@@ -2,6 +2,7 @@ package persist
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "os"
5 "path/filepath"6 "path/filepath"
6 "testing"7 "testing"
7 8
@@ -18,6 +19,42 @@ func validRecord(name string, id byte) domain.CatalogueRecord {
18 return domain.CatalogueRecord{Name: name, IncarnationID: domain.IncarnationID{id}, RecoveryState: domain.RecoveryFresh}19 return domain.CatalogueRecord{Name: name, IncarnationID: domain.IncarnationID{id}, RecoveryState: domain.RecoveryFresh}
19 }20 }
20 21
22+func TestOpenOrCreate(t *testing.T) {
23+ t.Parallel()
24+ tests := []struct {
25+ name string
26+ write []byte // nil means "no file at all"
27+ wantNew bool
28+ wantErr error
29+ }{
30+ {name: "absent file starts empty", write: nil, wantNew: true},
31+ {name: "garbage fails closed", write: []byte("not a vev catalogue"), wantErr: ErrCatalogueUnreadable},
32+ {name: "legacy headerless record fails closed", write: []byte{0x08, 0x00, 0x00, 0x00, 0xde, 0xad, 0xbe, 0xef}, wantErr: ErrCatalogueUnreadable},
33+ }
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ t.Parallel()
37+ dir := privateDir(t)
38+ if tt.write != nil {
39+ require.NoError(t, os.MkdirAll(dir, 0o700))
40+ require.NoError(t, os.WriteFile(StorePath(dir), tt.write, 0o600))
41+ }
42+ got, err := OpenOrCreate(dir)
43+ if tt.wantErr != nil {
44+ require.ErrorIs(t, err, tt.wantErr)
45+ after, readErr := os.ReadFile(StorePath(dir))
46+ require.NoError(t, readErr)
47+ require.Equal(t, tt.write, after, "unreadable catalogue must remain untouched")
48+ return
49+ }
50+ require.NoError(t, err)
51+ t.Cleanup(func() { _ = got.Catalogue.Close() })
52+ require.Equal(t, tt.wantNew, got.NewInstall)
53+ require.Empty(t, got.Records)
54+ })
55+ }
56+}
57+
21 func TestCatalogueDecodeAllFailsOnMalformedRecord(t *testing.T) {58 func TestCatalogueDecodeAllFailsOnMalformedRecord(t *testing.T) {
22 good, err := encodeRecordValue(validRecord("good", 1))59 good, err := encodeRecordValue(validRecord("good", 1))
23 require.NoError(t, err)60 require.NoError(t, err)
M internal/ports/mocks/recovery_mock.go +0 -219
@@ -13,225 +13,6 @@ import (
1313 mock "github.com/stretchr/testify/mock"
1414 )
1515
16-// NewMockSnapshotMigration creates a new instance of MockSnapshotMigration. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17-// The first argument is typically a *testing.T value.
18-func NewMockSnapshotMigration(t interface {
19- mock.TestingT
20- Cleanup(func())
21-}) *MockSnapshotMigration {
22- mock := &MockSnapshotMigration{}
23- mock.Mock.Test(t)
24-
25- t.Cleanup(func() { mock.AssertExpectations(t) })
26-
27- return mock
28-}
29-
30-// MockSnapshotMigration is an autogenerated mock type for the SnapshotMigration type
31-type MockSnapshotMigration struct {
32- mock.Mock
33-}
34-
35-type MockSnapshotMigration_Expecter struct {
36- mock *mock.Mock
37-}
38-
39-func (_m *MockSnapshotMigration) EXPECT() *MockSnapshotMigration_Expecter {
40- return &MockSnapshotMigration_Expecter{mock: &_m.Mock}
41-}
42-
43-// HasLegacyState provides a mock function for the type MockSnapshotMigration
44-func (_mock *MockSnapshotMigration) HasLegacyState(context1 context.Context) (bool, error) {
45- ret := _mock.Called(context1)
46-
47- if len(ret) == 0 {
48- panic("no return value specified for HasLegacyState")
49- }
50-
51- var r0 bool
52- var r1 error
53- if returnFunc, ok := ret.Get(0).(func(context.Context) (bool, error)); ok {
54- return returnFunc(context1)
55- }
56- if returnFunc, ok := ret.Get(0).(func(context.Context) bool); ok {
57- r0 = returnFunc(context1)
58- } else {
59- r0 = ret.Get(0).(bool)
60- }
61- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
62- r1 = returnFunc(context1)
63- } else {
64- r1 = ret.Error(1)
65- }
66- return r0, r1
67-}
68-
69-// MockSnapshotMigration_HasLegacyState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasLegacyState'
70-type MockSnapshotMigration_HasLegacyState_Call struct {
71- *mock.Call
72-}
73-
74-// HasLegacyState is a helper method to define mock.On call
75-// - context1 context.Context
76-func (_e *MockSnapshotMigration_Expecter) HasLegacyState(context1 any) *MockSnapshotMigration_HasLegacyState_Call {
77- return &MockSnapshotMigration_HasLegacyState_Call{Call: _e.mock.On("HasLegacyState", context1)}
78-}
79-
80-func (_c *MockSnapshotMigration_HasLegacyState_Call) Run(run func(context1 context.Context)) *MockSnapshotMigration_HasLegacyState_Call {
81- _c.Call.Run(func(args mock.Arguments) {
82- var arg0 context.Context
83- if args[0] != nil {
84- arg0 = args[0].(context.Context)
85- }
86- run(
87- arg0,
88- )
89- })
90- return _c
91-}
92-
93-func (_c *MockSnapshotMigration_HasLegacyState_Call) Return(b bool, err error) *MockSnapshotMigration_HasLegacyState_Call {
94- _c.Call.Return(b, err)
95- return _c
96-}
97-
98-func (_c *MockSnapshotMigration_HasLegacyState_Call) RunAndReturn(run func(context1 context.Context) (bool, error)) *MockSnapshotMigration_HasLegacyState_Call {
99- _c.Call.Return(run)
100- return _c
101-}
102-
103-// MigrateV1Checkpoint provides a mock function for the type MockSnapshotMigration
104-func (_mock *MockSnapshotMigration) MigrateV1Checkpoint(context1 context.Context, snapshotMigrationRequest ports.SnapshotMigrationRequest) (domain.CheckpointRef, error) {
105- ret := _mock.Called(context1, snapshotMigrationRequest)
106-
107- if len(ret) == 0 {
108- panic("no return value specified for MigrateV1Checkpoint")
109- }
110-
111- var r0 domain.CheckpointRef
112- var r1 error
113- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.SnapshotMigrationRequest) (domain.CheckpointRef, error)); ok {
114- return returnFunc(context1, snapshotMigrationRequest)
115- }
116- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.SnapshotMigrationRequest) domain.CheckpointRef); ok {
117- r0 = returnFunc(context1, snapshotMigrationRequest)
118- } else {
119- r0 = ret.Get(0).(domain.CheckpointRef)
120- }
121- if returnFunc, ok := ret.Get(1).(func(context.Context, ports.SnapshotMigrationRequest) error); ok {
122- r1 = returnFunc(context1, snapshotMigrationRequest)
123- } else {
124- r1 = ret.Error(1)
125- }
126- return r0, r1
127-}
128-
129-// MockSnapshotMigration_MigrateV1Checkpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MigrateV1Checkpoint'
130-type MockSnapshotMigration_MigrateV1Checkpoint_Call struct {
131- *mock.Call
132-}
133-
134-// MigrateV1Checkpoint is a helper method to define mock.On call
135-// - context1 context.Context
136-// - snapshotMigrationRequest ports.SnapshotMigrationRequest
137-func (_e *MockSnapshotMigration_Expecter) MigrateV1Checkpoint(context1 any, snapshotMigrationRequest any) *MockSnapshotMigration_MigrateV1Checkpoint_Call {
138- return &MockSnapshotMigration_MigrateV1Checkpoint_Call{Call: _e.mock.On("MigrateV1Checkpoint", context1, snapshotMigrationRequest)}
139-}
140-
141-func (_c *MockSnapshotMigration_MigrateV1Checkpoint_Call) Run(run func(context1 context.Context, snapshotMigrationRequest ports.SnapshotMigrationRequest)) *MockSnapshotMigration_MigrateV1Checkpoint_Call {
142- _c.Call.Run(func(args mock.Arguments) {
143- var arg0 context.Context
144- if args[0] != nil {
145- arg0 = args[0].(context.Context)
146- }
147- var arg1 ports.SnapshotMigrationRequest
148- if args[1] != nil {
149- arg1 = args[1].(ports.SnapshotMigrationRequest)
150- }
151- run(
152- arg0,
153- arg1,
154- )
155- })
156- return _c
157-}
158-
159-func (_c *MockSnapshotMigration_MigrateV1Checkpoint_Call) Return(checkpointRef domain.CheckpointRef, err error) *MockSnapshotMigration_MigrateV1Checkpoint_Call {
160- _c.Call.Return(checkpointRef, err)
161- return _c
162-}
163-
164-func (_c *MockSnapshotMigration_MigrateV1Checkpoint_Call) RunAndReturn(run func(context1 context.Context, snapshotMigrationRequest ports.SnapshotMigrationRequest) (domain.CheckpointRef, error)) *MockSnapshotMigration_MigrateV1Checkpoint_Call {
165- _c.Call.Return(run)
166- return _c
167-}
168-
169-// ReadLegacyHEAD provides a mock function for the type MockSnapshotMigration
170-func (_mock *MockSnapshotMigration) ReadLegacyHEAD(context1 context.Context, s string) (domain.CheckpointRef, error) {
171- ret := _mock.Called(context1, s)
172-
173- if len(ret) == 0 {
174- panic("no return value specified for ReadLegacyHEAD")
175- }
176-
177- var r0 domain.CheckpointRef
178- var r1 error
179- if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.CheckpointRef, error)); ok {
180- return returnFunc(context1, s)
181- }
182- if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.CheckpointRef); ok {
183- r0 = returnFunc(context1, s)
184- } else {
185- r0 = ret.Get(0).(domain.CheckpointRef)
186- }
187- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
188- r1 = returnFunc(context1, s)
189- } else {
190- r1 = ret.Error(1)
191- }
192- return r0, r1
193-}
194-
195-// MockSnapshotMigration_ReadLegacyHEAD_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadLegacyHEAD'
196-type MockSnapshotMigration_ReadLegacyHEAD_Call struct {
197- *mock.Call
198-}
199-
200-// ReadLegacyHEAD is a helper method to define mock.On call
201-// - context1 context.Context
202-// - s string
203-func (_e *MockSnapshotMigration_Expecter) ReadLegacyHEAD(context1 any, s any) *MockSnapshotMigration_ReadLegacyHEAD_Call {
204- return &MockSnapshotMigration_ReadLegacyHEAD_Call{Call: _e.mock.On("ReadLegacyHEAD", context1, s)}
205-}
206-
207-func (_c *MockSnapshotMigration_ReadLegacyHEAD_Call) Run(run func(context1 context.Context, s string)) *MockSnapshotMigration_ReadLegacyHEAD_Call {
208- _c.Call.Run(func(args mock.Arguments) {
209- var arg0 context.Context
210- if args[0] != nil {
211- arg0 = args[0].(context.Context)
212- }
213- var arg1 string
214- if args[1] != nil {
215- arg1 = args[1].(string)
216- }
217- run(
218- arg0,
219- arg1,
220- )
221- })
222- return _c
223-}
224-
225-func (_c *MockSnapshotMigration_ReadLegacyHEAD_Call) Return(checkpointRef domain.CheckpointRef, err error) *MockSnapshotMigration_ReadLegacyHEAD_Call {
226- _c.Call.Return(checkpointRef, err)
227- return _c
228-}
229-
230-func (_c *MockSnapshotMigration_ReadLegacyHEAD_Call) RunAndReturn(run func(context1 context.Context, s string) (domain.CheckpointRef, error)) *MockSnapshotMigration_ReadLegacyHEAD_Call {
231- _c.Call.Return(run)
232- return _c
233-}
234-
23516 // NewMockRecoveryJournal creates a new instance of MockRecoveryJournal. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
23617 // The first argument is typically a *testing.T value.
23718 func NewMockRecoveryJournal(t interface {
@@ -13,225 +13,6 @@ import (
13 mock "github.com/stretchr/testify/mock"13 mock "github.com/stretchr/testify/mock"
14 )14 )
15 15
16-// NewMockSnapshotMigration creates a new instance of MockSnapshotMigration. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17-// The first argument is typically a *testing.T value.
18-func NewMockSnapshotMigration(t interface {
19- mock.TestingT
20- Cleanup(func())
21-}) *MockSnapshotMigration {
22- mock := &MockSnapshotMigration{}
23- mock.Mock.Test(t)
24-
25- t.Cleanup(func() { mock.AssertExpectations(t) })
26-
27- return mock
28-}
29-
30-// MockSnapshotMigration is an autogenerated mock type for the SnapshotMigration type
31-type MockSnapshotMigration struct {
32- mock.Mock
33-}
34-
35-type MockSnapshotMigration_Expecter struct {
36- mock *mock.Mock
37-}
38-
39-func (_m *MockSnapshotMigration) EXPECT() *MockSnapshotMigration_Expecter {
40- return &MockSnapshotMigration_Expecter{mock: &_m.Mock}
41-}
42-
43-// HasLegacyState provides a mock function for the type MockSnapshotMigration
44-func (_mock *MockSnapshotMigration) HasLegacyState(context1 context.Context) (bool, error) {
45- ret := _mock.Called(context1)
46-
47- if len(ret) == 0 {
48- panic("no return value specified for HasLegacyState")
49- }
50-
51- var r0 bool
52- var r1 error
53- if returnFunc, ok := ret.Get(0).(func(context.Context) (bool, error)); ok {
54- return returnFunc(context1)
55- }
56- if returnFunc, ok := ret.Get(0).(func(context.Context) bool); ok {
57- r0 = returnFunc(context1)
58- } else {
59- r0 = ret.Get(0).(bool)
60- }
61- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
62- r1 = returnFunc(context1)
63- } else {
64- r1 = ret.Error(1)
65- }
66- return r0, r1
67-}
68-
69-// MockSnapshotMigration_HasLegacyState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasLegacyState'
70-type MockSnapshotMigration_HasLegacyState_Call struct {
71- *mock.Call
72-}
73-
74-// HasLegacyState is a helper method to define mock.On call
75-// - context1 context.Context
76-func (_e *MockSnapshotMigration_Expecter) HasLegacyState(context1 any) *MockSnapshotMigration_HasLegacyState_Call {
77- return &MockSnapshotMigration_HasLegacyState_Call{Call: _e.mock.On("HasLegacyState", context1)}
78-}
79-
80-func (_c *MockSnapshotMigration_HasLegacyState_Call) Run(run func(context1 context.Context)) *MockSnapshotMigration_HasLegacyState_Call {
81- _c.Call.Run(func(args mock.Arguments) {
82- var arg0 context.Context
83- if args[0] != nil {
84- arg0 = args[0].(context.Context)
85- }
86- run(
87- arg0,
88- )
89- })
90- return _c
91-}
92-
93-func (_c *MockSnapshotMigration_HasLegacyState_Call) Return(b bool, err error) *MockSnapshotMigration_HasLegacyState_Call {
94- _c.Call.Return(b, err)
95- return _c
96-}
97-
98-func (_c *MockSnapshotMigration_HasLegacyState_Call) RunAndReturn(run func(context1 context.Context) (bool, error)) *MockSnapshotMigration_HasLegacyState_Call {
99- _c.Call.Return(run)
100- return _c
101-}
102-
103-// MigrateV1Checkpoint provides a mock function for the type MockSnapshotMigration
104-func (_mock *MockSnapshotMigration) MigrateV1Checkpoint(context1 context.Context, snapshotMigrationRequest ports.SnapshotMigrationRequest) (domain.CheckpointRef, error) {
105- ret := _mock.Called(context1, snapshotMigrationRequest)
106-
107- if len(ret) == 0 {
108- panic("no return value specified for MigrateV1Checkpoint")
109- }
110-
111- var r0 domain.CheckpointRef
112- var r1 error
113- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.SnapshotMigrationRequest) (domain.CheckpointRef, error)); ok {
114- return returnFunc(context1, snapshotMigrationRequest)
115- }
116- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.SnapshotMigrationRequest) domain.CheckpointRef); ok {
117- r0 = returnFunc(context1, snapshotMigrationRequest)
118- } else {
119- r0 = ret.Get(0).(domain.CheckpointRef)
120- }
121- if returnFunc, ok := ret.Get(1).(func(context.Context, ports.SnapshotMigrationRequest) error); ok {
122- r1 = returnFunc(context1, snapshotMigrationRequest)
123- } else {
124- r1 = ret.Error(1)
125- }
126- return r0, r1
127-}
128-
129-// MockSnapshotMigration_MigrateV1Checkpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MigrateV1Checkpoint'
130-type MockSnapshotMigration_MigrateV1Checkpoint_Call struct {
131- *mock.Call
132-}
133-
134-// MigrateV1Checkpoint is a helper method to define mock.On call
135-// - context1 context.Context
136-// - snapshotMigrationRequest ports.SnapshotMigrationRequest
137-func (_e *MockSnapshotMigration_Expecter) MigrateV1Checkpoint(context1 any, snapshotMigrationRequest any) *MockSnapshotMigration_MigrateV1Checkpoint_Call {
138- return &MockSnapshotMigration_MigrateV1Checkpoint_Call{Call: _e.mock.On("MigrateV1Checkpoint", context1, snapshotMigrationRequest)}
139-}
140-
141-func (_c *MockSnapshotMigration_MigrateV1Checkpoint_Call) Run(run func(context1 context.Context, snapshotMigrationRequest ports.SnapshotMigrationRequest)) *MockSnapshotMigration_MigrateV1Checkpoint_Call {
142- _c.Call.Run(func(args mock.Arguments) {
143- var arg0 context.Context
144- if args[0] != nil {
145- arg0 = args[0].(context.Context)
146- }
147- var arg1 ports.SnapshotMigrationRequest
148- if args[1] != nil {
149- arg1 = args[1].(ports.SnapshotMigrationRequest)
150- }
151- run(
152- arg0,
153- arg1,
154- )
155- })
156- return _c
157-}
158-
159-func (_c *MockSnapshotMigration_MigrateV1Checkpoint_Call) Return(checkpointRef domain.CheckpointRef, err error) *MockSnapshotMigration_MigrateV1Checkpoint_Call {
160- _c.Call.Return(checkpointRef, err)
161- return _c
162-}
163-
164-func (_c *MockSnapshotMigration_MigrateV1Checkpoint_Call) RunAndReturn(run func(context1 context.Context, snapshotMigrationRequest ports.SnapshotMigrationRequest) (domain.CheckpointRef, error)) *MockSnapshotMigration_MigrateV1Checkpoint_Call {
165- _c.Call.Return(run)
166- return _c
167-}
168-
169-// ReadLegacyHEAD provides a mock function for the type MockSnapshotMigration
170-func (_mock *MockSnapshotMigration) ReadLegacyHEAD(context1 context.Context, s string) (domain.CheckpointRef, error) {
171- ret := _mock.Called(context1, s)
172-
173- if len(ret) == 0 {
174- panic("no return value specified for ReadLegacyHEAD")
175- }
176-
177- var r0 domain.CheckpointRef
178- var r1 error
179- if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.CheckpointRef, error)); ok {
180- return returnFunc(context1, s)
181- }
182- if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.CheckpointRef); ok {
183- r0 = returnFunc(context1, s)
184- } else {
185- r0 = ret.Get(0).(domain.CheckpointRef)
186- }
187- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
188- r1 = returnFunc(context1, s)
189- } else {
190- r1 = ret.Error(1)
191- }
192- return r0, r1
193-}
194-
195-// MockSnapshotMigration_ReadLegacyHEAD_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadLegacyHEAD'
196-type MockSnapshotMigration_ReadLegacyHEAD_Call struct {
197- *mock.Call
198-}
199-
200-// ReadLegacyHEAD is a helper method to define mock.On call
201-// - context1 context.Context
202-// - s string
203-func (_e *MockSnapshotMigration_Expecter) ReadLegacyHEAD(context1 any, s any) *MockSnapshotMigration_ReadLegacyHEAD_Call {
204- return &MockSnapshotMigration_ReadLegacyHEAD_Call{Call: _e.mock.On("ReadLegacyHEAD", context1, s)}
205-}
206-
207-func (_c *MockSnapshotMigration_ReadLegacyHEAD_Call) Run(run func(context1 context.Context, s string)) *MockSnapshotMigration_ReadLegacyHEAD_Call {
208- _c.Call.Run(func(args mock.Arguments) {
209- var arg0 context.Context
210- if args[0] != nil {
211- arg0 = args[0].(context.Context)
212- }
213- var arg1 string
214- if args[1] != nil {
215- arg1 = args[1].(string)
216- }
217- run(
218- arg0,
219- arg1,
220- )
221- })
222- return _c
223-}
224-
225-func (_c *MockSnapshotMigration_ReadLegacyHEAD_Call) Return(checkpointRef domain.CheckpointRef, err error) *MockSnapshotMigration_ReadLegacyHEAD_Call {
226- _c.Call.Return(checkpointRef, err)
227- return _c
228-}
229-
230-func (_c *MockSnapshotMigration_ReadLegacyHEAD_Call) RunAndReturn(run func(context1 context.Context, s string) (domain.CheckpointRef, error)) *MockSnapshotMigration_ReadLegacyHEAD_Call {
231- _c.Call.Return(run)
232- return _c
233-}
234-
235 // NewMockRecoveryJournal creates a new instance of MockRecoveryJournal. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.16 // NewMockRecoveryJournal creates a new instance of MockRecoveryJournal. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
236 // The first argument is typically a *testing.T value.17 // The first argument is typically a *testing.T value.
237 func NewMockRecoveryJournal(t interface {18 func NewMockRecoveryJournal(t interface {
M internal/ports/recovery.go +1 -16
@@ -104,22 +104,7 @@ type ReconcileDecision struct {
104104 RetentionResolved bool
105105 }
106106
107-var (
108- ErrLegacySnapshotUncertain = errors.New("legacy snapshot state is uncertain")
109- ErrBudgetExhausted = errors.New("snapshot maintenance budget exhausted")
110-)
111-
112-type SnapshotMigrationRequest struct {
113- LegacyName string
114- IncarnationID domain.IncarnationID
115- LegacyRef domain.CheckpointRef
116-}
117-
118-type SnapshotMigration interface {
119- HasLegacyState(context.Context) (bool, error)
120- ReadLegacyHEAD(context.Context, string) (domain.CheckpointRef, error)
121- MigrateV1Checkpoint(context.Context, SnapshotMigrationRequest) (domain.CheckpointRef, error)
122-}
107+var ErrBudgetExhausted = errors.New("snapshot maintenance budget exhausted")
123108
124109 type RecoveryJournal interface {
125110 SaveDiscard(context.Context, domain.DiscardIntent) error
@@ -104,22 +104,7 @@ type ReconcileDecision struct {
104 RetentionResolved bool104 RetentionResolved bool
105 }105 }
106 106
107-var (107+var ErrBudgetExhausted = errors.New("snapshot maintenance budget exhausted")
108- ErrLegacySnapshotUncertain = errors.New("legacy snapshot state is uncertain")
109- ErrBudgetExhausted = errors.New("snapshot maintenance budget exhausted")
110-)
111-
112-type SnapshotMigrationRequest struct {
113- LegacyName string
114- IncarnationID domain.IncarnationID
115- LegacyRef domain.CheckpointRef
116-}
117-
118-type SnapshotMigration interface {
119- HasLegacyState(context.Context) (bool, error)
120- ReadLegacyHEAD(context.Context, string) (domain.CheckpointRef, error)
121- MigrateV1Checkpoint(context.Context, SnapshotMigrationRequest) (domain.CheckpointRef, error)
122-}
123 108
124 type RecoveryJournal interface {109 type RecoveryJournal interface {
125 SaveDiscard(context.Context, domain.DiscardIntent) error110 SaveDiscard(context.Context, domain.DiscardIntent) error

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close