host: split the landing into orchestration, park adapters and install helpers (no behaviour change)
movesland.go keeps landingPass and the phases; moveslandpark.go holds the park engine assembly, the stand-in view and authorizer, the generation classifier and the own-location lease; moveslandinstall.go holds the archive extraction, the sealed timers, the database install, the rebuild and the fsync helpers. Every block moved verbatim.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+499
−451
approved_moves_copy_test.go+2 −0internal/host/movesland.go+0 −451internal/host/moveslandinstall.go+321 −0internal/host/moveslandpark.go+176 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index a035ae96d..8bf6ace78 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -558,6 +558,8 @@ var movesScanFiles = []string{ |
| "internal/host/movesheldlock.go", |
| "internal/host/moveshold.go", |
| "internal/host/movesland.go", |
| + "internal/host/moveslandinstall.go", |
| + "internal/host/moveslandpark.go", |
| "internal/host/movesmeasure.go", |
| "internal/host/movesmeasure_linux.go", |
| "internal/host/movesmeasure_other.go", |
diff --git a/internal/host/movesland.go b/internal/host/movesland.go| index bce15a9d7..746de3755 100644 |
| --- a/internal/host/movesland.go |
| +++ b/internal/host/movesland.go |
| @@ -2,11 +2,9 @@ package host |
| import ( |
| "context" |
| - "crypto/ecdh" |
| "encoding/json" |
| "errors" |
| "fmt" |
| - "io" |
| "log" |
| "os" |
| "path/filepath" |
| @@ -15,7 +13,6 @@ import ( |
| "strings" |
| "time" |
| - "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| @@ -105,12 +102,6 @@ const ( |
| // C8 (the prepared-manifest build, region HA spec 1 §5.8) is not on main. |
| const landingFilesManifestSkipped = "skipped: the prepared files manifest needs C8 (files.BuildPrepared)" |
| -// filesGenMarker is the .files-gen body (§5.1): the op and the incarnation. |
| -type filesGenMarker struct { |
| - Op string `json:"op"` |
| - Incarnation string `json:"incarnation"` |
| -} |
| - |
| // landingRun is one pass's state for one landing record. |
| type landingRun struct { |
| w *movesWorker |
| @@ -363,87 +354,6 @@ func (l *landingRun) landingCheck(phase string) func(*hostclaim.Claim) error { |
| } |
| } |
| -// authorizeChore is park.Local.Authorize's stand-in until Plan 5: a chore is |
| -// run only when it is this landing's own and the claim still names this |
| -// landing with this box executing. |
| -func (l *landingRun) authorizeChore(_ context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error { |
| - if c == nil || o.Op != l.g.ID || c.Op.ID != l.g.ID || c.Op.Kind != hostclaim.KindLanding || c.Op.Exec != l.cfg().Box { |
| - return fmt.Errorf("%w: not this landing's chore", park.ErrUnsafe) |
| - } |
| - return nil |
| -} |
| - |
| -// authorizeObligation is park.Engine.Authorize's stand-in (ruling G17): the |
| -// same rule over an observation. |
| -func (l *landingRun) authorizeObligation(ctx context.Context, obs hostclaim.Observed, o hostclaim.Obligation) error { |
| - if obs.Absent { |
| - return park.ErrUnsafe |
| - } |
| - return l.authorizeChore(ctx, obs.Claim, o) |
| -} |
| - |
| -// landingView is park.Engine.View's stand-in (ruling G17): the location's |
| -// objects classified by key, and the source's region authoritative, which |
| -// §5.1's table says it is for a landing before the target's probe is |
| -// admitted (every phase here). Plan 5's provider replaces it; a test may set |
| -// cfg.ParkView to the view a later state has. |
| -func (l *landingRun) landingView(_ context.Context, obs hostclaim.Observed) (park.View, error) { |
| - if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil { |
| - return park.View{}, park.ErrUnsafe |
| - } |
| - location := strings.TrimSuffix(obs.Claim.Holder.Location, "/") |
| - return park.View{ClaimETag: obs.ETag, Region: l.cfg().Region, AuthoritativeRegion: l.rec.Move.From, |
| - GenerationOf: func(r park.Ref) (string, bool) { return landingGenerationOf(location, r.Key) }}, nil |
| -} |
| - |
| -// landingMarkerGeneration is what landingGenerationOf answers for the |
| -// location's generation.json. The marker is the one key every generation |
| -// reuses (§5.1): the probe's admission supersedes an earlier stay's marker |
| -// with If-Match rather than deleting it, and a park-generation chore whose |
| -// deletion set named the marker key could never be acknowledged once a new |
| -// marker stands there (the engine skips the marker key and then refuses a |
| -// recorded delete at it). So the marker belongs to no generation's chore: |
| -// parking never queues one for it and the engine passes it over. "@" keeps |
| -// it apart from any generation name, as hostclaim.RootGeneration does. |
| -const landingMarkerGeneration = "@marker" |
| - |
| -// landingGenerationOf classifies an object under a holder location by |
| -// generation, as the view does: `g-<gen>/...` and `files/g-<gen>/...` are |
| -// gen's; a level directory (four hex digits) at the location is the |
| -// origin, ""; generation.json is landingMarkerGeneration; anything else is |
| -// unknown, and a caller fails closed on it. The naming rule is |
| -// moves.GenerationPrefix's twin. |
| -func landingGenerationOf(location, key string) (string, bool) { |
| - rel, ok := strings.CutPrefix(key, location+"/") |
| - if !ok || rel == "" { |
| - return "", false |
| - } |
| - for _, p := range []string{"g-", "files/g-"} { |
| - if rest, ok := strings.CutPrefix(rel, p); ok { |
| - if gen, _, ok := strings.Cut(rest, "/"); ok && gen != "" { |
| - return gen, true |
| - } |
| - return "", false |
| - } |
| - } |
| - if rel == "generation.json" { |
| - return landingMarkerGeneration, true |
| - } |
| - if first, _, ok := strings.Cut(rel, "/"); ok && len(first) == 4 && isHexString(first) { |
| - return "", true |
| - } |
| - return "", false |
| -} |
| - |
| -func isHexString(s string) bool { |
| - for _, c := range s { |
| - if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { |
| - return false |
| - } |
| - } |
| - return s != "" |
| -} |
| - |
| // route is the host's registry row on this box. |
| func (l *landingRun) route() (registry.Route, error) { |
| rt, err := l.cfg().Route(l.rec.Host) |
| @@ -743,80 +653,6 @@ func (l *landingRun) parking(ctx context.Context) (bool, error) { |
| return l.advance(ctx, provision.LandParking, provision.LandBuilding, nil) |
| } |
| -// engine is the park engine over this box's own bucket: the wired leases, |
| -// and the view and authorizer Plan 5 supplies, or the stand-ins. |
| -func (l *landingRun) engine(vs lease.VersionedStore) park.Engine { |
| - cfg := l.cfg() |
| - view, auth := cfg.ParkView, cfg.ParkAuthorize |
| - if view == nil { |
| - view = l.landingView |
| - } |
| - if auth == nil { |
| - auth = l.authorizeObligation |
| - } |
| - return park.Engine{Store: vs, Claims: l.s, Bucket: cfg.OwnBucket, Region: cfg.Region, Leases: cfg.ParkLeases, |
| - Now: cfg.Now, NewID: obligationID, View: view, Authorize: auth, After: cfg.parkFault} |
| -} |
| - |
| -// lease is the own-location lease the build holds (§7.6 checking: "hold it |
| -// through published"), kept in the worker's per-host map across passes and |
| -// re-acquired after a restart. waiting is true when another holder has it. |
| -func (l *landingRun) lease(ctx context.Context, holder hostclaim.Holder) (held park.HeldLease, waiting bool, err error) { |
| - cfg := l.cfg() |
| - host := l.rec.Host |
| - l.w.mu.Lock() |
| - held = l.w.landLeases[host] |
| - l.w.mu.Unlock() |
| - if held != nil { |
| - if err := held.Check(ctx); err == nil { |
| - return held, false, nil |
| - } |
| - l.releaseLease() |
| - } |
| - held, err = cfg.OwnLease(ctx, cfg.OwnBucket, strings.TrimSuffix(holder.Location, "/")) |
| - if err != nil { |
| - var he *lease.HeldError |
| - if errors.As(err, &he) { |
| - log.Printf("moves: %s: another holder has the location's lease; the build waits", host) |
| - return nil, true, nil |
| - } |
| - return nil, false, fmt.Errorf("the location's lease: %w", err) |
| - } |
| - l.w.mu.Lock() |
| - if l.w.landLeases == nil { |
| - l.w.landLeases = map[string]park.HeldLease{} |
| - } |
| - l.w.landLeases[host] = held |
| - l.w.mu.Unlock() |
| - return held, false, nil |
| -} |
| - |
| -// releaseLease releases the host's own-location lease, if held. |
| -func (l *landingRun) releaseLease() { |
| - host := l.rec.Host |
| - l.w.mu.Lock() |
| - held := l.w.landLeases[host] |
| - delete(l.w.landLeases, host) |
| - l.w.mu.Unlock() |
| - if held == nil { |
| - return |
| - } |
| - rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| - defer cancel() |
| - if err := held.Release(rctx); err != nil { |
| - log.Printf("moves: %s: release the location's lease: %v", host, err) |
| - } |
| -} |
| - |
| -// settleLease releases the lease once the record is past published, marked |
| -// for a rollback, or no longer landing: the lease is the build's alone. |
| -func (l *landingRun) settleLease() { |
| - m := l.rec.Move |
| - if m == nil || m.State != provision.MoveLanding || m.Reason != "" || !landingBefore(m.LandingPhase, provision.LandLocal) { |
| - l.releaseLease() |
| - } |
| -} |
| - |
| // building (§7.6, §5.1 step 2): the generation built from the seal's pinned |
| // replica versions under the location's lease (moves.BuildGeneration: the |
| // intent, the snapshot, the anchor, the completed install manifest, every |
| @@ -1095,116 +931,6 @@ func (l *landingRun) local_(ctx context.Context) (bool, error) { |
| return l.advance(ctx, provision.LandLocal, provision.LandConfigured, nil) |
| } |
| -// extractArchive reads the seal's archive and manifest by exact version from |
| -// this box's own bucket, verifies the archive against the manifest and the |
| -// record's digests (archive.Verify: the sealed digest, every entry), and |
| -// extracts it into dir and envPath (archive.Open under the box's secrets |
| -// key, then archive.Extract, which admits nothing it did not read whole). |
| -func (l *landingRun) extractArchive(ctx context.Context, seal moves.Seal, want provision.MoveFilesDigest, dir, envPath string) error { |
| - cfg := l.cfg() |
| - var sealedObj, manifestObj *moves.SealedObject |
| - for i := range seal.Files { |
| - switch o := &seal.Files[i]; { |
| - case strings.HasSuffix(o.Key, ".sealed"): |
| - sealedObj = o |
| - case strings.HasSuffix(o.Key, ".json"): |
| - manifestObj = o |
| - } |
| - } |
| - if sealedObj == nil || manifestObj == nil { |
| - return errors.New("the seal does not name the archive and its manifest") |
| - } |
| - vs, err := l.versioned(ctx) |
| - if err != nil { |
| - return err |
| - } |
| - raw, err := vs.GetVersion(ctx, cfg.OwnBucket, manifestObj.Key, manifestObj.VersionID) |
| - if err != nil { |
| - return fmt.Errorf("read the archive manifest: %w", err) |
| - } |
| - var m archive.Manifest |
| - if err := json.Unmarshal(raw, &m); err != nil { |
| - return fmt.Errorf("the archive manifest: %w", err) |
| - } |
| - if m.Op != l.g.ID || m.Incarnation != l.rec.Move.Incarnation || m.Plain != want.Plain || m.Sealed != want.Sealed { |
| - return fmt.Errorf("the archive manifest is for %s/%s (%s, %s), not this move's %s (%s, %s)", m.Op, m.Incarnation, m.Plain, m.Sealed, l.g.ID, want.Plain, want.Sealed) |
| - } |
| - if err := os.MkdirAll(l.journalDir(), 0o700); err != nil { |
| - return err |
| - } |
| - sealedPath := filepath.Join(l.journalDir(), "archive.sealed") |
| - if err := os.Remove(sealedPath); err != nil && !errors.Is(err, os.ErrNotExist) { |
| - return err |
| - } |
| - defer os.Remove(sealedPath) |
| - if err := downloadVersion(ctx, vs, cfg.OwnBucket, sealedObj.Key, sealedObj.VersionID, sealedPath); err != nil { |
| - return fmt.Errorf("read the archive: %w", err) |
| - } |
| - f, err := os.Open(sealedPath) |
| - if err != nil { |
| - return err |
| - } |
| - defer f.Close() |
| - bind := appconfig.Binding{Account: l.rec.Account, App: l.rec.App} |
| - if err := archive.Verify(f, m, cfg.SecretsPriv, bind); err != nil { |
| - return fmt.Errorf("verify the archive: %w", err) |
| - } |
| - return openAndExtract(f, m, cfg.SecretsPriv, bind, dir, envPath) |
| -} |
| - |
| -// openAndExtract pipes the opened archive into Extract, as carriedEnv does. |
| -func openAndExtract(f *os.File, m archive.Manifest, priv *ecdh.PrivateKey, bind appconfig.Binding, dir, envPath string) error { |
| - if _, err := f.Seek(0, io.SeekStart); err != nil { |
| - return err |
| - } |
| - pr, pw := io.Pipe() |
| - done := make(chan error, 1) |
| - go func() { |
| - err := archive.Open(f, m.SealedDataKey, priv, bind, pw) |
| - pw.CloseWithError(err) |
| - done <- err |
| - }() |
| - xerr := archive.Extract(pr, dir, envPath) |
| - pr.CloseWithError(errors.New("extract stopped reading")) |
| - if oerr := <-done; oerr != nil && xerr == nil { |
| - xerr = oerr |
| - } |
| - return xerr |
| -} |
| - |
| -// sealedTimers reads the sealed timers snapshot by exact version and checks |
| -// its digest against the record's; every row is this host's. |
| -func (l *landingRun) sealedTimers(ctx context.Context, seal moves.Seal, wantDigest string) ([]registry.Timer, error) { |
| - cfg := l.cfg() |
| - if len(seal.Timers) != 1 { |
| - return nil, fmt.Errorf("the seal names %d timers objects, want 1", len(seal.Timers)) |
| - } |
| - vs, err := l.versioned(ctx) |
| - if err != nil { |
| - return nil, err |
| - } |
| - raw, err := vs.GetVersion(ctx, cfg.OwnBucket, seal.Timers[0].Key, seal.Timers[0].VersionID) |
| - if err != nil { |
| - return nil, fmt.Errorf("read the timers snapshot: %w", err) |
| - } |
| - snap, err := moves.DecodeTimers(raw) |
| - if err != nil { |
| - return nil, err |
| - } |
| - if snap.Host != l.rec.Host { |
| - return nil, fmt.Errorf("the timers snapshot is %s's, not %s's", snap.Host, l.rec.Host) |
| - } |
| - if got := moves.TimersDigest(snap.Rows); got != wantDigest { |
| - return nil, fmt.Errorf("the timers snapshot digests to %s, the record says %s", got, wantDigest) |
| - } |
| - for _, r := range snap.Rows { |
| - if r.Host != l.rec.Host { |
| - return nil, fmt.Errorf("timer %q is %s's, not %s's", r.Name, r.Host, l.rec.Host) |
| - } |
| - } |
| - return snap.Rows, nil |
| -} |
| - |
| // configured (§7.6, §9.1, §9.2): the held row rendered (RenderHeld, the |
| // move-bound ack), its bundle and overlay etags required to be the ones |
| // pinned at readiness (else the rollback marker, "configuration changed |
| @@ -1265,180 +991,3 @@ func (l *landingRun) configured(ctx context.Context) (bool, error) { |
| m.Landing.InstalledDBSHA256 = sha |
| }) |
| } |
| - |
| -// sameBundle compares two bundle etag maps. |
| -func sameBundle(a, b map[string]string) bool { |
| - if len(a) != len(b) { |
| - return false |
| - } |
| - for k, v := range a { |
| - if bv, ok := b[k]; !ok || bv != v { |
| - return false |
| - } |
| - } |
| - return true |
| -} |
| - |
| -// restore is the install of the built database (§7.6 restored, R6-3): |
| -// its sha256 must be the manifest's and CheckDB must pass, else the |
| -// rollback marker; then the rename into DBPath and the generation marker. |
| -// installed is false when the phase ended with the marker written. The |
| -// returned sha carries the record's "sha256:" prefix, as Source.DBSHA256 |
| -// does. |
| -func (l *landingRun) restore(ctx context.Context, obs hostclaim.Observed) (sha string, installed bool, err error) { |
| - cfg := l.cfg() |
| - host, id := l.rec.Host, l.g.ID |
| - m, err := l.installManifest(ctx, obs) |
| - if err != nil { |
| - return "", false, err |
| - } |
| - want := "sha256:" + m.DBSHA256 |
| - dbPath := l.dbPath() |
| - present, err := regularFile(dbPath) |
| - if err != nil { |
| - return "", false, err |
| - } |
| - if present { |
| - got, err := fileSHA256(dbPath) |
| - if err != nil { |
| - return "", false, err |
| - } |
| - if got != want { |
| - return "", false, fmt.Errorf("%s holds a database that is not this landing's build (%s, want %s)", dbPath, got, want) |
| - } |
| - } else { |
| - built := filepath.Join(l.buildDir(), host+".db") |
| - if ok, err := regularFile(built); err != nil { |
| - return "", false, err |
| - } else if !ok { |
| - // The scratch was lost (a rebuilt data directory): build again |
| - // from the pinned set, which reproduces the same bytes. |
| - if _, err := l.rebuild(ctx, obs); err != nil { |
| - return "", false, err |
| - } |
| - } |
| - got, err := fileSHA256(built) |
| - if err != nil { |
| - return "", false, err |
| - } |
| - if got != want { |
| - log.Printf("moves: %s: the built database is %s, the manifest says %s", host, got, want) |
| - ok, err := l.markRollback(ctx, provision.RollbackInstallMismatch) |
| - return "", ok, err |
| - } |
| - if err := lease.CheckDB(built); err != nil { |
| - log.Printf("moves: %s: the built database does not pass its check: %v", host, err) |
| - ok, err := l.markRollback(ctx, provision.RollbackInstallMismatch) |
| - return "", ok, err |
| - } |
| - for _, side := range []string{"-wal", "-shm"} { |
| - os.Remove(built + side) |
| - } |
| - if err := os.Rename(built, dbPath); err != nil { |
| - return "", false, fmt.Errorf("install the database: %w", err) |
| - } |
| - if err := syncPath(filepath.Dir(dbPath)); err != nil { |
| - return "", false, err |
| - } |
| - } |
| - if err := l.fault("after-db-rename"); err != nil { |
| - return "", false, err |
| - } |
| - if err := cfg.WriteGen(dbPath+".gen", id); err != nil { |
| - return "", false, fmt.Errorf("write the generation marker: %w", err) |
| - } |
| - if err := syncPath(filepath.Dir(dbPath)); err != nil { |
| - return "", false, err |
| - } |
| - log.Printf("moves: %s: database installed at %s (%s), generation %s", host, dbPath, want, id) |
| - return want, true, nil |
| -} |
| - |
| -// installManifest is the journal's, or the build's again when the journal |
| -// lost it. |
| -func (l *landingRun) installManifest(ctx context.Context, obs hostclaim.Observed) (moves.InstallManifest, error) { |
| - j, err := l.journal() |
| - if err != nil { |
| - return moves.InstallManifest{}, err |
| - } |
| - var m moves.InstallManifest |
| - if raw, ok := j.Steps[landStepInstall]; ok && json.Unmarshal([]byte(raw), &m) == nil && m.Op == l.g.ID && m.Host == l.rec.Host && m.DBSHA256 != "" { |
| - return m, nil |
| - } |
| - return l.rebuild(ctx, obs) |
| -} |
| - |
| -// rebuild runs BuildGeneration again over the seal: a replay that reuses |
| -// every object it finds and restores the same bytes into the scratch. |
| -func (l *landingRun) rebuild(ctx context.Context, obs hostclaim.Observed) (moves.InstallManifest, error) { |
| - cfg := l.cfg() |
| - seal, err := journalledSeal(obs.Claim) |
| - if err != nil { |
| - return moves.InstallManifest{}, err |
| - } |
| - vs, err := l.versioned(ctx) |
| - if err != nil { |
| - return moves.InstallManifest{}, err |
| - } |
| - holder := *obs.Claim.Holder |
| - m, err := moves.BuildGeneration(ctx, moves.BuildInputs{Store: vs, Bucket: cfg.OwnBucket, Source: seal.Replica, N: seal.N, |
| - GenerationPrefix: moves.GenerationPrefix(holder.Location, l.g.ID), Op: l.g.ID, Host: l.rec.Host, WorkDir: l.buildDir(), Now: cfg.Now}) |
| - if err != nil { |
| - return moves.InstallManifest{}, fmt.Errorf("rebuild the generation: %w", err) |
| - } |
| - j, err := l.journal() |
| - if err != nil { |
| - return m, err |
| - } |
| - body, err := json.Marshal(m) |
| - if err != nil { |
| - return m, err |
| - } |
| - j.Mark(landStepInstall, string(body)) |
| - return m, j.Write(cfg.DataDir) |
| -} |
| - |
| -// writeFileAtomic writes body to path through a temporary file in the same |
| -// directory, synced and renamed, then syncs the directory. |
| -func writeFileAtomic(path string, body []byte, mode os.FileMode) error { |
| - dir := filepath.Dir(path) |
| - f, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*") |
| - if err != nil { |
| - return err |
| - } |
| - tmp := f.Name() |
| - if _, err := f.Write(body); err != nil { |
| - f.Close() |
| - os.Remove(tmp) |
| - return err |
| - } |
| - if err := f.Chmod(mode); err != nil { |
| - f.Close() |
| - os.Remove(tmp) |
| - return err |
| - } |
| - if err := f.Sync(); err != nil { |
| - f.Close() |
| - os.Remove(tmp) |
| - return err |
| - } |
| - if err := f.Close(); err != nil { |
| - os.Remove(tmp) |
| - return err |
| - } |
| - if err := os.Rename(tmp, path); err != nil { |
| - os.Remove(tmp) |
| - return err |
| - } |
| - return syncPath(dir) |
| -} |
| - |
| -// syncPath fsyncs a file or directory. |
| -func syncPath(path string) error { |
| - f, err := os.Open(path) |
| - if err != nil { |
| - return err |
| - } |
| - defer f.Close() |
| - return f.Sync() |
| -} |
diff --git a/internal/host/moveslandinstall.go b/internal/host/moveslandinstall.go| new file mode 100644 |
| index 000000000..509343b9c |
| --- /dev/null |
| +++ b/internal/host/moveslandinstall.go |
| @@ -0,0 +1,321 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/moves/archive" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// The landing's install helpers (moves spec §7.6 local, restored): the |
| +// sealed archive read by exact version, verified and extracted; the sealed |
| +// timers; the database install from the build scratch with its marker; the |
| +// build again when the journal lost it; and the fsync and atomic-write |
| +// helpers. landingPass and the phases are in movesland.go. |
| + |
| +// filesGenMarker is the .files-gen body (§5.1): the op and the incarnation. |
| +type filesGenMarker struct { |
| + Op string `json:"op"` |
| + Incarnation string `json:"incarnation"` |
| +} |
| + |
| +// extractArchive reads the seal's archive and manifest by exact version from |
| +// this box's own bucket, verifies the archive against the manifest and the |
| +// record's digests (archive.Verify: the sealed digest, every entry), and |
| +// extracts it into dir and envPath (archive.Open under the box's secrets |
| +// key, then archive.Extract, which admits nothing it did not read whole). |
| +func (l *landingRun) extractArchive(ctx context.Context, seal moves.Seal, want provision.MoveFilesDigest, dir, envPath string) error { |
| + cfg := l.cfg() |
| + var sealedObj, manifestObj *moves.SealedObject |
| + for i := range seal.Files { |
| + switch o := &seal.Files[i]; { |
| + case strings.HasSuffix(o.Key, ".sealed"): |
| + sealedObj = o |
| + case strings.HasSuffix(o.Key, ".json"): |
| + manifestObj = o |
| + } |
| + } |
| + if sealedObj == nil || manifestObj == nil { |
| + return errors.New("the seal does not name the archive and its manifest") |
| + } |
| + vs, err := l.versioned(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + raw, err := vs.GetVersion(ctx, cfg.OwnBucket, manifestObj.Key, manifestObj.VersionID) |
| + if err != nil { |
| + return fmt.Errorf("read the archive manifest: %w", err) |
| + } |
| + var m archive.Manifest |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + return fmt.Errorf("the archive manifest: %w", err) |
| + } |
| + if m.Op != l.g.ID || m.Incarnation != l.rec.Move.Incarnation || m.Plain != want.Plain || m.Sealed != want.Sealed { |
| + return fmt.Errorf("the archive manifest is for %s/%s (%s, %s), not this move's %s (%s, %s)", m.Op, m.Incarnation, m.Plain, m.Sealed, l.g.ID, want.Plain, want.Sealed) |
| + } |
| + if err := os.MkdirAll(l.journalDir(), 0o700); err != nil { |
| + return err |
| + } |
| + sealedPath := filepath.Join(l.journalDir(), "archive.sealed") |
| + if err := os.Remove(sealedPath); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + defer os.Remove(sealedPath) |
| + if err := downloadVersion(ctx, vs, cfg.OwnBucket, sealedObj.Key, sealedObj.VersionID, sealedPath); err != nil { |
| + return fmt.Errorf("read the archive: %w", err) |
| + } |
| + f, err := os.Open(sealedPath) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.Close() |
| + bind := appconfig.Binding{Account: l.rec.Account, App: l.rec.App} |
| + if err := archive.Verify(f, m, cfg.SecretsPriv, bind); err != nil { |
| + return fmt.Errorf("verify the archive: %w", err) |
| + } |
| + return openAndExtract(f, m, cfg.SecretsPriv, bind, dir, envPath) |
| +} |
| + |
| +// openAndExtract pipes the opened archive into Extract, as carriedEnv does. |
| +func openAndExtract(f *os.File, m archive.Manifest, priv *ecdh.PrivateKey, bind appconfig.Binding, dir, envPath string) error { |
| + if _, err := f.Seek(0, io.SeekStart); err != nil { |
| + return err |
| + } |
| + pr, pw := io.Pipe() |
| + done := make(chan error, 1) |
| + go func() { |
| + err := archive.Open(f, m.SealedDataKey, priv, bind, pw) |
| + pw.CloseWithError(err) |
| + done <- err |
| + }() |
| + xerr := archive.Extract(pr, dir, envPath) |
| + pr.CloseWithError(errors.New("extract stopped reading")) |
| + if oerr := <-done; oerr != nil && xerr == nil { |
| + xerr = oerr |
| + } |
| + return xerr |
| +} |
| + |
| +// sealedTimers reads the sealed timers snapshot by exact version and checks |
| +// its digest against the record's; every row is this host's. |
| +func (l *landingRun) sealedTimers(ctx context.Context, seal moves.Seal, wantDigest string) ([]registry.Timer, error) { |
| + cfg := l.cfg() |
| + if len(seal.Timers) != 1 { |
| + return nil, fmt.Errorf("the seal names %d timers objects, want 1", len(seal.Timers)) |
| + } |
| + vs, err := l.versioned(ctx) |
| + if err != nil { |
| + return nil, err |
| + } |
| + raw, err := vs.GetVersion(ctx, cfg.OwnBucket, seal.Timers[0].Key, seal.Timers[0].VersionID) |
| + if err != nil { |
| + return nil, fmt.Errorf("read the timers snapshot: %w", err) |
| + } |
| + snap, err := moves.DecodeTimers(raw) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if snap.Host != l.rec.Host { |
| + return nil, fmt.Errorf("the timers snapshot is %s's, not %s's", snap.Host, l.rec.Host) |
| + } |
| + if got := moves.TimersDigest(snap.Rows); got != wantDigest { |
| + return nil, fmt.Errorf("the timers snapshot digests to %s, the record says %s", got, wantDigest) |
| + } |
| + for _, r := range snap.Rows { |
| + if r.Host != l.rec.Host { |
| + return nil, fmt.Errorf("timer %q is %s's, not %s's", r.Name, r.Host, l.rec.Host) |
| + } |
| + } |
| + return snap.Rows, nil |
| +} |
| + |
| +// sameBundle compares two bundle etag maps. |
| +func sameBundle(a, b map[string]string) bool { |
| + if len(a) != len(b) { |
| + return false |
| + } |
| + for k, v := range a { |
| + if bv, ok := b[k]; !ok || bv != v { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// restore is the install of the built database (§7.6 restored, R6-3): |
| +// its sha256 must be the manifest's and CheckDB must pass, else the |
| +// rollback marker; then the rename into DBPath and the generation marker. |
| +// installed is false when the phase ended with the marker written. The |
| +// returned sha carries the record's "sha256:" prefix, as Source.DBSHA256 |
| +// does. |
| +func (l *landingRun) restore(ctx context.Context, obs hostclaim.Observed) (sha string, installed bool, err error) { |
| + cfg := l.cfg() |
| + host, id := l.rec.Host, l.g.ID |
| + m, err := l.installManifest(ctx, obs) |
| + if err != nil { |
| + return "", false, err |
| + } |
| + want := "sha256:" + m.DBSHA256 |
| + dbPath := l.dbPath() |
| + present, err := regularFile(dbPath) |
| + if err != nil { |
| + return "", false, err |
| + } |
| + if present { |
| + got, err := fileSHA256(dbPath) |
| + if err != nil { |
| + return "", false, err |
| + } |
| + if got != want { |
| + return "", false, fmt.Errorf("%s holds a database that is not this landing's build (%s, want %s)", dbPath, got, want) |
| + } |
| + } else { |
| + built := filepath.Join(l.buildDir(), host+".db") |
| + if ok, err := regularFile(built); err != nil { |
| + return "", false, err |
| + } else if !ok { |
| + // The scratch was lost (a rebuilt data directory): build again |
| + // from the pinned set, which reproduces the same bytes. |
| + if _, err := l.rebuild(ctx, obs); err != nil { |
| + return "", false, err |
| + } |
| + } |
| + got, err := fileSHA256(built) |
| + if err != nil { |
| + return "", false, err |
| + } |
| + if got != want { |
| + log.Printf("moves: %s: the built database is %s, the manifest says %s", host, got, want) |
| + ok, err := l.markRollback(ctx, provision.RollbackInstallMismatch) |
| + return "", ok, err |
| + } |
| + if err := lease.CheckDB(built); err != nil { |
| + log.Printf("moves: %s: the built database does not pass its check: %v", host, err) |
| + ok, err := l.markRollback(ctx, provision.RollbackInstallMismatch) |
| + return "", ok, err |
| + } |
| + for _, side := range []string{"-wal", "-shm"} { |
| + os.Remove(built + side) |
| + } |
| + if err := os.Rename(built, dbPath); err != nil { |
| + return "", false, fmt.Errorf("install the database: %w", err) |
| + } |
| + if err := syncPath(filepath.Dir(dbPath)); err != nil { |
| + return "", false, err |
| + } |
| + } |
| + if err := l.fault("after-db-rename"); err != nil { |
| + return "", false, err |
| + } |
| + if err := cfg.WriteGen(dbPath+".gen", id); err != nil { |
| + return "", false, fmt.Errorf("write the generation marker: %w", err) |
| + } |
| + if err := syncPath(filepath.Dir(dbPath)); err != nil { |
| + return "", false, err |
| + } |
| + log.Printf("moves: %s: database installed at %s (%s), generation %s", host, dbPath, want, id) |
| + return want, true, nil |
| +} |
| + |
| +// installManifest is the journal's, or the build's again when the journal |
| +// lost it. |
| +func (l *landingRun) installManifest(ctx context.Context, obs hostclaim.Observed) (moves.InstallManifest, error) { |
| + j, err := l.journal() |
| + if err != nil { |
| + return moves.InstallManifest{}, err |
| + } |
| + var m moves.InstallManifest |
| + if raw, ok := j.Steps[landStepInstall]; ok && json.Unmarshal([]byte(raw), &m) == nil && m.Op == l.g.ID && m.Host == l.rec.Host && m.DBSHA256 != "" { |
| + return m, nil |
| + } |
| + return l.rebuild(ctx, obs) |
| +} |
| + |
| +// rebuild runs BuildGeneration again over the seal: a replay that reuses |
| +// every object it finds and restores the same bytes into the scratch. |
| +func (l *landingRun) rebuild(ctx context.Context, obs hostclaim.Observed) (moves.InstallManifest, error) { |
| + cfg := l.cfg() |
| + seal, err := journalledSeal(obs.Claim) |
| + if err != nil { |
| + return moves.InstallManifest{}, err |
| + } |
| + vs, err := l.versioned(ctx) |
| + if err != nil { |
| + return moves.InstallManifest{}, err |
| + } |
| + holder := *obs.Claim.Holder |
| + m, err := moves.BuildGeneration(ctx, moves.BuildInputs{Store: vs, Bucket: cfg.OwnBucket, Source: seal.Replica, N: seal.N, |
| + GenerationPrefix: moves.GenerationPrefix(holder.Location, l.g.ID), Op: l.g.ID, Host: l.rec.Host, WorkDir: l.buildDir(), Now: cfg.Now}) |
| + if err != nil { |
| + return moves.InstallManifest{}, fmt.Errorf("rebuild the generation: %w", err) |
| + } |
| + j, err := l.journal() |
| + if err != nil { |
| + return m, err |
| + } |
| + body, err := json.Marshal(m) |
| + if err != nil { |
| + return m, err |
| + } |
| + j.Mark(landStepInstall, string(body)) |
| + return m, j.Write(cfg.DataDir) |
| +} |
| + |
| +// writeFileAtomic writes body to path through a temporary file in the same |
| +// directory, synced and renamed, then syncs the directory. |
| +func writeFileAtomic(path string, body []byte, mode os.FileMode) error { |
| + dir := filepath.Dir(path) |
| + f, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*") |
| + if err != nil { |
| + return err |
| + } |
| + tmp := f.Name() |
| + if _, err := f.Write(body); err != nil { |
| + f.Close() |
| + os.Remove(tmp) |
| + return err |
| + } |
| + if err := f.Chmod(mode); err != nil { |
| + f.Close() |
| + os.Remove(tmp) |
| + return err |
| + } |
| + if err := f.Sync(); err != nil { |
| + f.Close() |
| + os.Remove(tmp) |
| + return err |
| + } |
| + if err := f.Close(); err != nil { |
| + os.Remove(tmp) |
| + return err |
| + } |
| + if err := os.Rename(tmp, path); err != nil { |
| + os.Remove(tmp) |
| + return err |
| + } |
| + return syncPath(dir) |
| +} |
| + |
| +// syncPath fsyncs a file or directory. |
| +func syncPath(path string) error { |
| + f, err := os.Open(path) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.Close() |
| + return f.Sync() |
| +} |
diff --git a/internal/host/moveslandpark.go b/internal/host/moveslandpark.go| new file mode 100644 |
| index 000000000..eeb262e27 |
| --- /dev/null |
| +++ b/internal/host/moveslandpark.go |
| @@ -0,0 +1,176 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// The landing's park and lease adapters (moves spec §6.4, §7.6; ruling |
| +// G17): the park engine over this box's own bucket for the residue parks, |
| +// the stand-in view and authorizer the tests use until Plan 5's providers, |
| +// the generation classifier they share, and the own-location lease the |
| +// build holds. landingPass and the phases are in movesland.go. |
| + |
| +// authorizeChore is park.Local.Authorize's stand-in until Plan 5: a chore is |
| +// run only when it is this landing's own and the claim still names this |
| +// landing with this box executing. |
| +func (l *landingRun) authorizeChore(_ context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error { |
| + if c == nil || o.Op != l.g.ID || c.Op.ID != l.g.ID || c.Op.Kind != hostclaim.KindLanding || c.Op.Exec != l.cfg().Box { |
| + return fmt.Errorf("%w: not this landing's chore", park.ErrUnsafe) |
| + } |
| + return nil |
| +} |
| + |
| +// authorizeObligation is park.Engine.Authorize's stand-in (ruling G17): the |
| +// same rule over an observation. |
| +func (l *landingRun) authorizeObligation(ctx context.Context, obs hostclaim.Observed, o hostclaim.Obligation) error { |
| + if obs.Absent { |
| + return park.ErrUnsafe |
| + } |
| + return l.authorizeChore(ctx, obs.Claim, o) |
| +} |
| + |
| +// landingView is park.Engine.View's stand-in (ruling G17): the location's |
| +// objects classified by key, and the source's region authoritative, which |
| +// §5.1's table says it is for a landing before the target's probe is |
| +// admitted (every phase here). Plan 5's provider replaces it; a test may set |
| +// cfg.ParkView to the view a later state has. |
| +func (l *landingRun) landingView(_ context.Context, obs hostclaim.Observed) (park.View, error) { |
| + if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil { |
| + return park.View{}, park.ErrUnsafe |
| + } |
| + location := strings.TrimSuffix(obs.Claim.Holder.Location, "/") |
| + return park.View{ClaimETag: obs.ETag, Region: l.cfg().Region, AuthoritativeRegion: l.rec.Move.From, |
| + GenerationOf: func(r park.Ref) (string, bool) { return landingGenerationOf(location, r.Key) }}, nil |
| +} |
| + |
| +// landingMarkerGeneration is what landingGenerationOf answers for the |
| +// location's generation.json. The marker is the one key every generation |
| +// reuses (§5.1): the probe's admission supersedes an earlier stay's marker |
| +// with If-Match rather than deleting it, and a park-generation chore whose |
| +// deletion set named the marker key could never be acknowledged once a new |
| +// marker stands there (the engine skips the marker key and then refuses a |
| +// recorded delete at it). So the marker belongs to no generation's chore: |
| +// parking never queues one for it and the engine passes it over. "@" keeps |
| +// it apart from any generation name, as hostclaim.RootGeneration does. |
| +const landingMarkerGeneration = "@marker" |
| + |
| +// landingGenerationOf classifies an object under a holder location by |
| +// generation, as the view does: `g-<gen>/...` and `files/g-<gen>/...` are |
| +// gen's; a level directory (four hex digits) at the location is the |
| +// origin, ""; generation.json is landingMarkerGeneration; anything else is |
| +// unknown, and a caller fails closed on it. The naming rule is |
| +// moves.GenerationPrefix's twin. |
| +func landingGenerationOf(location, key string) (string, bool) { |
| + rel, ok := strings.CutPrefix(key, location+"/") |
| + if !ok || rel == "" { |
| + return "", false |
| + } |
| + for _, p := range []string{"g-", "files/g-"} { |
| + if rest, ok := strings.CutPrefix(rel, p); ok { |
| + if gen, _, ok := strings.Cut(rest, "/"); ok && gen != "" { |
| + return gen, true |
| + } |
| + return "", false |
| + } |
| + } |
| + if rel == "generation.json" { |
| + return landingMarkerGeneration, true |
| + } |
| + if first, _, ok := strings.Cut(rel, "/"); ok && len(first) == 4 && isHexString(first) { |
| + return "", true |
| + } |
| + return "", false |
| +} |
| + |
| +func isHexString(s string) bool { |
| + for _, c := range s { |
| + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { |
| + return false |
| + } |
| + } |
| + return s != "" |
| +} |
| + |
| +// engine is the park engine over this box's own bucket: the wired leases, |
| +// and the view and authorizer Plan 5 supplies, or the stand-ins. |
| +func (l *landingRun) engine(vs lease.VersionedStore) park.Engine { |
| + cfg := l.cfg() |
| + view, auth := cfg.ParkView, cfg.ParkAuthorize |
| + if view == nil { |
| + view = l.landingView |
| + } |
| + if auth == nil { |
| + auth = l.authorizeObligation |
| + } |
| + return park.Engine{Store: vs, Claims: l.s, Bucket: cfg.OwnBucket, Region: cfg.Region, Leases: cfg.ParkLeases, |
| + Now: cfg.Now, NewID: obligationID, View: view, Authorize: auth, After: cfg.parkFault} |
| +} |
| + |
| +// lease is the own-location lease the build holds (§7.6 checking: "hold it |
| +// through published"), kept in the worker's per-host map across passes and |
| +// re-acquired after a restart. waiting is true when another holder has it. |
| +func (l *landingRun) lease(ctx context.Context, holder hostclaim.Holder) (held park.HeldLease, waiting bool, err error) { |
| + cfg := l.cfg() |
| + host := l.rec.Host |
| + l.w.mu.Lock() |
| + held = l.w.landLeases[host] |
| + l.w.mu.Unlock() |
| + if held != nil { |
| + if err := held.Check(ctx); err == nil { |
| + return held, false, nil |
| + } |
| + l.releaseLease() |
| + } |
| + held, err = cfg.OwnLease(ctx, cfg.OwnBucket, strings.TrimSuffix(holder.Location, "/")) |
| + if err != nil { |
| + var he *lease.HeldError |
| + if errors.As(err, &he) { |
| + log.Printf("moves: %s: another holder has the location's lease; the build waits", host) |
| + return nil, true, nil |
| + } |
| + return nil, false, fmt.Errorf("the location's lease: %w", err) |
| + } |
| + l.w.mu.Lock() |
| + if l.w.landLeases == nil { |
| + l.w.landLeases = map[string]park.HeldLease{} |
| + } |
| + l.w.landLeases[host] = held |
| + l.w.mu.Unlock() |
| + return held, false, nil |
| +} |
| + |
| +// releaseLease releases the host's own-location lease, if held. |
| +func (l *landingRun) releaseLease() { |
| + host := l.rec.Host |
| + l.w.mu.Lock() |
| + held := l.w.landLeases[host] |
| + delete(l.w.landLeases, host) |
| + l.w.mu.Unlock() |
| + if held == nil { |
| + return |
| + } |
| + rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + if err := held.Release(rctx); err != nil { |
| + log.Printf("moves: %s: release the location's lease: %v", host, err) |
| + } |
| +} |
| + |
| +// settleLease releases the lease once the record is past published, marked |
| +// for a rollback, or no longer landing: the lease is the build's alone. |
| +func (l *landingRun) settleLease() { |
| + m := l.rec.Move |
| + if m == nil || m.State != provision.MoveLanding || m.Reason != "" || !landingBefore(m.LandingPhase, provision.LandLocal) { |
| + l.releaseLease() |
| + } |
| +} |