| 1 | package hostbox |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "log/slog" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "sort" |
| 13 | "strings" |
| 14 | |
| 15 | "github.com/benbjohnson/litestream" |
| 16 | "github.com/carlosframework/platform/internal/genselect" |
| 17 | "github.com/carlosframework/platform/internal/hostwrite" |
| 18 | "github.com/carlosframework/platform/internal/lease" |
| 19 | "github.com/superfly/ltx" |
| 20 | ) |
| 21 | |
| 22 | // RetireArtifacts moves a predecessor installation out of the live namespace. |
| 23 | // It is called only when the durable local generation differs from the selected |
| 24 | // generation, and while Work still vouches for the installation being replaced. |
| 25 | type RetireArtifacts func(context.Context, string) error |
| 26 | |
| 27 | // ColdRestore installs a selected immutable generation. It never accepts an |
| 28 | // unselected current object and never reuses a local database. |
| 29 | type ColdRestore struct { |
| 30 | Reader genselect.Reader |
| 31 | Bucket string |
| 32 | Generation string // newly admitted installation, distinct from baseline plan.Generation |
| 33 | Work hostwrite.Work |
| 34 | Identity hostwrite.Identity |
| 35 | RetireArtifacts RetireArtifacts |
| 36 | // BeforeLegacyInstall parks an unmarked source outside Work.With. The |
| 37 | // caller must bind it for serving, including a genuinely empty creator. |
| 38 | BeforeLegacyInstall func(context.Context, string, string) error |
| 39 | // BeforeInstallLocked validates enrollment after the marker recheck. An |
| 40 | // error prevents purge, temporary database creation and marker writes. |
| 41 | BeforeInstallLocked func(context.Context, string, string) error |
| 42 | } |
| 43 | |
| 44 | // Install purges the old local state and restores plan into dbPath. All local |
| 45 | // mutation stays inside the work selected after admission. |
| 46 | func (r ColdRestore) Install(ctx context.Context, dbPath string, plan *genselect.Plan) error { |
| 47 | if ctx == nil || r.Reader == nil || r.Bucket == "" || r.Work == nil || plan == nil || |
| 48 | plan.Generation == "" || !safeGeneration(r.Generation) || len(plan.Files) == 0 || plan.MaxTXID == 0 || dbPath == "" { |
| 49 | return ErrUnsupported |
| 50 | } |
| 51 | var capturedGeneration string |
| 52 | if err := r.Work.With(ctx, r.Identity, func() error { |
| 53 | var err error |
| 54 | capturedGeneration, err = readGeneration(dbPath + ".gen") |
| 55 | return err |
| 56 | }); err != nil { |
| 57 | return fmt.Errorf("capture local generation: %w", err) |
| 58 | } |
| 59 | files, finalChecksum, err := loadPinned(ctx, r.Reader, r.Bucket, plan) |
| 60 | if err != nil { |
| 61 | return err |
| 62 | } |
| 63 | // Retirement takes park.Local's own flock. Calling it inside Work.With |
| 64 | // would recursively acquire the same lock and deadlock. |
| 65 | if capturedGeneration != "" && capturedGeneration != r.Generation { |
| 66 | if r.RetireArtifacts == nil { |
| 67 | return fmt.Errorf("retire generation %q: %w", capturedGeneration, ErrUnsupported) |
| 68 | } |
| 69 | if err := r.RetireArtifacts(ctx, capturedGeneration); err != nil { |
| 70 | return fmt.Errorf("retire generation %q: %w", capturedGeneration, err) |
| 71 | } |
| 72 | } |
| 73 | if capturedGeneration == "" && r.BeforeLegacyInstall != nil { |
| 74 | if err := r.BeforeLegacyInstall(ctx, dbPath, r.Generation); err != nil { |
| 75 | return err |
| 76 | } |
| 77 | } |
| 78 | return r.Work.With(ctx, r.Identity, func() error { |
| 79 | currentGeneration, err := readGeneration(dbPath + ".gen") |
| 80 | if err != nil { |
| 81 | return fmt.Errorf("recheck local generation: %w", err) |
| 82 | } |
| 83 | if capturedGeneration != "" && capturedGeneration != r.Generation { |
| 84 | // Successful predecessor retirement normally moves the enrolled |
| 85 | // marker with the rest of the installation. Absence is therefore the |
| 86 | // expected receipt disposition. A different marker means a successor |
| 87 | // appeared after our capture and must never be purged. |
| 88 | if currentGeneration != "" { |
| 89 | return ErrChanged |
| 90 | } |
| 91 | } else if currentGeneration != capturedGeneration { |
| 92 | return ErrChanged |
| 93 | } |
| 94 | if r.BeforeInstallLocked != nil { |
| 95 | if err := r.BeforeInstallLocked(ctx, dbPath, r.Generation); err != nil { |
| 96 | return err |
| 97 | } |
| 98 | } |
| 99 | if err := purgeDB(dbPath); err != nil { |
| 100 | return err |
| 101 | } |
| 102 | tmp, err := os.CreateTemp(filepath.Dir(dbPath), ".restore-*.db") |
| 103 | if err != nil { |
| 104 | return fmt.Errorf("create restore temp: %w", err) |
| 105 | } |
| 106 | tmpPath := tmp.Name() |
| 107 | if err := tmp.Close(); err != nil { |
| 108 | _ = os.Remove(tmpPath) |
| 109 | return err |
| 110 | } |
| 111 | _ = os.Remove(tmpPath) // Litestream requires an absent output. |
| 112 | // The check below opens the temp database read-only; a restored database |
| 113 | // is in WAL mode, so that open attaches a -wal and a -shm it cannot |
| 114 | // delete on close. Remove exactly the names derived from this restore's |
| 115 | // own temp path, never a glob: another restore's .restore-* files may be |
| 116 | // in flight beside it. |
| 117 | defer removeRestoreTemp(tmpPath) |
| 118 | client := &pinnedClient{files: files} |
| 119 | replica := litestream.NewReplicaWithClient(nil, client) |
| 120 | opt := litestream.NewRestoreOptions() |
| 121 | opt.OutputPath = tmpPath |
| 122 | opt.TXID = ltx.TXID(plan.MaxTXID) |
| 123 | if err := replica.Restore(ctx, opt); err != nil { |
| 124 | return fmt.Errorf("restore selected generation: %w", err) |
| 125 | } |
| 126 | f, err := os.Open(tmpPath) |
| 127 | if err != nil { |
| 128 | return err |
| 129 | } |
| 130 | checksum, checkErr := ltx.ChecksumReader(f, files[0].infoPageSize) |
| 131 | closeErr := f.Close() |
| 132 | if checkErr != nil { |
| 133 | return fmt.Errorf("checksum restored database: %w", checkErr) |
| 134 | } |
| 135 | if closeErr != nil { |
| 136 | return closeErr |
| 137 | } |
| 138 | // HeaderFlagNoChecksum is Litestream's pinned on-disk format. Its LTX |
| 139 | // trailer still detects corruption across the complete file, but is not |
| 140 | // an authenticity proof and intentionally has no database checksum to |
| 141 | // compare. A moves-owned object digest must supply authenticity. When |
| 142 | // the selected chain carries a database checksum, require the restored |
| 143 | // database to match it. |
| 144 | if finalChecksum != 0 && checksum != finalChecksum { |
| 145 | return fmt.Errorf("restored database checksum %s, want %s", checksum, finalChecksum) |
| 146 | } |
| 147 | if err := lease.CheckDBContext(ctx, tmpPath); err != nil { |
| 148 | return fmt.Errorf("check restored database: %w", err) |
| 149 | } |
| 150 | // The check may attach a WAL and a shared-memory file; neither is part |
| 151 | // of the restore's result and neither may follow the rename. |
| 152 | removeRestoreSidecars(tmpPath) |
| 153 | if err := syncFile(tmpPath); err != nil { |
| 154 | return err |
| 155 | } |
| 156 | if err := os.Rename(tmpPath, dbPath); err != nil { |
| 157 | return fmt.Errorf("install restored database: %w", err) |
| 158 | } |
| 159 | if err := writeGeneration(dbPath+".gen", r.Generation); err != nil { |
| 160 | return fmt.Errorf("install generation marker: %w", err) |
| 161 | } |
| 162 | return syncDir(filepath.Dir(dbPath)) |
| 163 | }) |
| 164 | } |
| 165 | |
| 166 | // InstallEmpty installs a separately proved initial-empty admission. It writes |
| 167 | // no database and no zero-TXID LTX. Authority must prove both the complete |
| 168 | // empty remote baseline and first-creator status before local mutation. |
| 169 | func (r ColdRestore) InstallEmpty(ctx context.Context, dbPath string, authority func(context.Context) error) error { |
| 170 | if ctx == nil || r.Work == nil || !safeGeneration(r.Generation) || dbPath == "" || authority == nil { |
| 171 | return ErrUnsupported |
| 172 | } |
| 173 | if err := authority(ctx); err != nil { |
| 174 | return err |
| 175 | } |
| 176 | var captured string |
| 177 | if err := r.Work.With(ctx, r.Identity, func() error { var e error; captured, e = readGeneration(dbPath + ".gen"); return e }); err != nil { |
| 178 | return err |
| 179 | } |
| 180 | if captured != "" && captured != r.Generation { |
| 181 | if r.RetireArtifacts == nil { |
| 182 | return ErrUnsupported |
| 183 | } |
| 184 | if err := r.RetireArtifacts(ctx, captured); err != nil { |
| 185 | return err |
| 186 | } |
| 187 | } |
| 188 | if captured == "" && r.BeforeLegacyInstall != nil { |
| 189 | if err := r.BeforeLegacyInstall(ctx, dbPath, r.Generation); err != nil { |
| 190 | return err |
| 191 | } |
| 192 | } |
| 193 | return r.Work.With(ctx, r.Identity, func() error { |
| 194 | current, err := readGeneration(dbPath + ".gen") |
| 195 | if err != nil { |
| 196 | return err |
| 197 | } |
| 198 | if captured != "" && captured != r.Generation { |
| 199 | if current != "" { |
| 200 | return ErrChanged |
| 201 | } |
| 202 | } else if current != captured { |
| 203 | return ErrChanged |
| 204 | } |
| 205 | if r.BeforeInstallLocked != nil { |
| 206 | if err := r.BeforeInstallLocked(ctx, dbPath, r.Generation); err != nil { |
| 207 | return err |
| 208 | } |
| 209 | } |
| 210 | if err := purgeDB(dbPath); err != nil { |
| 211 | return err |
| 212 | } |
| 213 | if err := writeGeneration(dbPath+".gen", r.Generation); err != nil { |
| 214 | return err |
| 215 | } |
| 216 | return syncDir(filepath.Dir(dbPath)) |
| 217 | }) |
| 218 | } |
| 219 | |
| 220 | func safeGeneration(s string) bool { |
| 221 | return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00\r\n") |
| 222 | } |
| 223 | |
| 224 | // removeRestoreSidecars removes the -wal and -shm files a check of the temp |
| 225 | // database at tmpPath may have attached. Best effort, as in |
| 226 | // moves.BuildGeneration: a name that is already absent is the normal case. |
| 227 | func removeRestoreSidecars(tmpPath string) { |
| 228 | for _, p := range []string{tmpPath + "-wal", tmpPath + "-shm"} { |
| 229 | _ = os.Remove(p) |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | // removeRestoreTemp is the failure-path cleanup: the temp database and its |
| 234 | // sidecars, by the names this restore derived and no others. |
| 235 | func removeRestoreTemp(tmpPath string) { |
| 236 | _ = os.Remove(tmpPath) |
| 237 | removeRestoreSidecars(tmpPath) |
| 238 | } |
| 239 | |
| 240 | func purgeDB(path string) error { |
| 241 | // ".seed" is the activator's seed marker (activator.SeedMarkerSuffix), |
| 242 | // spelled out because the activator imports this package: a marker |
| 243 | // must not outlive the database it named (re-review finding 8). |
| 244 | for _, p := range []string{path, path + "-wal", path + "-shm", path + ".gen", path + ".seed"} { |
| 245 | if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { |
| 246 | return fmt.Errorf("purge %s: %w", p, err) |
| 247 | } |
| 248 | } |
| 249 | for _, p := range []string{ |
| 250 | path + "-litestream", |
| 251 | filepath.Join(filepath.Dir(path), ".activator-"+filepath.Base(path)+"-litestream"), |
| 252 | } { |
| 253 | if err := os.RemoveAll(p); err != nil { |
| 254 | return fmt.Errorf("purge %s: %w", p, err) |
| 255 | } |
| 256 | } |
| 257 | return nil |
| 258 | } |
| 259 | |
| 260 | // WriteGenerationMarker writes the <dbPath>.gen marker beside a database: |
| 261 | // the prepared generation's name as plain text, the one format the cold |
| 262 | // restore's readGeneration accepts. It is the shared writer the moves |
| 263 | // landing uses for the database it installs (moves Plan 2 ruling G2; the |
| 264 | // marker the moves spec §5.1 draws as JSON is written in this form instead, |
| 265 | // and the incarnation and location it would have carried are vouched for by |
| 266 | // the host guard record and the claim). A name readGeneration would refuse |
| 267 | // is refused here, with nothing written. |
| 268 | func WriteGenerationMarker(path, generation string) error { |
| 269 | if !safeGeneration(generation) { |
| 270 | return fmt.Errorf("hostbox: %q is not a generation marker: %w", generation, ErrUnsupported) |
| 271 | } |
| 272 | return writeGeneration(path, generation) |
| 273 | } |
| 274 | |
| 275 | // ReadGenerationMarker reads a marker WriteGenerationMarker wrote. An |
| 276 | // absent marker is "" with no error (the origin). |
| 277 | func ReadGenerationMarker(path string) (string, error) { return readGeneration(path) } |
| 278 | |
| 279 | func readGeneration(path string) (string, error) { |
| 280 | b, err := os.ReadFile(path) |
| 281 | if errors.Is(err, os.ErrNotExist) { |
| 282 | return "", nil |
| 283 | } |
| 284 | if err != nil { |
| 285 | return "", err |
| 286 | } |
| 287 | g := strings.TrimSpace(string(b)) |
| 288 | if g == "" || strings.ContainsAny(g, "/\\\x00\r\n") { |
| 289 | return "", errors.New("invalid generation marker") |
| 290 | } |
| 291 | return g, nil |
| 292 | } |
| 293 | |
| 294 | func writeGeneration(path, generation string) error { |
| 295 | tmp, err := os.CreateTemp(filepath.Dir(path), ".generation-*") |
| 296 | if err != nil { |
| 297 | return err |
| 298 | } |
| 299 | name := tmp.Name() |
| 300 | defer os.Remove(name) |
| 301 | if _, err = io.WriteString(tmp, generation+"\n"); err == nil { |
| 302 | err = tmp.Sync() |
| 303 | } |
| 304 | if closeErr := tmp.Close(); err == nil { |
| 305 | err = closeErr |
| 306 | } |
| 307 | if err != nil { |
| 308 | return err |
| 309 | } |
| 310 | return os.Rename(name, path) |
| 311 | } |
| 312 | |
| 313 | func syncFile(path string) error { |
| 314 | f, err := os.Open(path) |
| 315 | if err != nil { |
| 316 | return err |
| 317 | } |
| 318 | defer f.Close() |
| 319 | return f.Sync() |
| 320 | } |
| 321 | func syncDir(path string) error { |
| 322 | f, err := os.Open(path) |
| 323 | if err != nil { |
| 324 | return err |
| 325 | } |
| 326 | defer f.Close() |
| 327 | return f.Sync() |
| 328 | } |
| 329 | |
| 330 | type pinnedFile struct { |
| 331 | plan genselect.File |
| 332 | data []byte |
| 333 | info *ltx.FileInfo |
| 334 | infoPageSize int |
| 335 | } |
| 336 | |
| 337 | func loadPinned(ctx context.Context, reader genselect.Reader, bucket string, plan *genselect.Plan) ([]pinnedFile, ltx.Checksum, error) { |
| 338 | files := make([]pinnedFile, 0, len(plan.Files)) |
| 339 | var last ltx.Checksum |
| 340 | var previous uint64 |
| 341 | for i, selected := range plan.Files { |
| 342 | if i != 0 && (previous == ^uint64(0) || !ltx.IsContiguous(ltx.TXID(previous), ltx.TXID(selected.MinTXID), ltx.TXID(selected.MaxTXID))) { |
| 343 | return nil, 0, genselect.ErrGap |
| 344 | } |
| 345 | rc, err := reader.OpenVersion(ctx, bucket, selected.Key, selected.VersionID) |
| 346 | if err != nil { |
| 347 | return nil, 0, fmt.Errorf("open selected %s@%s: %w", selected.Key, selected.VersionID, err) |
| 348 | } |
| 349 | data, readErr := io.ReadAll(rc) |
| 350 | closeErr := rc.Close() |
| 351 | if readErr != nil { |
| 352 | return nil, 0, fmt.Errorf("read selected %s: %w", selected.Key, readErr) |
| 353 | } |
| 354 | if closeErr != nil { |
| 355 | return nil, 0, fmt.Errorf("close selected %s: %w", selected.Key, closeErr) |
| 356 | } |
| 357 | if int64(len(data)) != selected.Size { |
| 358 | return nil, 0, fmt.Errorf("selected %s size %d, want %d", selected.Key, len(data), selected.Size) |
| 359 | } |
| 360 | dec := ltx.NewDecoder(bytes.NewReader(data)) |
| 361 | if err := dec.DecodeHeader(); err != nil { |
| 362 | return nil, 0, fmt.Errorf("decode %s header: %w", selected.Key, err) |
| 363 | } |
| 364 | h := dec.Header() |
| 365 | if uint64(h.MinTXID) != selected.MinTXID || uint64(h.MaxTXID) != selected.MaxTXID { |
| 366 | return nil, 0, fmt.Errorf("selected %s range mismatch", selected.Key) |
| 367 | } |
| 368 | // Litestream emits HeaderFlagNoChecksum but copies the last input's |
| 369 | // database checksum. Re-encode one page at a time for that format, |
| 370 | // retaining the original checksum for the final restored database. |
| 371 | // Nothing uses the converted bytes until the original proof passes. |
| 372 | var encoded bytes.Buffer |
| 373 | var enc *ltx.Encoder |
| 374 | if !h.NoChecksum() { |
| 375 | enc, err = ltx.NewEncoder(&encoded) |
| 376 | if err != nil { |
| 377 | return nil, 0, err |
| 378 | } |
| 379 | normalized := h |
| 380 | normalized.Flags |= ltx.HeaderFlagNoChecksum |
| 381 | normalized.PreApplyChecksum = 0 |
| 382 | if err = enc.EncodeHeader(normalized); err != nil { |
| 383 | return nil, 0, err |
| 384 | } |
| 385 | } |
| 386 | buf := make([]byte, h.PageSize) |
| 387 | var ph ltx.PageHeader |
| 388 | for { |
| 389 | err = dec.DecodePage(&ph, buf) |
| 390 | if errors.Is(err, io.EOF) { |
| 391 | break |
| 392 | } |
| 393 | if err != nil { |
| 394 | return nil, 0, fmt.Errorf("decode %s: %w", selected.Key, err) |
| 395 | } |
| 396 | if enc != nil { |
| 397 | if err := enc.EncodePage(ph, buf); err != nil { |
| 398 | return nil, 0, err |
| 399 | } |
| 400 | } |
| 401 | } |
| 402 | if err := dec.Close(); err != nil { |
| 403 | return nil, 0, fmt.Errorf("verify %s trailer: %w", selected.Key, err) |
| 404 | } |
| 405 | t := dec.Trailer() |
| 406 | if i == 0 && plan.Pin != nil && plan.Pin.SnapshotCRC64 != 0 && uint64(t.FileChecksum) != plan.Pin.SnapshotCRC64 { |
| 407 | return nil, 0, genselect.ErrPinnedMismatch |
| 408 | } |
| 409 | if enc != nil { |
| 410 | if err := enc.Close(); err != nil { |
| 411 | return nil, 0, err |
| 412 | } |
| 413 | data = encoded.Bytes() |
| 414 | } |
| 415 | info := <x.FileInfo{Level: selected.Level, MinTXID: h.MinTXID, MaxTXID: h.MaxTXID, PreApplyChecksum: h.PreApplyChecksum, PostApplyChecksum: t.PostApplyChecksum, Size: int64(len(data))} |
| 416 | files = append(files, pinnedFile{plan: selected, data: data, info: info, infoPageSize: int(h.PageSize)}) |
| 417 | last, previous = t.PostApplyChecksum, selected.MaxTXID |
| 418 | } |
| 419 | if previous != plan.MaxTXID { |
| 420 | return nil, 0, fmt.Errorf("selected final TXID %d, want %d", previous, plan.MaxTXID) |
| 421 | } |
| 422 | return files, last, nil |
| 423 | } |
| 424 | |
| 425 | type pinnedClient struct{ files []pinnedFile } |
| 426 | |
| 427 | func (*pinnedClient) Type() string { return "selected-versions" } |
| 428 | func (*pinnedClient) Init(context.Context) error { return nil } |
| 429 | func (*pinnedClient) SetLogger(*slog.Logger) {} |
| 430 | func (c *pinnedClient) LTXFiles(_ context.Context, level int, seek ltx.TXID, _ bool) (ltx.FileIterator, error) { |
| 431 | var a []*ltx.FileInfo |
| 432 | for _, f := range c.files { |
| 433 | if f.info.Level == level && f.info.MaxTXID >= seek { |
| 434 | cp := *f.info |
| 435 | a = append(a, &cp) |
| 436 | } |
| 437 | } |
| 438 | sort.Slice(a, func(i, j int) bool { return a[i].MinTXID < a[j].MinTXID }) |
| 439 | return ltx.NewFileInfoSliceIterator(a), nil |
| 440 | } |
| 441 | func (c *pinnedClient) OpenLTXFile(_ context.Context, level int, min, max ltx.TXID, offset, size int64) (io.ReadCloser, error) { |
| 442 | for _, f := range c.files { |
| 443 | if f.info.Level == level && f.info.MinTXID == min && f.info.MaxTXID == max { |
| 444 | if offset < 0 || size < 0 || offset > int64(len(f.data)) { |
| 445 | return nil, os.ErrInvalid |
| 446 | } |
| 447 | end := int64(len(f.data)) |
| 448 | if size > 0 && offset+size < end { |
| 449 | end = offset + size |
| 450 | } |
| 451 | return io.NopCloser(bytes.NewReader(f.data[offset:end])), nil |
| 452 | } |
| 453 | } |
| 454 | return nil, os.ErrNotExist |
| 455 | } |
| 456 | func (*pinnedClient) WriteLTXFile(context.Context, int, ltx.TXID, ltx.TXID, io.Reader) (*ltx.FileInfo, error) { |
| 457 | return nil, ErrUnsupported |
| 458 | } |
| 459 | func (*pinnedClient) DeleteLTXFiles(context.Context, []*ltx.FileInfo) error { return ErrUnsupported } |
| 460 | func (*pinnedClient) DeleteAll(context.Context) error { return ErrUnsupported } |
| 461 | |