refactor(persist): replace migration with fail-closed catalogue open
- commit
- 23c31e39a646a575630ae8b1e200a7478edcf254
- author
- brice · · 2026-07-26 10:27 UTC
- parent
- 2b0e9f7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 685 | 685 | go func() { | |
| 686 | 686 | wrapperReturned <- runWithLifecycleOwner(ctx, runtimeDir, stateDir, func(ctx context.Context) error { | |
| 687 | 687 | 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) | |
| 693 | 689 | if err != nil { | |
| 694 | 690 | return err | |
| 695 | 691 | } | |
| @@ -745,11 +741,7 @@ func publishRestorableCheckpoint(t *testing.T, stateDir string, repository *snap | |||
| 745 | 741 | ctx, cancel := context.WithCancel(context.Background()) | |
| 746 | 742 | defer cancel() | |
| 747 | 743 | ||
| 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) | |
| 753 | 745 | require.NoError(t, err) | |
| 754 | 746 | coordinator := recovery.NewCoordinator(opened.Catalogue, repository, recoveryfs.New(stateDir), rand.Reader) | |
| 755 | 747 | require.NoError(t, coordinator.Recover(ctx)) | |
| @@ -1007,11 +999,7 @@ func TestLifecycleSocketCloseCatalogueRace(t *testing.T) { | |||
| 1007 | 999 | ||
| 1008 | 1000 | oldOwner, err := lifecycle.TryAcquire(runtimeDir) | |
| 1009 | 1001 | 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) | |
| 1015 | 1003 | require.NoError(t, err) | |
| 1016 | 1004 | oldListener, err := ipc.Listen(runtimeDir) | |
| 1017 | 1005 | require.NoError(t, err) | |
| @@ -1062,11 +1050,7 @@ func TestLifecycleSocketCloseCatalogueRace(t *testing.T) { | |||
| 1062 | 1050 | go func() { | |
| 1063 | 1051 | newDone <- runWithLifecycleOwnerDeps(context.Background(), runtimeDir, stateDir, func(ctx context.Context) error { | |
| 1064 | 1052 | 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) | |
| 1070 | 1054 | if err != nil { | |
| 1071 | 1055 | return err | |
| 1072 | 1056 | } | |
| @@ -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 err | 690 | 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 err | 1055 | return err | ||
| 1072 | } | 1056 | } | ||
M internal/app/offline_kill_retry_test.go +5 -7
| @@ -14,7 +14,7 @@ import ( | |||
| 14 | 14 | "github.com/bnema/vev/internal/persist" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | - | func TestRunOfflineNamedKillUsesOpenOrMigrateSeam(t *testing.T) { | |
| 17 | + | func TestRunOfflineNamedKillUsesOpenCatalogueSeam(t *testing.T) { | |
| 18 | 18 | stateRoot := t.TempDir() | |
| 19 | 19 | t.Setenv("XDG_STATE_HOME", stateRoot) | |
| 20 | 20 | stateDir := filepath.Join(stateRoot, "vev") | |
| @@ -24,15 +24,13 @@ func TestRunOfflineNamedKillUsesOpenOrMigrateSeam(t *testing.T) { | |||
| 24 | 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 | 25 | require.NoError(t, p.Close()) | |
| 26 | 26 | ||
| 27 | - | original := openOrMigrate | |
| 28 | - | t.Cleanup(func() { openOrMigrate = original }) | |
| 27 | + | original := openCatalogue | |
| 28 | + | t.Cleanup(func() { openCatalogue = original }) | |
| 29 | 29 | wantErr := errors.New("open seam called") | |
| 30 | 30 | called := false | |
| 31 | - | openOrMigrate = func(_ context.Context, deps persist.OpenDeps) (persist.OpenResult, error) { | |
| 31 | + | openCatalogue = func(gotStateDir string) (persist.OpenResult, error) { | |
| 32 | 32 | 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) | |
| 36 | 34 | return persist.OpenResult{}, wantErr | |
| 37 | 35 | } | |
| 38 | 36 | ||
| @@ -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 := openOrMigrate | 27 | + | 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 := false | 30 | called := false | ||
| 31 | - | openOrMigrate = func(_ context.Context, deps persist.OpenDeps) (persist.OpenResult, error) { | 31 | + | openCatalogue = func(gotStateDir string) (persist.OpenResult, error) { |
| 32 | called = true | 32 | 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{}, wantErr | 34 | return persist.OpenResult{}, wantErr | ||
| 37 | } | 35 | } | ||
| 38 | 36 | ||||
M internal/app/run.go +21 -25
| @@ -118,7 +118,7 @@ var ( | |||
| 118 | 118 | commit = "none" | |
| 119 | 119 | date = "unknown" | |
| 120 | 120 | ||
| 121 | - | openOrMigrate = persist.OpenOrMigrate | |
| 121 | + | openCatalogue = persist.OpenOrCreate | |
| 122 | 122 | listenDaemon = func(dir string, observer ports.SerializedRuntimeObserver) (ports.Listener, error) { | |
| 123 | 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 | 723 | daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository)) | |
| 724 | 724 | noticeStore := noticefile.New(platform.StateDir()) | |
| 725 | 725 | 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) | |
| 732 | 729 | if err != nil { | |
| 733 | 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 | 736 | recoveryMode := "current" | |
| 737 | 737 | if opened.NewInstall { | |
| 738 | 738 | recoveryMode = "new-install" | |
| 739 | - | } else if opened.Migrated { | |
| 740 | - | recoveryMode = "migrated" | |
| 741 | 739 | } | |
| 742 | 740 | logCatalogueRecovery(log, opened.Records, recoveryMode) | |
| 743 | 741 | journal := recoveryfs.New(platform.StateDir()) | |
| @@ -1541,18 +1539,17 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||
| 1541 | 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 | 1548 | 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) | |
| 1554 | 1551 | if err != nil { | |
| 1555 | - | return fmt.Errorf("vev: reading stored sessions: %w", err) | |
| 1552 | + | return unreadableCatalogueError(stateDir) | |
| 1556 | 1553 | } | |
| 1557 | 1554 | found := false | |
| 1558 | 1555 | for _, record := range records { | |
| @@ -1566,12 +1563,11 @@ func runOfflineNamedKill(ctx context.Context, name string) (retErr error) { | |||
| 1566 | 1563 | } | |
| 1567 | 1564 | ||
| 1568 | 1565 | 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) | |
| 1574 | 1567 | if err != nil { | |
| 1568 | + | if errors.Is(err, persist.ErrCatalogueUnreadable) { | |
| 1569 | + | return unreadableCatalogueError(stateDir) | |
| 1570 | + | } | |
| 1575 | 1571 | return fmt.Errorf("vev: opening stored sessions: %w", err) | |
| 1576 | 1572 | } | |
| 1577 | 1573 | 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.OpenOrMigrate | 121 | + | 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 (empty | 1549 | + | stateDir := platform.StateDir() |
| 1546 | - | // slice, nil error) from a real IO error, and recovers the same | 1550 | + | 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 := false | 1554 | 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) { | |||
| 370 | 370 | } | |
| 371 | 371 | ||
| 372 | 372 | func TestCatalogueFailureDoesNotListen(t *testing.T) { | |
| 373 | - | originalOpenOrMigrate, originalListenDaemon := openOrMigrate, listenDaemon | |
| 373 | + | originalOpenCatalogue, originalListenDaemon := openCatalogue, listenDaemon | |
| 374 | 374 | t.Cleanup(func() { | |
| 375 | - | openOrMigrate = originalOpenOrMigrate | |
| 375 | + | openCatalogue = originalOpenCatalogue | |
| 376 | 376 | listenDaemon = originalListenDaemon | |
| 377 | 377 | }) | |
| 378 | 378 | ||
| @@ -385,8 +385,8 @@ func TestCatalogueFailureDoesNotListen(t *testing.T) { | |||
| 385 | 385 | t.Setenv("XDG_STATE_HOME", stateRoot) | |
| 386 | 386 | ||
| 387 | 387 | 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) | |
| 390 | 390 | return persist.OpenResult{}, catalogueErr | |
| 391 | 391 | } | |
| 392 | 392 | listenDaemon = func(string, ports.SerializedRuntimeObserver) (ports.Listener, error) { | |
| @@ -512,9 +512,15 @@ func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) { | |||
| 512 | 512 | ||
| 513 | 513 | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | |
| 514 | 514 | t.Setenv("XDG_STATE_HOME", stateRoot) | |
| 515 | + | before, err := os.ReadFile(persist.StorePath(stateDir)) | |
| 516 | + | require.NoError(t, err) | |
| 515 | 517 | err = runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned) | |
| 516 | 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 | 524 | _, statErr := os.Stat(filepath.Join(runtimeDir, "daemon.sock")) | |
| 519 | 525 | require.ErrorIs(t, statErr, os.ErrNotExist) | |
| 520 | 526 | }) | |
| @@ -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, listenDaemon | 373 | + | originalOpenCatalogue, originalListenDaemon := openCatalogue, listenDaemon |
| 374 | t.Cleanup(func() { | 374 | t.Cleanup(func() { | ||
| 375 | - | openOrMigrate = originalOpenOrMigrate | 375 | + | openCatalogue = originalOpenCatalogue |
| 376 | listenDaemon = originalListenDaemon | 376 | 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 := 0 | 387 | 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{}, catalogueErr | 390 | 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 = ©Ref | |
| 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 = ©Ref | |||
| 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "fmt" | |
| 6 | + | "os" | |
| 6 | 7 | "path/filepath" | |
| 7 | 8 | "sort" | |
| 8 | 9 | "sync" | |
| @@ -16,6 +17,17 @@ const filename = "sessions.kv" | |||
| 16 | 17 | ||
| 17 | 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 | 31 | func StorePath(dir string) string { return filepath.Join(dir, filename) } | |
| 20 | 32 | ||
| 21 | 33 | type Persister struct { | |
| @@ -59,8 +71,52 @@ func Open(dir string) (*Persister, error) { | |||
| 59 | 71 | p, _, err := openCurrentCatalogue(dir, false) | |
| 60 | 72 | 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 | 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 | 120 | func openCurrentCatalogue(dir string, createProvenEmpty bool) (*Persister, []domain.CatalogueRecord, error) { | |
| 65 | 121 | path := StorePath(dir) | |
| 66 | 122 | 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, err | 72 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "os" | |
| 5 | 6 | "path/filepath" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| @@ -18,6 +19,42 @@ func validRecord(name string, id byte) domain.CatalogueRecord { | |||
| 18 | 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 | 58 | func TestCatalogueDecodeAllFailsOnMalformedRecord(t *testing.T) { | |
| 22 | 59 | good, err := encodeRecordValue(validRecord("good", 1)) | |
| 23 | 60 | 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 ( | |||
| 13 | 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 | 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 | 17 | // The first argument is typically a *testing.T value. | |
| 237 | 18 | 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 { | |||
| 104 | 104 | RetentionResolved bool | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 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") | |
| 123 | 108 | ||
| 124 | 109 | type RecoveryJournal interface { | |
| 125 | 110 | SaveDiscard(context.Context, domain.DiscardIntent) error | |
| @@ -104,22 +104,7 @@ type ReconcileDecision struct { | |||||
| 104 | RetentionResolved bool | 104 | 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) error | 110 | SaveDiscard(context.Context, domain.DiscardIntent) error | ||