carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package activator
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 "os"
12 "path/filepath"
13 "strconv"
14 "strings"
15
16 "github.com/carlosframework/platform/internal/instancefs"
17 "github.com/carlosframework/platform/internal/lease"
18 "github.com/carlosframework/platform/internal/registry"
19 "github.com/carlosframework/platform/internal/seeds"
20)
21
22// SeedObjects reads a seed's snapshot, seeds/<host>/<id>/snapshot.db, from
23// this box's own region data bucket.
24type SeedObjects interface {
25 Open(ctx context.Context, host, id string) (io.ReadCloser, error)
26}
27
28// SeedMarkerSuffix names the installation evidence beside a placed
29// database: <db>.seed holds the seed's id (spec §3.4 step 3, codex R2-1).
30const SeedMarkerSuffix = ".seed"
31
32// seedMarkerMax bounds the marker read: it holds a 29-byte id and a newline,
33// and it sits in tenant-writable ground.
34const seedMarkerMax = 128
35
36// hasOwnReplica and checkSeedDB are seams for the tests.
37var (
38 hasOwnReplica = lease.HasReplica
39 checkSeedDB = lease.CheckDBContext
40)
41
42// SeedPlacementError is a wake the seed fallback failed or refused. Its
43// Sentence is the member's (SD-N14 to SD-N19): memberSafeWakeReason passes
44// it to carlos logs whole. Err is the cause, for the box log.
45type SeedPlacementError struct {
46 Sentence string
47 Err error
48}
49
50func (e *SeedPlacementError) Error() string { return e.Sentence }
51func (e *SeedPlacementError) Unwrap() error { return e.Err }
52
53func seedMismatch(host string) error {
54 return &SeedPlacementError{Sentence: fmt.Sprintf("%s could not start from its seed: the snapshot's length or sha256 does not match its record.", host)}
55}
56
57func seedNotDB(host string, err error) error {
58 return &SeedPlacementError{Sentence: fmt.Sprintf("%s could not start from its seed: the snapshot is not a database that passes an integrity check.", host), Err: err}
59}
60
61func seedUnread(host string, err error) error {
62 return &SeedPlacementError{Sentence: fmt.Sprintf("%s could not start from its seed: the snapshot could not be read. The next wake tries again.", host), Err: err}
63}
64
65func seedRefusedFlat(host string) error {
66 return &SeedPlacementError{Sentence: fmt.Sprintf("%s could not start from its seed: this box does not run instances nested.", host)}
67}
68
69func seedRefusedClaim(host string) error {
70 return &SeedPlacementError{Sentence: fmt.Sprintf("%s could not start from its seed: claim control is on for this box.", host)}
71}
72
73func seedPlaceFailed(host string, err error) error {
74 return &SeedPlacementError{Sentence: fmt.Sprintf("%s could not start from its seed: it could not be put in place. The next wake tries again.", host), Err: err}
75}
76
77// ownsReplica reports a seed whose host already has a replica of its own:
78// placed is set only after the first upload, and committed only from
79// placed (spec §5.4). Such a row restores its own lineage and never boots
80// fresh: after commit the app retires its old process, so an empty boot
81// would read as the import's data lost (preflight M11).
82func ownsReplica(src *seeds.RowSeed) bool {
83 return src.State == seeds.StatePlaced || src.State == seeds.StateCommitted
84}
85
86// seedRestores reports a row Restore sends through seededRestore: a seed
87// still to place, or one whose own replica exists.
88func seedRestores(src *seeds.RowSeed) bool {
89 return src != nil && (src.Placeable() || ownsReplica(src))
90}
91
92// seededRestore is the plain-path seed fallback (spec §3.4). It never
93// reports fresh: a seeded row never runs the manifest's seed command, and
94// every failure fails the wake instead of falling through to an empty
95// database. Every file it touches in the instance directory goes through
96// instancefs, so no symlink there is followed.
97func (b *ExecBackend) seededRestore(ctx context.Context, rt registry.Route, src *seeds.RowSeed) (bool, error) {
98 host := rt.Host
99 fail := func(err error) (bool, error) {
100 var spe *SeedPlacementError
101 if errors.As(err, &spe) && spe.Err != nil {
102 log.Printf("activator: %s: seed %s: %v: %v", host, src.ID, err, spe.Err)
103 } else {
104 log.Printf("activator: %s: seed %s: %v", host, src.ID, err)
105 }
106 return false, err
107 }
108 if err := seeds.Check(host, src.ID); err != nil {
109 return fail(fmt.Errorf("seed fallback for %s: %w", host, err))
110 }
111 // restoreOwn restores the host's own lineage, the staging fallback
112 // included, and never reports fresh: a host that has (or was listed as
113 // having) a replica of its own and finds none must not boot empty. The
114 // next wake asks again.
115 //
116 // The database it leaves is this seed's lineage, so its <db>.seed
117 // marker is written to name the seed (final review carry item): the
118 // rollback's missing-row purge and an aborted seed's local purge decide
119 // by the marker, and a database with none is left in place.
120 restoreOwn := func() (bool, error) {
121 fresh, err := b.restorePlain(ctx, rt)
122 if err != nil {
123 return false, err
124 }
125 if fresh {
126 return fail(fmt.Errorf("seed fallback for %s: found no replica of its own to restore", host))
127 }
128 db := b.pathsFor(rt).dbPath
129 if err := instancefs.WriteAtomic(filepath.Dir(db), filepath.Base(db)+SeedMarkerSuffix, []byte(src.ID+"\n"), 0o600); err != nil {
130 return fail(seedPlaceFailed(host, err))
131 }
132 return false, nil
133 }
134 // A placed seed stays on the row for life, and placed is set only once
135 // the host's own replica exists. So a placed row's wake is a plain
136 // restore on any box: no layout or claim refusal, no listing on every
137 // wake, and the staging fallback still promotes a moved host's replica
138 // (the snapshot was deleted at placement; it is never read again). A
139 // committed import was placed first, so it is restored the same way.
140 if ownsReplica(src) {
141 return restoreOwn()
142 }
143 if b.ClaimArmed {
144 return fail(seedRefusedClaim(host))
145 }
146 paths := b.pathsFor(rt)
147 if paths.layout != LayoutNested {
148 return fail(seedRefusedFlat(host))
149 }
150 dir, base := filepath.Dir(paths.dbPath), filepath.Base(paths.dbPath)
151 // The instance directory may not exist yet on a first wake. Created as
152 // prepare and lease.Restore create it.
153 if err := os.MkdirAll(dir, 0o700); err != nil {
154 return fail(seedPlaceFailed(host, err))
155 }
156
157 // Step 1: a replica of its own means this host was placed already. A
158 // listing that cannot tell fails the wake: read as "no replica", it
159 // would place the snapshot over a lineage that exists.
160 has, err := hasOwnReplica(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host), b.Cfg.Region)
161 if err != nil {
162 return fail(fmt.Errorf("seed fallback for %s: cannot tell whether it has a replica of its own: %w", host, err))
163 }
164 if has {
165 if _, err := restoreOwn(); err != nil {
166 return false, err
167 }
168 b.finishPlacement(ctx, rt, src)
169 return false, nil
170 }
171
172 // An earlier first-boot seed command at this path whose processes were
173 // not proven gone left an obligation beside the database
174 // (settleSeedObligation, as restorePlain answers it): it is answered
175 // before the file there is kept as this seed's, purged or replaced, so
176 // nothing is placed under a process that may still write the path. A
177 // seeded row runs no seed command itself, so this is another row's
178 // leftover. Unproven, the wake is refused and the lease left to its
179 // TTL, as on the plain path.
180 if err := b.settleSeedObligation(ctx, rt, paths.dbPath); err != nil {
181 return fail(err)
182 }
183
184 // This seed, placed by an interrupted first wake: keep it. Replication
185 // starts in doWake, as generation one of the host's own lineage.
186 placed, err := seedPlacedAt(dir, base, src.ID)
187 if err != nil {
188 return fail(seedPlaceFailed(host, err))
189 }
190 if placed {
191 log.Printf("activator: %s: keeping seed %s placed by an earlier wake", host, src.ID)
192 return false, nil
193 }
194 if b.Seeds == nil {
195 return fail(seedUnread(host, errors.New("no seed reader on this box")))
196 }
197 // Anything else is purged before the fetch: another seed's database,
198 // one with no marker, their WAL and SHM, the marker, litestream's
199 // metadata, and an earlier fetch's leftovers.
200 if err := purgeSeedTarget(dir, base); err != nil {
201 return fail(seedPlaceFailed(host, err))
202 }
203 if err := b.placeSeed(ctx, host, src, dir, base); err != nil {
204 return fail(err)
205 }
206 log.Printf("activator: %s: placed seed %s (%d bytes)", host, src.ID, src.Bytes)
207 return false, nil
208}
209
210// reconcileSeedMarker runs first on the plain restore path, whether or not
211// a database is at dbPath (final review F2b, re-review finding 8). A
212// <db>.seed marker beside the path that names a seed other than the row's
213// own current seed (the row has none, or another) is a leftover of that
214// seed: an aborted --from seed whose purge ran on no box, a rolled-back
215// import, or a placement that crashed between its marker and its rename
216// and left the marker alone. Whatever is there goes, through
217// purgeSeedTarget (the database and its sidecars, the marker, any
218// .seed-sd_*.tmp), before anything is restored or created, so the file is
219// never served as this row's warm cache and no new database is ever born
220// beside a foreign marker that a later wake would read as the old seed's.
221// A marker naming the row's own seed, or none, changes nothing. A marker
222// that cannot be read fails the wake, with nothing served or purged.
223//
224// A relative path is no seeded path (seeded rows are always given the
225// nested, absolute one), so it is left to the restore as before.
226func reconcileSeedMarker(rt registry.Route, dbPath string) error {
227 if !filepath.IsAbs(dbPath) {
228 return nil
229 }
230 dbPath = filepath.Clean(dbPath)
231 marker, err := ReadSeedMarker(dbPath)
232 if err != nil {
233 return fmt.Errorf("restore %s: the seed marker beside %s cannot be read: %w", rt.Host, dbPath, err)
234 }
235 own := rowSeedID(rt.SeedSource)
236 if marker == "" || marker == own {
237 return nil
238 }
239 dir, base, err := splitDBPath(dbPath)
240 if err != nil {
241 return err
242 }
243 if err := purgeSeedTarget(dir, base); err != nil {
244 return fmt.Errorf("restore %s: purge what seed %s left at %s: %w", rt.Host, marker, dbPath, err)
245 }
246 log.Printf("activator: %s: %s held seed %s's marker, not this route's (seed %q): purged before the restore", rt.Host, dbPath, marker, own)
247 return nil
248}
249
250// seedPlacedAt reports whether dir/base is a regular file whose marker names
251// id. A missing, symlinked or oversized marker or database is "not placed",
252// and is purged; any other error is returned.
253func seedPlacedAt(dir, base, id string) (bool, error) {
254 m, err := instancefs.ReadBeneathLimit(dir, base+SeedMarkerSuffix, seedMarkerMax)
255 if err != nil {
256 if notAPlacedFile(err) {
257 return false, nil
258 }
259 return false, err
260 }
261 if strings.TrimSpace(string(m)) != id {
262 return false, nil
263 }
264 f, err := instancefs.OpenBeneath(dir, base, os.O_RDONLY, 0)
265 if err != nil {
266 if notAPlacedFile(err) {
267 return false, nil
268 }
269 return false, err
270 }
271 f.Close()
272 return true, nil
273}
274
275func notAPlacedFile(err error) bool {
276 return errors.Is(err, os.ErrNotExist) || errors.Is(err, instancefs.ErrSymlink) ||
277 errors.Is(err, instancefs.ErrNotRegular) || errors.Is(err, instancefs.ErrTooLarge)
278}
279
280// seedTempName is where a seed's snapshot is fetched to, in the instance
281// directory. The id is a seed id (seeds.Check), one clean path element.
282func seedTempName(id string) string { return ".seed-" + id + ".tmp" }
283
284// PurgeSeedLocal removes an instance's local database at dbPath and
285// everything beside it that could stand in for one (purgeSeedTarget): the
286// database, -wal, -shm, the <db>.seed marker, litestream's metadata and
287// any seed fetch leftovers, through instancefs. It is the import
288// rollback's local removal (seeded-instances spec §5.6 step 3), run after
289// the host is fenced and stopped. A directory that is not there holds
290// nothing, so a second run, or one for an instance never woken, is done.
291func PurgeSeedLocal(dbPath string) error {
292 dir, base, err := splitDBPath(dbPath)
293 if err != nil {
294 return err
295 }
296 if _, err := os.Lstat(dir); errors.Is(err, os.ErrNotExist) {
297 return nil
298 }
299 return purgeSeedTarget(dir, base)
300}
301
302// ReadSeedMarker is the seed id the <db>.seed marker beside dbPath names,
303// read through instancefs: the evidence that the database there was placed
304// for that seed (spec §3.4 step 3). No marker, or one that is a symlink,
305// not a regular file or oversized, is "" (it names no seed); so is a
306// directory that is not there. Any other failure to read it is an error:
307// the caller cannot tell.
308func ReadSeedMarker(dbPath string) (string, error) {
309 dir, base, err := splitDBPath(dbPath)
310 if err != nil {
311 return "", err
312 }
313 if _, err := os.Lstat(dir); errors.Is(err, os.ErrNotExist) {
314 return "", nil
315 }
316 m, err := instancefs.ReadBeneathLimit(dir, base+SeedMarkerSuffix, seedMarkerMax)
317 if err != nil {
318 if notAPlacedFile(err) {
319 return "", nil
320 }
321 return "", err
322 }
323 return strings.TrimSpace(string(m)), nil
324}
325
326// splitDBPath is a clean absolute database path's directory and file name.
327func splitDBPath(dbPath string) (dir, base string, err error) {
328 if !filepath.IsAbs(dbPath) || filepath.Clean(dbPath) != dbPath {
329 return "", "", fmt.Errorf("activator: %q is not a clean absolute database path", dbPath)
330 }
331 dir, base = filepath.Split(dbPath)
332 if base == "" {
333 return "", "", fmt.Errorf("activator: %q names no database", dbPath)
334 }
335 return filepath.Clean(dir), base, nil
336}
337
338// purgeSeedTarget removes everything at and beside dir/base that could
339// stand in for a seed's database: what PurgeLocalDBArtifacts removes in its
340// full form, the marker, and any seed temp file, through instancefs.
341func purgeSeedTarget(dir, base string) error {
342 names := []string{base, base + "-wal", base + "-shm", base + SeedMarkerSuffix,
343 base + "-litestream", ".activator-" + base + "-litestream"}
344 ents, err := os.ReadDir(dir)
345 if err != nil {
346 return err
347 }
348 for _, e := range ents {
349 if n := e.Name(); strings.HasPrefix(n, ".seed-sd_") && strings.Contains(n, ".tmp") {
350 names = append(names, n)
351 }
352 }
353 for _, n := range names {
354 if err := instancefs.RemoveAllBeneath(dir, n); err != nil {
355 return err
356 }
357 }
358 return nil
359}
360
361// placeSeed fetches and checks the snapshot, then writes the marker and
362// renames the snapshot onto dir/base. The marker goes first, so a database
363// at that path always has one. The temp file and any sidecar the check left
364// are removed on every path.
365//
366// The instance directory is the tenant's uid's to write, so the file is
367// pinned by identity: the one fetchSeed wrote is reopened beneath dir and
368// must be the same file, the integrity check reads it through that
369// descriptor, it must still be the entry at tmp after the check, and the
370// entry renamed onto dir/base must still be it. Anything else is refused,
371// and a refusal after the rename removes the marker and the database, so no
372// unchecked file is ever kept as this seed.
373func (b *ExecBackend) placeSeed(ctx context.Context, host string, src *seeds.RowSeed, dir, base string) error {
374 tmp := seedTempName(src.ID)
375 defer func() {
376 for _, n := range []string{tmp, tmp + "-wal", tmp + "-shm"} {
377 _ = instancefs.RemoveAllBeneath(dir, n)
378 }
379 }()
380 wrote, err := b.fetchSeed(ctx, host, src, dir, tmp)
381 if err != nil {
382 return err
383 }
384 f, err := openSameFile(dir, tmp, wrote)
385 if err != nil {
386 return seedPlaceFailed(host, err)
387 }
388 cerr := checkSeedDB(ctx, "/proc/self/fd/"+strconv.FormatUint(uint64(f.Fd()), 10))
389 f.Close()
390 if cerr != nil {
391 return seedNotDB(host, cerr)
392 }
393 if err := sameFileAt(dir, tmp, wrote); err != nil {
394 return seedPlaceFailed(host, err)
395 }
396 if err := instancefs.WriteAtomic(dir, base+SeedMarkerSuffix, []byte(src.ID+"\n"), 0o600); err != nil {
397 return seedPlaceFailed(host, err)
398 }
399 if err := instancefs.RenameBetween(dir, tmp, dir, base); err != nil {
400 return seedPlaceFailed(host, err)
401 }
402 if err := sameFileAt(dir, base, wrote); err != nil {
403 _ = instancefs.RemoveAllBeneath(dir, base+SeedMarkerSuffix)
404 _ = instancefs.RemoveAllBeneath(dir, base)
405 return seedPlaceFailed(host, err)
406 }
407 return nil
408}
409
410// errSwapped is a seed file that is no longer the one fetchSeed wrote.
411var errSwapped = errors.New("the fetched snapshot was replaced in the instance directory")
412
413// openSameFile opens dir/name beneath dir, read-only, and refuses it unless
414// it is the file want describes.
415func openSameFile(dir, name string, want os.FileInfo) (*os.File, error) {
416 f, err := instancefs.OpenBeneath(dir, name, os.O_RDONLY, 0)
417 if err != nil {
418 return nil, err
419 }
420 fi, err := f.Stat()
421 if err != nil {
422 f.Close()
423 return nil, err
424 }
425 if !os.SameFile(fi, want) {
426 f.Close()
427 return nil, errSwapped
428 }
429 return f, nil
430}
431
432// sameFileAt reports errSwapped unless dir/name is the file want describes.
433func sameFileAt(dir, name string, want os.FileInfo) error {
434 f, err := openSameFile(dir, name, want)
435 if err != nil {
436 return err
437 }
438 return f.Close()
439}
440
441// fetchSeed copies the snapshot into dir/tmp, checking its length and
442// sha256 against the row as it writes, and returns the identity of the file
443// it wrote. It reads at most one byte more than the row records, so an
444// oversized object is refused without being read.
445func (b *ExecBackend) fetchSeed(ctx context.Context, host string, src *seeds.RowSeed, dir, tmp string) (os.FileInfo, error) {
446 rc, err := b.Seeds.Open(ctx, host, src.ID)
447 if err != nil {
448 return nil, seedUnread(host, err)
449 }
450 defer rc.Close()
451 f, err := instancefs.OpenBeneath(dir, tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
452 if err != nil {
453 return nil, seedPlaceFailed(host, err)
454 }
455 h := sha256.New()
456 r := &readErrs{r: io.LimitReader(rc, src.Bytes+1)}
457 n, cerr := io.Copy(io.MultiWriter(f, h), r)
458 serr := f.Sync()
459 fi, iderr := f.Stat()
460 ferr := f.Close()
461 if r.err != nil {
462 return nil, seedUnread(host, r.err)
463 }
464 if cerr != nil || serr != nil || iderr != nil || ferr != nil {
465 return nil, seedPlaceFailed(host, errors.Join(cerr, serr, iderr, ferr))
466 }
467 if n != src.Bytes || hex.EncodeToString(h.Sum(nil)) != src.SHA256 {
468 return nil, seedMismatch(host)
469 }
470 return fi, nil
471}
472
473// readErrs keeps the read side's error apart from the write side's, which
474// io.Copy returns alike: a bucket that fails is SD-N16, a disk that fails is
475// SD-N19.
476type readErrs struct {
477 r io.Reader
478 err error
479}
480
481func (e *readErrs) Read(p []byte) (int, error) {
482 n, err := e.r.Read(p)
483 if err != nil && err != io.EOF {
484 e.err = err
485 }
486 return n, err
487}
488
489// SeedPlaced records a seed placed once the host's own replica holds it
490// (spec §3.4 steps 4 and 5): it waits for the first upload, then
491// OnSeedPlaced CASes the record to placed and deletes seeds/<host>/<id>/.
492// The snapshot goes only after the replica exists, so a crash at any point
493// retries from the placed local file or the snapshot. A replicator that
494// cannot be asked for its first upload records nothing.
495func (b *ExecBackend) SeedPlaced(ctx context.Context, rt registry.Route, rep Rep) error {
496 src, err := seeds.ParseRowSeed(rt.SeedSource)
497 if err != nil || src == nil || !src.Placeable() || src.State == seeds.StatePlaced {
498 return err
499 }
500 s, ok := rep.(interface{ SyncAndWait(context.Context) error })
501 if !ok {
502 return fmt.Errorf("first upload of %s: the replicator cannot confirm it", rt.Host)
503 }
504 if err := s.SyncAndWait(ctx); err != nil {
505 return fmt.Errorf("first upload of %s: %w", rt.Host, err)
506 }
507 if b.OnSeedPlaced == nil {
508 return errors.New("no seed recorder on this box")
509 }
510 return b.OnSeedPlaced(ctx, rt.Account, rt.App, rt.Host, src.ID)
511}
512
513// finishPlacement is SeedPlaced for a host whose own replica already
514// exists (a placed CAS lost on an earlier wake). Best effort: a failure is
515// logged, never the wake's, and the next wake asks again.
516func (b *ExecBackend) finishPlacement(ctx context.Context, rt registry.Route, src *seeds.RowSeed) {
517 if b.OnSeedPlaced == nil {
518 return
519 }
520 if err := b.OnSeedPlaced(ctx, rt.Account, rt.App, rt.Host, src.ID); err != nil {
521 log.Printf("activator: %s: seed %s not recorded as placed yet: %v", rt.Host, src.ID, err)
522 }
523}
524