carlos / platform Public

host: the landing installs only inside the host guard and never deletes at the live paths (final-review fixes 1 and 2)

The archive is extracted into the op's own staging (<JournalDir>/extract/),
journalled by identity, and moved to the files directory and <host>.env
inside the host guard only when nothing the landing did not write is
there; anything else marks the rollback (MV-N180) and is left alone. A
retry clears only the staging. The install receipt (files_placed) is
journalled before extracted, and .files-gen is written inside the guard
into the directory the receipt names.

The database install (the destination check, the rename, <dbPath>.gen)
runs inside Guard.With at the captured installation stamp, with the claim
and the record read fresh there, mirroring hostbox ColdRestore.Install; a
database, marker or WAL that is not this landing's is never replaced.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 88abf7dd433f7713a12dda2ea3d30686120a0fb4 parent d86852f
4 files changed, +732 −65
  • internal/host/movesland.go +43 −31
  • internal/host/movesland_finalfix_test.go +308 −0
  • internal/host/movesland_test.go +1 −1
  • internal/host/moveslandinstall.go +380 −33
diff --git a/internal/host/movesland.go b/internal/host/movesland.go
index 62ef09889..7d5152fea 100644
--- a/internal/host/movesland.go
+++ b/internal/host/movesland.go
@@ -60,6 +60,17 @@ import (
// marked record alone. landing_deadline passing before `restored` marks it
// too.
//
+// Local installation (final review, findings 1 and 2). Every destructive
+// local mutation (the files and env install, .files-gen, the database
+// rename and <dbPath>.gen) runs inside the host guard at the installation
+// stamp captured from the record (landingRun.guarded, the pattern of
+// hostbox ColdRestore.Install): the claim and the record are read fresh
+// there and must still be this landing at this phase, and the destination
+// is checked again there. Retirement (park.Local) takes the guard itself
+// and runs outside those blocks. The landing never deletes at the live
+// paths: the archive is extracted into the op's own staging and moved into
+// place only when nothing it did not write is there.
+//
// Arming (ruling G17). armPhase1 wires the hooks that exist on main (the
// guard, park.Local, the own-location lease, RenderHeld, the registry
// setters, the restore-verification record, the marker writer), and leaves
@@ -93,7 +104,8 @@ const (
landStepInstall = "install" // the completed install manifest (JSON)
landStepPublished = "published" // the build is complete and recorded (R10-3: no generation.json)
landStepRetired = "retired" // the earlier stay's artifacts are parked, or there were none
- landStepExtracting = "extracting" // this landing's own extraction began (G15: a partial is ours)
+ landStepStaged = "staged" // the archive is extracted into <JournalDir>/extract/ (JSON stagedFiles)
+ landStepFilesPlaced = "files_placed" // the install receipt: the staged tree is at the live paths (JSON stagedFiles)
landStepExtracted = "extracted" // the archive is in the files directory and <host>.env
landStepInventory = "inventory" // the new paths are registered under F
landStepTimers = "timers" // the timers table is the sealed snapshot
@@ -790,10 +802,12 @@ func (l *landingRun) published(ctx context.Context) (bool, error) {
// F; (2) an earlier stay's artifacts tagged S' are parked into .parked/
// through park.Local (which refuses a path tagged with another stamp), or a
// park-local left with nothing to retire is completed; (3) the sealed
-// archive, read by exact version and verified, is extracted into the files
-// directory and <host>.env (a partial of this landing's own is removed
-// first, G15: the journal says extraction began and the paths are not yet
-// registered); (4) the new paths are registered under F; (5) the timers
+// archive, read by exact version and verified, is extracted into this op's
+// own staging, <JournalDir>/extract/, and then, inside the host guard, moved
+// to the files directory and <host>.env when nothing this landing did not
+// write is there (installFiles: the landing never deletes at the live
+// paths, final review finding 1; a retry clears only its staging); (4) the
+// new paths are registered under F; (5) the timers
// table is the sealed snapshot with its registrations, the carried columns
// are set and the restore-verification entry removed; (6) the prepared
// files manifest, skipped with a log line until C8 exists; (7) the files
@@ -833,12 +847,9 @@ func (l *landingRun) local_(ctx context.Context) (bool, error) {
}
return l.fault("after-" + step)
}
- captured, err := hostguard.Capture(cfg.DataDir, host)
+ captured, err := l.capture(holder)
if err != nil {
- return false, fmt.Errorf("read the installation record: %w", err)
- }
- if captured.Incarnation != holder.Incarnation || captured.Stamp != holder.Stamp {
- return false, fmt.Errorf("move %s: the installation record is at stamp %d of %s, not this landing's %d", id, captured.Stamp, captured.Incarnation, holder.Stamp)
+ return false, err
}
if !j.Done(landStepRetired) {
if raw, ok := j.Steps[landStepPrevious]; ok {
@@ -870,25 +881,14 @@ func (l *landingRun) local_(ctx context.Context) (bool, error) {
}
dir, envPath := cfg.FilesDir(host)
if !j.Done(landStepExtracted) {
- if j.Done(landStepExtracting) {
- // G15: a partial extraction of this landing's own, not yet
- // registered. Nothing else can be at these paths: the earlier
- // stay's were parked above.
- if err := os.RemoveAll(dir); err != nil {
- return false, err
- }
- if err := os.Remove(envPath); err != nil && !errors.Is(err, os.ErrNotExist) {
- return false, err
- }
- }
- if err := mark(landStepExtracting, ""); err != nil {
- return false, err
- }
- if err := l.extractArchive(ctx, seal, *src.Files, dir, envPath); err != nil {
- return false, fmt.Errorf("extract the archive: %w", err)
- }
- if err := mark(landStepExtracted, ""); err != nil {
+ outcome, err := l.installFiles(ctx, j, mark, captured, seal, *src.Files, dir, envPath)
+ switch {
+ case err != nil:
return false, err
+ case outcome == restoreMarked:
+ return true, nil // the record changed: the rollback marker
+ case outcome == restoreWaiting:
+ return false, nil
}
}
if !j.Done(landStepInventory) {
@@ -945,14 +945,26 @@ func (l *landingRun) local_(ctx context.Context) (bool, error) {
if err := l.fault("before-files-gen"); err != nil {
return false, err
}
- if err := syncPath(dir); err != nil {
+ marker, err := json.Marshal(filesGenMarker{Op: id, Incarnation: holder.Incarnation})
+ if err != nil {
return false, err
}
- marker, err := json.Marshal(filesGenMarker{Op: id, Incarnation: holder.Incarnation})
+ placed, err := journalledStaged(j, landStepFilesPlaced)
if err != nil {
return false, err
}
- if err := writeFileAtomic(filepath.Join(dir, archive.MarkerName), marker, 0o644); err != nil {
+ // The marker goes into the files directory this landing placed, and
+ // only while the installation is still this landing's (final review,
+ // finding 2): inside the guard, the directory is the receipt's.
+ if err := l.guarded(ctx, captured, provision.LandLocal, func() error {
+ if ok, err := sameIdentity(dir, placed.Files); err != nil || !ok {
+ return fmt.Errorf("%w: %s is not the files directory this landing placed (%v)", errLandingChanged, dir, err)
+ }
+ if err := syncPath(dir); err != nil {
+ return err
+ }
+ return writeFileAtomic(filepath.Join(dir, archive.MarkerName), marker, 0o644)
+ }); err != nil {
return false, fmt.Errorf("write %s: %w", archive.MarkerName, err)
}
if err := mark(landStepFilesGen, ""); err != nil {
diff --git a/internal/host/movesland_finalfix_test.go b/internal/host/movesland_finalfix_test.go
new file mode 100644
index 000000000..6de1a504d
--- /dev/null
+++ b/internal/host/movesland_finalfix_test.go
@@ -0,0 +1,308 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// The final whole-branch review's three blockers (final-review.md,
+// rulings in final-fix.md): the landing never deletes files it did not
+// create, every local installation runs inside the host guard with the
+// destination checked there, and the absolute cut deadline bounds the
+// landing as well as landing_deadline.
+
+// sentinels plants files this landing never wrote at the live files paths:
+// a file in the files directory and <host>.env. No installation record and
+// no inventory names them, so local retires nothing (ruling G5).
+func (h *landingHarness) sentinels(t *testing.T) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Join(h.tdir, p1Host), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for name, body := range map[string]string{filepath.Join(p1Host, "sentinel.txt"): "not the landing's", p1Host + ".env": "SENTINEL=1\n"} {
+ if err := os.WriteFile(filepath.Join(h.tdir, name), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+// sentinelsIntact fails unless both sentinels are exactly as planted.
+func (h *landingHarness) sentinelsIntact(t *testing.T, when string) {
+ t.Helper()
+ for name, want := range map[string]string{filepath.Join(p1Host, "sentinel.txt"): "not the landing's", p1Host + ".env": "SENTINEL=1\n"} {
+ body, err := os.ReadFile(filepath.Join(h.tdir, name))
+ if err != nil || string(body) != want {
+ t.Fatalf("%s: %s is %q (%v), want %q", when, name, body, err, want)
+ }
+ }
+ if _, err := os.Stat(filepath.Join(h.tdir, p1Host, "upload.txt")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("%s: the archive was installed over the sentinels: %v", when, err)
+ }
+}
+
+// retireInstallation retires the installation record's active stamp, as a
+// successor taking this box over would before installing its own.
+func (h *landingHarness) retireInstallation(t *testing.T) {
+ t.Helper()
+ g := hostguard.Guard{DataDir: h.tdir, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: h.s}}
+ if err := g.RetireInstallation(context.Background(), p1Host, func(*hostclaim.Claim) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Finding 1: files at the live paths that no earlier stay registered are
+// not the landing's. Across two passes (and a fresh worker between them,
+// so any attempt marker the first pass journalled is what the second
+// reads), they are preserved, and the landing records why it stops.
+func TestLandingNeverDeletesFilesItDidNotCreate(t *testing.T) {
+ t.Run("present before the landing", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ h.sentinels(t)
+ h.land(t)
+ h.sentinelsIntact(t, "after the first pass")
+ h.freshTarget()
+ h.land(t)
+ h.sentinelsIntact(t, "after the second pass")
+ m := h.read(t, p1Host).Move
+ if m.Reason != provision.RollbackFilesOccupied || m.LandingPhase != provision.LandLocal {
+ t.Fatalf("record: %+v", m)
+ }
+ if _, ok := h.filesGen(t); ok {
+ t.Fatal(".files-gen written over files the landing did not write")
+ }
+ })
+ t.Run("only an env file", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ if err := os.WriteFile(filepath.Join(h.tdir, p1Host+".env"), []byte("SENTINEL=1\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ h.land(t)
+ h.freshTarget()
+ h.land(t)
+ if body, err := os.ReadFile(filepath.Join(h.tdir, p1Host+".env")); err != nil || string(body) != "SENTINEL=1\n" {
+ t.Fatalf("the env file the landing did not write: %q %v", body, err)
+ }
+ if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackFilesOccupied {
+ t.Fatalf("record: %+v", m)
+ }
+ })
+ // The review's failure: the attempt is journalled, then the files
+ // appear (the landing's own crash in between), and the retry reads the
+ // journal. The point is the one after the journal step that begins the
+ // extraction, whatever it is called.
+ t.Run("appearing after the attempt began", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ planted := false
+ h.landFault = func(p string) error {
+ if !planted && (p == "after-extracting" || p == "after-staged") {
+ planted = true
+ h.sentinels(t)
+ return errCrash
+ }
+ return nil
+ }
+ h.land(t)
+ if !planted {
+ t.Fatalf("the extraction never began: %v", h.landHits)
+ }
+ h.sentinelsIntact(t, "after the crash")
+ h.freshTarget()
+ h.land(t)
+ h.sentinelsIntact(t, "after the retry")
+ h.freshTarget()
+ h.land(t)
+ h.sentinelsIntact(t, "after a third pass")
+ if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackFilesOccupied {
+ t.Fatalf("record: %+v", m)
+ }
+ })
+ // The marker vouches for the tree this landing placed: a files
+ // directory swapped after the placement is not written into.
+ t.Run("replaced after the placement", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ dir := filepath.Join(h.tdir, p1Host)
+ h.landFault = func(p string) error {
+ if p == "before-files-gen" {
+ if err := os.Rename(dir, dir+".moved"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "sentinel.txt"), []byte("not the landing's"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return nil
+ }
+ h.land(t)
+ if _, ok := h.filesGen(t); ok {
+ t.Fatal(".files-gen written into a files directory the landing did not place")
+ }
+ if body, err := os.ReadFile(filepath.Join(dir, "sentinel.txt")); err != nil || string(body) != "not the landing's" {
+ t.Fatalf("the sentinel: %q %v", body, err)
+ }
+ if m := h.read(t, p1Host).Move; m.LandingPhase != provision.LandLocal {
+ t.Fatalf("record: %+v", m)
+ }
+ })
+ // An empty files directory is the landing's to fill (as Extract has
+ // always allowed), and a retry after a crash inside the install, with
+ // the files moved and the env not, finishes from the staged tree.
+ t.Run("an empty directory and a crash mid-install", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ if err := os.MkdirAll(filepath.Join(h.tdir, p1Host), 0o750); err != nil {
+ t.Fatal(err)
+ }
+ h.landAt = "after-files-rename"
+ h.crashLand(t)
+ if _, err := os.Stat(filepath.Join(h.tdir, p1Host, "upload.txt")); err != nil {
+ t.Fatalf("the files were not moved before the crash: %v", err)
+ }
+ h.freshTarget()
+ h.land(t)
+ h.installed(t)
+ if fi, err := os.Stat(filepath.Join(h.tdir, p1Host)); err != nil || fi.Mode().Perm() != 0o750 {
+ t.Fatalf("the files directory's mode: %v %v", fi, err)
+ }
+ if _, err := os.Stat(filepath.Join(h.tdir, ".moves", p1Host, p1ID, "extract")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the staging was left behind: %v", err)
+ }
+ })
+}
+
+// Finding 2: the installation is checked and made inside the host guard.
+// A successor that appears between the landing's observation and its
+// install is never replaced.
+func TestLandingInstallRefusesASuccessor(t *testing.T) {
+ dbPath := func(h *landingHarness) string { return provision.DefaultDBPath(h.tdir, p1Host) }
+ notRestored := func(t *testing.T, h *landingHarness) {
+ t.Helper()
+ m := h.read(t, p1Host).Move
+ if m.LandingPhase == provision.LandRestored || (m.Landing != nil && m.Landing.InstalledDBSHA256 != "") {
+ t.Fatalf("the landing completed over a successor: %+v", m)
+ }
+ }
+ t.Run("a successor database", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ h.landFault = func(p string) error {
+ if p == "before-db-verify" {
+ if err := os.WriteFile(dbPath(h), []byte("successor database"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := hostbox.WriteGenerationMarker(dbPath(h)+".gen", "successor"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return nil
+ }
+ h.land(t)
+ if !containsString(h.landHits, "before-db-verify") {
+ t.Fatalf("the install never observed: %v", h.landHits)
+ }
+ if body, err := os.ReadFile(dbPath(h)); err != nil || string(body) != "successor database" {
+ t.Fatalf("the successor's database was replaced: %q %v", body, err)
+ }
+ if gen, err := hostbox.ReadGenerationMarker(dbPath(h) + ".gen"); err != nil || gen != "successor" {
+ t.Fatalf("the successor's marker: %q %v", gen, err)
+ }
+ notRestored(t, h)
+ })
+ t.Run("a successor database without a marker", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ h.landFault = func(p string) error {
+ if p == "before-db-verify" {
+ if err := os.WriteFile(dbPath(h), []byte("successor database"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return nil
+ }
+ h.land(t)
+ if body, err := os.ReadFile(dbPath(h)); err != nil || string(body) != "successor database" {
+ t.Fatalf("the successor's database was replaced: %q %v", body, err)
+ }
+ if _, err := os.Stat(dbPath(h) + ".gen"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the successor's database was labelled as this landing's: %v", err)
+ }
+ notRestored(t, h)
+ })
+ t.Run("a successor marker", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ h.landFault = func(p string) error {
+ if p == "before-db-verify" {
+ if err := hostbox.WriteGenerationMarker(dbPath(h)+".gen", "successor"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return nil
+ }
+ h.land(t)
+ if _, err := os.Stat(dbPath(h)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("installed beside a successor's marker: %v", err)
+ }
+ if gen, err := hostbox.ReadGenerationMarker(dbPath(h) + ".gen"); err != nil || gen != "successor" {
+ t.Fatalf("the successor's marker: %q %v", gen, err)
+ }
+ notRestored(t, h)
+ })
+ // The installation retired between the observation and the install:
+ // only the guard can see it, since nothing is at the destination yet.
+ t.Run("the installation retired before the database install", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ h.landFault = func(p string) error {
+ if p == "before-db-verify" {
+ h.retireInstallation(t)
+ }
+ return nil
+ }
+ h.land(t)
+ for _, p := range []string{dbPath(h), dbPath(h) + ".gen"} {
+ if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("%s installed under a retired installation: %v", p, err)
+ }
+ }
+ notRestored(t, h)
+ })
+ t.Run("the installation retired before the files install", func(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ retired := false
+ h.landFault = func(p string) error {
+ if !retired && (p == "after-extracting" || p == "after-staged") {
+ retired = true
+ h.retireInstallation(t)
+ }
+ return nil
+ }
+ h.land(t)
+ if !retired {
+ t.Fatalf("the extraction never began: %v", h.landHits)
+ }
+ for _, p := range []string{filepath.Join(h.tdir, p1Host), filepath.Join(h.tdir, p1Host+".env")} {
+ if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("%s installed under a retired installation: %v", p, err)
+ }
+ }
+ if m := h.read(t, p1Host).Move; m.LandingPhase != provision.LandLocal {
+ t.Fatalf("record: %+v", m)
+ }
+ })
+}
diff --git a/internal/host/movesland_test.go b/internal/host/movesland_test.go
index 726fda813..60373a7d3 100644
--- a/internal/host/movesland_test.go
+++ b/internal/host/movesland_test.go
@@ -1119,7 +1119,7 @@ func TestLandingDeadlineMarksRollback(t *testing.T) {
// timers replacement, one install manifest, and the same end state.
func TestLandingReplaysArePhaseIdempotent(t *testing.T) {
points := []string{"after-previous", "after-activate", "after-record-parking", "after-residue-queued", "after-record-building", "after-build",
- "after-record-published", "after-record-local", "after-retired", "after-extracting", "after-extracted", "after-inventory", "after-timers",
+ "after-record-published", "after-record-local", "after-retired", "after-staged", "after-files-rename", "after-files_placed", "after-extracted", "after-inventory", "after-timers",
"after-columns", "after-verify", "after-files_manifest", "before-files-gen", "after-files_gen", "after-record-configured", "after-release-ready", "after-db-rename"}
for _, point := range points {
t.Run(point, func(t *testing.T) {
diff --git a/internal/host/moveslandinstall.go b/internal/host/moveslandinstall.go
index a492fb29b..6d4a08df0 100644
--- a/internal/host/moveslandinstall.go
+++ b/internal/host/moveslandinstall.go
@@ -7,13 +7,16 @@ import (
"errors"
"fmt"
"io"
+ "io/fs"
"log"
"os"
"path/filepath"
"strings"
"github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/moves/archive"
@@ -22,10 +25,313 @@ import (
)
// 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.
+// sealed archive read by exact version, verified and extracted into the
+// op's staging, then placed at the live paths inside the host guard; the
+// sealed timers; the database install from the build scratch with its
+// marker, inside the guard; the build again when the journal lost it; and
+// the fsync and atomic-write helpers. landingPass and the phases are in
+// movesland.go.
+
+// errLandingChanged is a guarded install's refusal when the claim, the
+// record, the installation record or the destination is no longer this
+// landing's: nothing is written, and the pass ends with the error.
+var errLandingChanged = errors.New("the installation is no longer this landing's")
+
+// errFilesOccupied is the files install's refusal when the files directory
+// holds anything, or <host>.env exists, that this landing did not write:
+// it is not the landing's to clear, so the rollback is marked instead.
+var errFilesOccupied = errors.New("the files paths hold what this landing did not write")
+
+// capture reads the installation record and requires it at the holder's
+// incarnation and stamp: the identity every guarded install runs under.
+func (l *landingRun) capture(holder hostclaim.Holder) (hostguard.Captured, error) {
+ captured, err := hostguard.Capture(l.cfg().DataDir, l.rec.Host)
+ if err != nil {
+ return captured, fmt.Errorf("read the installation record: %w", err)
+ }
+ if captured.Incarnation != holder.Incarnation || captured.Stamp != holder.Stamp {
+ return captured, fmt.Errorf("move %s: the installation record is at stamp %d of %s, not this landing's %d", l.g.ID, captured.Stamp, captured.Incarnation, holder.Stamp)
+ }
+ return captured, nil
+}
+
+// guarded runs fn inside the host guard for this landing's installation
+// (final review, finding 2; hostbox ColdRestore.Install's pattern): under
+// the host's lock, the installation record still active at the captured
+// stamp, no fence over it, the claim read fresh and still this landing's op
+// at phase with this box executing and the holder at the captured stamp,
+// and the record read fresh and still this landing at phase with no
+// rollback reason. fn re-checks its destination and makes the install; it
+// must not call anything that takes the guard itself (park.Local's
+// retirement and inventory), nor wait on the network.
+func (l *landingRun) guarded(ctx context.Context, captured hostguard.Captured, phase string, fn func() error) error {
+ check := func(c *hostclaim.Claim) error {
+ if err := l.landingCheck(phase)(c); err != nil {
+ return err
+ }
+ if c.Holder == nil || c.Holder.Stamp != captured.Stamp {
+ return fmt.Errorf("%w: the claim's holder is not at the installation stamp %d", errLandingChanged, captured.Stamp)
+ }
+ rec, err := provision.GetRecord(ctx, l.s, l.rec.Account, l.rec.App, l.rec.Host)
+ if err != nil {
+ return fmt.Errorf("re-read the record: %w", err)
+ }
+ m := rec.Move
+ if m == nil || m.ID != l.g.ID || m.Incarnation != l.g.Incarnation || m.State != provision.MoveLanding || m.LandingPhase != phase || m.Reason != "" {
+ return fmt.Errorf("%w: the record is no longer landing %s at %s", errLandingChanged, l.g.ID, phase)
+ }
+ return nil
+ }
+ return l.guard.With(ctx, l.rec.Host, captured, check, fn)
+}
+
+// stagedFiles names the staged extraction by identity (device and inode):
+// the files directory's and the env file's, Env "" when the archive
+// carries none. A rename keeps an identity, so the same record, journalled
+// as the install receipt once the tree is placed, is what tells this
+// landing's own tree at the live paths from anything else there.
+type stagedFiles struct {
+ Files string `json:"files"`
+ Env string `json:"env,omitempty"`
+}
+
+// stageDir is the op's own extraction staging, <JournalDir>/extract/: the
+// one place a retry clears (final review, finding 1).
+func (l *landingRun) stageDir() string { return filepath.Join(l.journalDir(), "extract") }
+
+// journalledStaged reads a stagedFiles journal step.
+func journalledStaged(j *moves.Journal, step string) (stagedFiles, error) {
+ var st stagedFiles
+ raw, ok := j.Steps[step]
+ if !ok {
+ return st, fmt.Errorf("the journal has no %s step", step)
+ }
+ if err := json.Unmarshal([]byte(raw), &st); err != nil || st.Files == "" {
+ return st, fmt.Errorf("the journal's %s step %q does not name the staged files (%v)", step, raw, err)
+ }
+ return st, nil
+}
+
+// installFiles is local's sub-step (3) (final review, finding 1). The
+// archive is extracted into the op's staging, never at the live paths, and
+// its identities journalled as `staged`; a retry before that clears only
+// the staging and extracts again. Then, inside the host guard, the staged
+// tree and env are moved to the files directory and <host>.env when those
+// hold nothing this landing did not write (absent, or an empty directory,
+// or this landing's own tree by identity after a crash mid-move). Anything
+// else there is not the landing's to clear: nothing is moved and the
+// rollback is marked. The install receipt (`files_placed`) is journalled,
+// the empty staging removed, and only then `extracted`.
+func (l *landingRun) installFiles(ctx context.Context, j *moves.Journal, mark func(step, value string) error, captured hostguard.Captured, seal moves.Seal, want provision.MoveFilesDigest, dir, envPath string) (restoreOutcome, error) {
+ stage := l.stageDir()
+ stFiles, stEnv := filepath.Join(stage, "files"), filepath.Join(stage, "env")
+ var staged stagedFiles
+ if j.Done(landStepStaged) {
+ var err error
+ if staged, err = journalledStaged(j, landStepStaged); err != nil {
+ return restoreWaiting, err
+ }
+ } else {
+ // Only the op's own staging is cleared: nothing at the live paths
+ // is touched before the guarded move.
+ if err := removeTree(stage); err != nil {
+ return restoreWaiting, fmt.Errorf("clear the extraction staging: %w", err)
+ }
+ if err := os.MkdirAll(stage, 0o700); err != nil {
+ return restoreWaiting, err
+ }
+ if err := l.extractArchive(ctx, seal, want, stFiles, stEnv); err != nil {
+ return restoreWaiting, fmt.Errorf("extract the archive: %w", err)
+ }
+ var err error
+ if staged.Files, err = pathIdentity(stFiles); err != nil {
+ return restoreWaiting, err
+ }
+ if present, err := regularFile(stEnv); err != nil {
+ return restoreWaiting, err
+ } else if present {
+ if staged.Env, err = pathIdentity(stEnv); err != nil {
+ return restoreWaiting, err
+ }
+ }
+ body, err := json.Marshal(staged)
+ if err != nil {
+ return restoreWaiting, err
+ }
+ if err := mark(landStepStaged, string(body)); err != nil {
+ return restoreWaiting, err
+ }
+ }
+ err := l.guarded(ctx, captured, provision.LandLocal, func() error {
+ return l.placeStaged(staged, stFiles, stEnv, dir, envPath)
+ })
+ if errors.Is(err, errFilesOccupied) {
+ log.Printf("moves: %s: %v; nothing was moved or removed", l.rec.Host, err)
+ ok, err := l.markRollback(ctx, provision.RollbackFilesOccupied)
+ if err != nil || !ok {
+ return restoreWaiting, err
+ }
+ return restoreMarked, nil
+ }
+ if err != nil {
+ return restoreWaiting, fmt.Errorf("install the files: %w", err)
+ }
+ if err := mark(landStepFilesPlaced, j.Steps[landStepStaged]); err != nil {
+ return restoreWaiting, err
+ }
+ if err := removeTree(stage); err != nil {
+ return restoreWaiting, fmt.Errorf("remove the extraction staging: %w", err)
+ }
+ if err := mark(landStepExtracted, ""); err != nil {
+ return restoreWaiting, err
+ }
+ return restoreInstalled, nil
+}
+
+// placeStaged runs inside the guard. Every destination is decided before
+// anything moves, so a refusal changes nothing: the files directory must be
+// this landing's tree already (by identity: a crash after its move) or
+// absent or an empty directory, and <host>.env this landing's env already,
+// or absent. An empty directory gives its mode to the tree and is replaced,
+// as Extract has always done.
+func (l *landingRun) placeStaged(staged stagedFiles, stFiles, stEnv, dir, envPath string) error {
+ filesDone, err := sameIdentity(dir, staged.Files)
+ if err != nil {
+ return err
+ }
+ var emptyDir bool
+ var mode fs.FileMode
+ if !filesDone {
+ if ok, err := sameIdentity(stFiles, staged.Files); err != nil || !ok {
+ return fmt.Errorf("the staged files at %s are not the ones journalled (%v)", stFiles, err)
+ }
+ if emptyDir, mode, err = emptyOrAbsentDir(dir); err != nil {
+ return err
+ }
+ }
+ envDone := false
+ if staged.Env != "" {
+ if envDone, err = sameIdentity(envPath, staged.Env); err != nil {
+ return err
+ }
+ if !envDone {
+ if ok, err := sameIdentity(stEnv, staged.Env); err != nil || !ok {
+ return fmt.Errorf("the staged env at %s is not the one journalled (%v)", stEnv, err)
+ }
+ }
+ }
+ if !envDone {
+ if _, err := os.Lstat(envPath); err == nil {
+ return fmt.Errorf("%w: %s exists", errFilesOccupied, envPath)
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ }
+ if !filesDone {
+ if emptyDir {
+ if err := os.Chmod(stFiles, mode); err != nil {
+ return err
+ }
+ if err := syncPath(stFiles); err != nil {
+ return err
+ }
+ // os.Remove refuses a directory that is no longer empty.
+ if err := os.Remove(dir); err != nil {
+ return err
+ }
+ }
+ if err := os.Rename(stFiles, dir); err != nil {
+ return fmt.Errorf("place the files: %w", err)
+ }
+ if err := syncPath(filepath.Dir(dir)); err != nil {
+ return err
+ }
+ if err := l.fault("after-files-rename"); err != nil {
+ return err
+ }
+ }
+ if staged.Env != "" && !envDone {
+ if err := os.Rename(stEnv, envPath); err != nil {
+ return fmt.Errorf("place the env file: %w", err)
+ }
+ if err := syncPath(filepath.Dir(envPath)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// emptyOrAbsentDir reports whether path is an empty directory (and its
+// mode); absent is false with no error; anything else (a file, a symlink,
+// a directory with anything in it) is errFilesOccupied.
+func emptyOrAbsentDir(path string) (bool, fs.FileMode, error) {
+ fi, err := os.Lstat(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return false, 0, nil
+ }
+ if err != nil {
+ return false, 0, err
+ }
+ if !fi.IsDir() {
+ return false, 0, fmt.Errorf("%w: %s is not a directory", errFilesOccupied, path)
+ }
+ f, err := os.Open(path)
+ if err != nil {
+ return false, 0, err
+ }
+ names, err := f.Readdirnames(1)
+ f.Close()
+ if len(names) > 0 {
+ return false, 0, fmt.Errorf("%w: %s holds %s", errFilesOccupied, path, names[0])
+ }
+ if err != nil && err != io.EOF {
+ return false, 0, err
+ }
+ return true, fi.Mode().Perm(), nil
+}
+
+// pathIdentity is the device and inode of path itself (not a symlink's
+// target), "dev:ino". Off unix there is none, and the landing refuses.
+func pathIdentity(path string) (string, error) {
+ fi, err := os.Lstat(path)
+ if err != nil {
+ return "", err
+ }
+ dev, ino, ok := fileInode(fi)
+ if !ok {
+ return "", fmt.Errorf("%s: this platform cannot identify files", path)
+ }
+ return fmt.Sprintf("%d:%d", dev, ino), nil
+}
+
+// sameIdentity reports whether path exists with identity id.
+func sameIdentity(path, id string) (bool, error) {
+ got, err := pathIdentity(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ return id != "" && got == id, nil
+}
+
+// removeTree removes a tree this landing made under its own journal
+// directory, whose directories may carry the archive's restrictive modes:
+// each is made writable before its contents are removed. Absent is nothing
+// to do.
+func removeTree(path string) error {
+ if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
+ if err == nil && d.IsDir() {
+ os.Chmod(p, 0o700)
+ }
+ return nil
+ })
+ return os.RemoveAll(path)
+}
// filesGenMarker is the .files-gen body (§5.1): the op and the incarnation.
type filesGenMarker struct {
@@ -156,10 +462,11 @@ func sameBundle(a, b map[string]string) bool {
return true
}
-// restoreOutcome is what restore ended with: the database installed and its
-// markers written; the record changed instead (the rollback marker, fix
-// round 1, item 2: a marker written is never read as an install); or the
-// phase waiting for the next pass (the location's lease held elsewhere).
+// restoreOutcome is what restore (or local's files install) ended with: the
+// database installed and its markers written (or the files placed); the
+// record changed instead (the rollback marker, fix round 1, item 2: a
+// marker written is never read as an install); or the phase waiting for
+// the next pass (the location's lease held elsewhere).
type restoreOutcome int
const (
@@ -170,9 +477,17 @@ const (
// 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.
-// The returned sha carries the record's "sha256:" prefix, as Source.DBSHA256
-// does.
+// rollback marker; then, inside the host guard (final review, finding 2;
+// hostbox ColdRestore.Install's pattern), the destination checked again,
+// the rename into DBPath and the generation marker. The returned sha
+// carries the record's "sha256:" prefix, as Source.DBSHA256 does.
+//
+// The build, the verification and the rollback marker run outside the
+// guard (they read the network or write the record); the observation they
+// start from is only a hint, and the guarded block decides: a database at
+// DBPath that is not this build, a marker naming another generation, or a
+// WAL beside an absent database is a successor's or someone else's, and
+// is never replaced.
func (l *landingRun) restore(ctx context.Context, obs hostclaim.Observed) (sha string, outcome restoreOutcome, err error) {
cfg := l.cfg()
host, id := l.rec.Host, l.g.ID
@@ -182,20 +497,16 @@ func (l *landingRun) restore(ctx context.Context, obs hostclaim.Observed) (sha s
}
want := "sha256:" + m.DBSHA256
dbPath := l.dbPath()
+ captured, err := l.capture(*obs.Claim.Holder)
+ if err != nil {
+ return "", restoreWaiting, err
+ }
present, err := regularFile(dbPath)
if err != nil {
return "", restoreWaiting, err
}
- if present {
- got, err := fileSHA256(dbPath)
- if err != nil {
- return "", restoreWaiting, err
- }
- if got != want {
- return "", restoreWaiting, 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")
+ built := filepath.Join(l.buildDir(), host+".db")
+ if !present {
if ok, err := regularFile(built); err != nil {
return "", restoreWaiting, err
} else if !ok {
@@ -224,20 +535,56 @@ func (l *landingRun) restore(ctx context.Context, obs hostclaim.Observed) (sha s
for _, side := range []string{"-wal", "-shm"} {
os.Remove(built + side)
}
- if err := os.Rename(built, dbPath); err != nil {
- return "", restoreWaiting, fmt.Errorf("install the database: %w", err)
+ }
+ err = l.guarded(ctx, captured, provision.LandConfigured, func() error {
+ gen, err := hostbox.ReadGenerationMarker(dbPath + ".gen")
+ if err != nil {
+ return fmt.Errorf("read %s.gen: %w", dbPath, err)
}
- if err := syncPath(filepath.Dir(dbPath)); err != nil {
- return "", restoreWaiting, err
+ if gen != "" && gen != id {
+ return fmt.Errorf("%w: %s.gen names generation %s", errLandingChanged, dbPath, gen)
}
- }
- if err := l.fault("after-db-rename"); err != nil {
- return "", restoreWaiting, err
- }
- if err := cfg.WriteGen(dbPath+".gen", id); err != nil {
- return "", restoreWaiting, fmt.Errorf("write the generation marker: %w", err)
- }
- if err := syncPath(filepath.Dir(dbPath)); err != nil {
+ now, err := regularFile(dbPath)
+ if err != nil {
+ return err
+ }
+ switch {
+ case now:
+ // This landing's own install (a replay after the rename) is
+ // the build's bytes; anything else is not to be replaced.
+ got, err := fileSHA256(dbPath)
+ if err != nil {
+ return err
+ }
+ if got != want {
+ return fmt.Errorf("%w: %s holds a database that is not this landing's build (%s, want %s)", errLandingChanged, dbPath, got, want)
+ }
+ case present:
+ return fmt.Errorf("%w: the database observed at %s is gone", errLandingChanged, dbPath)
+ default:
+ for _, side := range []string{"-wal", "-shm"} {
+ if _, err := os.Lstat(dbPath + side); err == nil {
+ return fmt.Errorf("%w: %s%s exists beside an absent database", errLandingChanged, dbPath, side)
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ }
+ if err := os.Rename(built, dbPath); err != nil {
+ return fmt.Errorf("install the database: %w", err)
+ }
+ if err := syncPath(filepath.Dir(dbPath)); err != nil {
+ return err
+ }
+ }
+ if err := l.fault("after-db-rename"); err != nil {
+ return err
+ }
+ if err := cfg.WriteGen(dbPath+".gen", id); err != nil {
+ return fmt.Errorf("write the generation marker: %w", err)
+ }
+ return syncPath(filepath.Dir(dbPath))
+ })
+ if err != nil {
return "", restoreWaiting, err
}
log.Printf("moves: %s: database installed at %s (%s), generation %s", host, dbPath, want, id)