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>
4 files changed,
+732
−65
internal/host/movesland.go+43 −31internal/host/movesland_finalfix_test.go+308 −0internal/host/movesland_test.go+1 −1internal/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) |