host: a release is proved by its full stop receipt; it stops the writers, flushes files, wake state and env, records its positions and only then drains
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+3087
−1
internal/host/relocationcontrollers_linux_test.go+357 −0internal/host/relocationeffects.go+869 −0internal/host/relocationeffects_test.go+726 −0internal/host/relocationflush.go+179 −0internal/host/relocationlifecycle_linux_test.go+788 −0internal/host/relocationreleases.go+53 −0internal/host/relocationreleases_test.go+62 −0internal/hostexec/readlocal.go+17 −0internal/hostfiles/platform.go+33 −0internal/hostrelocate/participant.go+3 −1
diff --git a/internal/host/relocationcontrollers_linux_test.go b/internal/host/relocationcontrollers_linux_test.go| new file mode 100644 |
| index 000000000..89028d43f |
| --- /dev/null |
| +++ b/internal/host/relocationcontrollers_linux_test.go |
| @@ -0,0 +1,357 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + execctl "github.com/carlosframework/platform/internal/hostexec" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostquarantine" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type activeDirectory struct{} |
| + |
| +func (activeDirectory) Lookup(context.Context, string) (hostquarantine.Actor, error) { |
| + return hostquarantine.Actor{Enrollment: strings.Repeat("a", 32), State: "active"}, nil |
| +} |
| + |
| +// A real hostexec controller on disk: after the relocation completes, |
| +// Finish closes it and clears its durable record, so root's §5.10 check |
| +// (hostexecParticipation) lets an executable update through. While the |
| +// operation still runs, the clear refuses and the record stays. A cleanup |
| +// that failed keeps the record however often Close is called afterwards |
| +// (hostexec returns the failure once), and across a restart, until a |
| +// repeated cleanup succeeds. |
| +func TestFinishClearsTheDurableRecordSoUpdatesAreEligible(t *testing.T) { |
| + ctx := context.Background() |
| + dataDir := t.TempDir() |
| + for _, name := range []string{"hostguard", ".hostexec", "locks", "exec-locks"} { |
| + if err := os.Mkdir(filepath.Join(dataDir, name), 0o770); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + store := release.NewDirStore(t.TempDir()) |
| + const host = "shop.bab.oncarlos.com" |
| + created := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC) |
| + nh := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: created} |
| + body := []byte(`{"host":"` + host + `","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`) |
| + obs, err := hostclaim.Read(ctx, store, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + step := func(o hostclaim.Observed, tr func(*hostclaim.Claim) error) hostclaim.Observed { |
| + t.Helper() |
| + next, err := hostclaim.Update(ctx, store, o, tr) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return next |
| + } |
| + obs = step(obs, hostclaim.BeginCreateOn(nh, body, "c1", "console", created, hostclaim.ExecutorSelection{Box: "src", Enrollment: strings.Repeat("a", 32)})) |
| + obs = step(obs, hostclaim.CompleteCreate("c1", "src")) |
| + obs = step(obs, hostclaim.AdmitWriter(hostclaim.Admission{Box: "src", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true})) |
| + obs = step(obs, func(c *hostclaim.Claim) error { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseStarting, Exec: "src", Participants: []string{"src", "dst"}, |
| + Intent: map[string]string{"region": "ie", "source_box": "src", "target_box": "dst"}} |
| + return nil |
| + }) |
| + failing := false |
| + scoped := func(context.Context) error { |
| + if failing { |
| + return errors.New("cleanup: the partial install would not go") |
| + } |
| + return nil |
| + } |
| + cfg := execctl.Config{Store: store, Guard: hostguard.Guard{DataDir: dataDir, LockDir: filepath.Join(dataDir, "locks"), Claims: hostguard.StoreReader{Store: store}}, |
| + Directory: activeDirectory{}, Clock: execctl.SystemClock{}, LockDir: filepath.Join(dataDir, "exec-locks"), |
| + CloseAdmission: func() {}, CancelWork: func() {}, Cleanup: scoped} |
| + journal := filepath.Join(dataDir, "relocate", "participations") |
| + controllers := func() *RelocationControllers { |
| + return &RelocationControllers{Journal: journal, DataDir: dataDir, |
| + Cleanup: func(ctx context.Context, _ execctl.Binding) error { return scoped(ctx) }, |
| + Clear: func(ctx context.Context, b execctl.Binding) error { |
| + c := cfg |
| + c.Binding = b |
| + return execctl.ClearStale(ctx, c) |
| + }} |
| + } |
| + e, _, _, _ := effectsFixture(t) |
| + e.Controllers = controllers() |
| + e.Identity = func(context.Context) (RelocationIdentity, error) { |
| + return RelocationIdentity{Actor: hostbox.Actor{Box: "src", Enrollment: strings.Repeat("a", 32), Revision: 4}, BootID: "boot", ControllerID: "ctl"}, nil |
| + } |
| + e.Controller = func(ctx context.Context, b execctl.Binding) (RelocationController, error) { |
| + c := cfg |
| + c.Binding = b |
| + return OpenRelocationController(ctx, c) |
| + } |
| + if _, err := e.Acknowledge(ctx, obs); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := hostexecParticipation(dataDir); err == nil { |
| + t.Fatal("a participant's record did not block an update") |
| + } |
| + // The controller's cleanup fails: Close reports it, the record stays. |
| + failing = true |
| + if err := e.Finish(ctx, host, "rel-1"); err == nil { |
| + t.Fatal("a failed cleanup was not reported") |
| + } |
| + if err := hostexecParticipation(dataDir); err == nil || len(kept(e)) != 1 { |
| + t.Fatalf("a failed cleanup cleared the record: %v retained=%v", err, kept(e)) |
| + } |
| + // Still relocating: the clear refuses, the record and the entry stay. |
| + failing = false |
| + if err := e.Finish(ctx, host, "rel-1"); !errors.Is(err, execctl.ErrInactive) { |
| + t.Fatalf("finish during the operation: %v", err) |
| + } |
| + if err := hostexecParticipation(dataDir); err == nil || len(kept(e)) != 1 { |
| + t.Fatalf("the record was cleared while the operation ran: %v retained=%v", err, kept(e)) |
| + } |
| + // Served on the target. The cleanup fails again: hostexec's Close now |
| + // returns nil, and the next sweep must still not clear. |
| + step(obs, func(c *hostclaim.Claim) error { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "dst", Participants: []string{"dst"}} |
| + return nil |
| + }) |
| + failing = true |
| + if err := e.Finish(ctx, host, "rel-1"); err == nil { |
| + t.Fatal("a sweep after a failed cleanup reported success") |
| + } |
| + if err := hostexecParticipation(dataDir); err == nil || len(kept(e)) != 1 { |
| + t.Fatalf("a nil Close cleared the record: %v retained=%v", err, kept(e)) |
| + } |
| + // The agent restarts with the cleanup still failing: nothing clears. |
| + restarted := controllers() |
| + if err := restarted.Finish(ctx, host, "rel-1"); err == nil { |
| + t.Fatal("a restart cleared without a successful cleanup") |
| + } |
| + if err := hostexecParticipation(dataDir); err == nil || len(kept(restarted)) != 1 { |
| + t.Fatalf("a restart cleared the record: %v retained=%v", err, kept(restarted)) |
| + } |
| + // The cleanup succeeds: the retry clears the record and the journal. |
| + failing = false |
| + if err := restarted.Finish(ctx, host, "rel-1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := hostexecParticipation(dataDir); err != nil { |
| + t.Fatalf("after the relocation an update is still refused: %v", err) |
| + } |
| + if len(kept(restarted)) != 0 { |
| + t.Fatalf("retained: %v", kept(restarted)) |
| + } |
| +} |
| + |
| +// Round 12 finding 1: relocation A completes and B starts before this box |
| +// sweeps A. A's real controller still holds the host's lifetime lock; |
| +// Acknowledge for B retires it first (hostexec refuses to clear A's record |
| +// while B lists this box, so B's Open replaces it), under the pass's |
| +// bounded context, and B's controller opens. A lock held by a controller |
| +// this box does not retain is waited for no longer than the pass. |
| +func TestASuccessorRelocationRetiresItsPredecessorsController(t *testing.T) { |
| + ctx := context.Background() |
| + dataDir := t.TempDir() |
| + for _, name := range []string{"hostguard", ".hostexec", "locks", "exec-locks"} { |
| + if err := os.Mkdir(filepath.Join(dataDir, name), 0o770); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + store := release.NewDirStore(t.TempDir()) |
| + const host = "shop.bab.oncarlos.com" |
| + created := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC) |
| + nh := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: created} |
| + body := []byte(`{"host":"` + host + `","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`) |
| + obs, err := hostclaim.Read(ctx, store, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + step := func(o hostclaim.Observed, tr func(*hostclaim.Claim) error) hostclaim.Observed { |
| + t.Helper() |
| + next, err := hostclaim.Update(ctx, store, o, tr) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return next |
| + } |
| + relocating := func(id, from, to string) func(*hostclaim.Claim) error { |
| + return func(c *hostclaim.Claim) error { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: id, Phase: hostclaim.PhaseStarting, Exec: from, Participants: []string{from, to}, |
| + Intent: map[string]string{"region": "ie", "source_box": from, "target_box": to}} |
| + return nil |
| + } |
| + } |
| + obs = step(obs, hostclaim.BeginCreateOn(nh, body, "c1", "console", created, hostclaim.ExecutorSelection{Box: "src", Enrollment: strings.Repeat("a", 32)})) |
| + obs = step(obs, hostclaim.CompleteCreate("c1", "src")) |
| + obs = step(obs, hostclaim.AdmitWriter(hostclaim.Admission{Box: "src", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true})) |
| + obs = step(obs, relocating("rel-1", "src", "dst")) |
| + cfg := execctl.Config{Store: store, Guard: hostguard.Guard{DataDir: dataDir, LockDir: filepath.Join(dataDir, "locks"), Claims: hostguard.StoreReader{Store: store}}, |
| + Directory: activeDirectory{}, Clock: execctl.SystemClock{}, LockDir: filepath.Join(dataDir, "exec-locks"), |
| + CloseAdmission: func() {}, CancelWork: func() {}, Cleanup: func(context.Context) error { return nil }} |
| + lifetime, stop := context.WithCancel(ctx) |
| + t.Cleanup(stop) |
| + e, _, _, _ := effectsFixture(t) |
| + e.Lifetime = lifetime |
| + e.Controllers = &RelocationControllers{Journal: filepath.Join(dataDir, "relocate", "participations"), DataDir: dataDir, |
| + Cleanup: func(context.Context, execctl.Binding) error { return nil }, |
| + Clear: func(ctx context.Context, b execctl.Binding) error { |
| + c := cfg |
| + c.Binding = b |
| + return execctl.ClearStale(ctx, c) |
| + }} |
| + e.Identity = func(context.Context) (RelocationIdentity, error) { |
| + return RelocationIdentity{Actor: hostbox.Actor{Box: "src", Enrollment: strings.Repeat("a", 32), Revision: 4}, BootID: "boot", ControllerID: "ctl"}, nil |
| + } |
| + e.Controller = func(ctx context.Context, b execctl.Binding) (RelocationController, error) { |
| + c := cfg |
| + c.Binding = b |
| + return OpenRelocationController(ctx, c) |
| + } |
| + if _, err := e.Acknowledge(ctx, obs); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A completes on dst, and B (dst back to src) starts before this box's |
| + // sweep has finished A. |
| + obs = step(obs, func(c *hostclaim.Claim) error { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "dst", Participants: []string{"dst"}} |
| + return nil |
| + }) |
| + obs = step(obs, relocating("rel-2", "dst", "src")) |
| + pass, cancel := context.WithTimeout(ctx, 10*time.Second) |
| + defer cancel() |
| + ack, err := e.Acknowledge(pass, obs) |
| + if err != nil { |
| + t.Fatalf("the successor's acknowledgement: %v", err) |
| + } |
| + if ack.Op != "rel-2" { |
| + t.Fatalf("ack: %+v", ack) |
| + } |
| + // A's journal entry stays until a sweep verifies the record is B's |
| + // (rev 14), then it goes; B's stays. |
| + if err := e.Finish(pass, host, "rel-1"); err != nil { |
| + t.Fatalf("sweeping A after the handover: %v", err) |
| + } |
| + got, err := e.Retained() |
| + if err != nil || len(got) != 1 || got[0].Op != "rel-2" { |
| + t.Fatalf("retained after the handover: %+v %v", got, err) |
| + } |
| + if err := hostexecParticipation(dataDir); err == nil { |
| + t.Fatal("B's record did not block an update") |
| + } |
| + |
| + // A lock held by a controller this box does not retain (here, another |
| + // operation's opened directly): the next open waits no longer than the |
| + // pass, and opens nothing. |
| + if err := e.Controllers.Close(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + obs = step(obs, relocating("rel-3", "dst", "src")) |
| + foreign := cfg |
| + foreign.Binding = execctl.Binding{Box: "src", Enrollment: strings.Repeat("a", 32), Host: host, Incarnation: obs.Claim.Holder.Incarnation, Op: "rel-3", BootID: "boot"} |
| + held, err := execctl.Open(lifetime, foreign) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer held.Close(ctx) |
| + other := *e |
| + other.Controllers = &RelocationControllers{Journal: filepath.Join(dataDir, "relocate", "other"), Cleanup: func(context.Context, execctl.Binding) error { return nil }} |
| + short, cancelShort := context.WithTimeout(ctx, 300*time.Millisecond) |
| + defer cancelShort() |
| + began := time.Now() |
| + if _, err := other.Acknowledge(short, obs); err == nil { |
| + t.Fatal("opened while another controller held the host") |
| + } |
| + if waited := time.Since(began); waited > 5*time.Second { |
| + t.Fatalf("the open waited %v, past the pass's bound", waited) |
| + } |
| + if got, _ := other.Controllers.Retained(); len(got) != 1 || got[0].Op != "rel-3" { |
| + t.Fatalf("the bounded open retained a controller: %+v", got) // only the journal entry |
| + } |
| + other.Controllers.mu.Lock() |
| + n := len(other.Controllers.open) |
| + other.Controllers.mu.Unlock() |
| + if n != 0 { |
| + t.Fatal("a controller was kept from a cancelled open") |
| + } |
| +} |
| + |
| +// Round 14: a failed clear forgets a participation only on hostexec's own |
| +// validated reading of the record. A parseable record missing its |
| +// enrollment or incarnation is one hostexec refuses, so it is never |
| +// "another binding": the participation is kept, across a restart, until |
| +// the record is repaired and a cleanup and clear succeed. |
| +func TestARecordHostexecRefusesKeepsItsParticipation(t *testing.T) { |
| + ctx := context.Background() |
| + const host = "shop.bab.oncarlos.com" |
| + b := execctl.Binding{Box: "src", Enrollment: strings.Repeat("a", 32), Host: host, Incarnation: "inc", Op: "rel-1", BootID: "boot", ControllerID: "ctl"} |
| + valid := execctl.State{V: 1, Binding: b, Expires: time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC), SafeUntil: 1, State: "lost"} |
| + for _, missing := range []string{"enrollment", "incarnation"} { |
| + t.Run(missing, func(t *testing.T) { |
| + dataDir := t.TempDir() |
| + if err := os.Mkdir(filepath.Join(dataDir, ".hostexec"), 0o770); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(dataDir, ".hostexec", host+".json") |
| + write := func(st execctl.State, drop string) { |
| + t.Helper() |
| + raw, _ := json.Marshal(st) |
| + var m map[string]any |
| + _ = json.Unmarshal(raw, &m) |
| + delete(m, drop) |
| + raw, _ = json.Marshal(m) |
| + if err := os.WriteFile(path, raw, 0o660); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + write(valid, missing) |
| + cleared, cleanups := 0, 0 |
| + clearErr := execctl.ErrState // ClearStale refuses the invalid record |
| + controllers := func() *RelocationControllers { |
| + return &RelocationControllers{Journal: filepath.Join(dataDir, "relocate", "participations"), DataDir: dataDir, |
| + Cleanup: func(context.Context, execctl.Binding) error { cleanups++; return nil }, |
| + Clear: func(context.Context, execctl.Binding) error { |
| + if clearErr != nil { |
| + return clearErr |
| + } |
| + cleared++ |
| + return os.Remove(path) |
| + }} |
| + } |
| + r := controllers() |
| + if err := r.record(host, "rel-1", b); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r.Finish(ctx, host, "rel-1"); err == nil { |
| + t.Fatal("a record hostexec refuses was taken as another binding") |
| + } |
| + if got := kept(&RelocationEffects{Controllers: r}); len(got) != 1 { |
| + t.Fatalf("the participation was forgotten: %+v", got) |
| + } |
| + // A restart: the journal still names it, and still nothing clears. |
| + r = controllers() |
| + if err := r.Finish(ctx, host, "rel-1"); err == nil || len(kept(&RelocationEffects{Controllers: r})) != 1 { |
| + t.Fatalf("after a restart: %v", err) |
| + } |
| + // Repaired: the record is valid and this binding's; the cleanup |
| + // and the clear run, and only then is the participation gone. |
| + write(valid, "") |
| + clearErr = nil |
| + before := cleanups |
| + if err := r.Finish(ctx, host, "rel-1"); err != nil || cleared != 1 || cleanups != before+1 { |
| + t.Fatalf("after the repair: %v cleared=%d cleanups=%d", err, cleared, cleanups-before) |
| + } |
| + if got := kept(&RelocationEffects{Controllers: r}); len(got) != 0 { |
| + t.Fatalf("retained after the clear: %+v", got) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/relocationeffects.go b/internal/host/relocationeffects.go| new file mode 100644 |
| index 000000000..b7500aa92 |
| --- /dev/null |
| +++ b/internal/host/relocationeffects.go |
| @@ -0,0 +1,869 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + "sync" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + execctl "github.com/carlosframework/platform/internal/hostexec" |
| + "github.com/carlosframework/platform/internal/hostrelocate" |
| + "github.com/carlosframework/platform/internal/stopevidence" |
| +) |
| + |
| +// RelocationIdentity is this box's verified actor and stop identity, as |
| +// Plan 6b's assembly reports it (claimStopLocal). |
| +type RelocationIdentity struct { |
| + Actor hostbox.Actor |
| + BootID, ControllerID string |
| +} |
| + |
| +// RelocationController is the execution controller as this package uses |
| +// it: hostexec.Controller's Ready and Close, and Clear, which removes the |
| +// controller's durable local record once its cleanup is done |
| +// (hostexec.ClearStale with the controller's config): Close leaves the |
| +// record in "lost", and under §5.10 any record blocks an executable |
| +// update until it is cleared. |
| +type RelocationController interface { |
| + Ready(context.Context) (execctl.State, error) |
| + Close(context.Context) error |
| + Clear(context.Context) error |
| + // Lost is closed once the controller has lost its lease for good |
| + // (hostexec latches the first failure: a failed renewal, a lost |
| + // authority, a clock fault). |
| + Lost() <-chan struct{} |
| +} |
| + |
| +// participation is one journaled participation whose controller may have |
| +// left a durable record: written before the controller is opened |
| +// (Acknowledge), removed only after the record is cleared (Finish). A |
| +// crash anywhere in between leaves the entry, so a restarted agent finds |
| +// every record it may have left, including one from a controller that |
| +// never reached its first Finish or whose Open failed after writing. |
| +type participation struct { |
| + V int `json:"v"` |
| + Host string `json:"host"` |
| + Op string `json:"op"` |
| + Binding execctl.Binding `json:"binding"` |
| +} |
| + |
| +// RelocationControllers retains the execution controllers this agent has |
| +// opened for its participations, one per {host, op}, for the agent's |
| +// lifetime. A controller is never bound to a pass's context (claim D6: |
| +// the lease lives as long as the participation). A restarted agent has |
| +// none and opens a fresh one; the claim keeps the first acknowledgement. |
| +type RelocationControllers struct { |
| + mu sync.Mutex |
| + open map[string]RelocationController |
| + // Journal is where participations are recorded before their |
| + // controller opens (<DataDir>/relocate/participations); Finish |
| + // retries a journaled one after a restart, when no controller object |
| + // exists, through the binding it recorded. Required: Acknowledge |
| + // opens nothing without it. |
| + Journal string |
| + // Cleanup is the participation's scoped cleanup for one binding |
| + // (Task 8a's relocationCleanup: it retires only that operation's own |
| + // partial installation of that host and incarnation, after checking |
| + // the binding's box and enrollment are this box's verified actor, and |
| + // does nothing once this box has admitted a writer under the |
| + // operation or serves the host). Finish repeats it, and needs it to |
| + // succeed, in the same call as every clear: hostexec.Controller.Close |
| + // returns a cleanup failure once and nil on every later call, and a |
| + // restarted agent has no controller at all, so neither a nil Close |
| + // nor a journal entry proves the cleanup ran. Nil clears nothing. |
| + Cleanup func(context.Context, execctl.Binding) error |
| + // Clear removes a journaled binding's durable record after a restart |
| + // (hostexec.ClearStale with the assembly's config for the binding). |
| + Clear func(context.Context, execctl.Binding) error |
| + // DataDir is hostexec's data directory (Guard.DataDir), whose |
| + // .hostexec/<host>.json is the durable record. When a clear fails, |
| + // the participation is forgotten only if that record is verifiably |
| + // not this binding's: absent, or naming another binding (rev 14). |
| + // Empty: never verifiable, so a failed clear always keeps it. |
| + DataDir string |
| + // bindings remembers each open controller's binding. |
| + bindings map[string]execctl.Binding |
| +} |
| + |
| +func controllerKey(host, op string) string { return host + "\x00" + op } |
| + |
| +func (r *RelocationControllers) get(host, op string) (RelocationController, bool) { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + c, ok := r.open[controllerKey(host, op)] |
| + return c, ok |
| +} |
| + |
| +func (r *RelocationControllers) put(host, op string, b execctl.Binding, c RelocationController) (RelocationController, bool) { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + if r.open == nil { |
| + r.open, r.bindings = map[string]RelocationController{}, map[string]execctl.Binding{} |
| + } |
| + if prior, ok := r.open[controllerKey(host, op)]; ok { |
| + return prior, false |
| + } |
| + r.open[controllerKey(host, op)], r.bindings[controllerKey(host, op)] = c, b |
| + return c, true |
| +} |
| + |
| +const journalSuffix = ".json" |
| + |
| +func (r *RelocationControllers) journalPath(host, op string) string { |
| + return filepath.Join(r.Journal, host+"~"+op+journalSuffix) |
| +} |
| + |
| +// record journals a participation before its controller opens. |
| +func (r *RelocationControllers) record(host, op string, b execctl.Binding) error { |
| + if r.Journal == "" { |
| + return ErrClaimStopUnavailable |
| + } |
| + raw, err := json.Marshal(participation{V: 1, Host: host, Op: op, Binding: b}) |
| + if err != nil { |
| + return err |
| + } |
| + if err := os.MkdirAll(r.Journal, 0o700); err != nil { |
| + return err |
| + } |
| + return writeFileSynced(r.journalPath(host, op), raw, 0o600) |
| +} |
| + |
| +// journaled lists the committed participations on disk. Only names |
| +// ending in .json are committed records: writeFileSynced's .json.tmp is |
| +// an uncommitted write a crash may have truncated, and is ignored (its |
| +// participation opened nothing, because the record is written before the |
| +// controller opens). A committed record that cannot be read is reported, |
| +// joined into the error, and every readable one is still returned, so |
| +// one damaged entry never hides the others' cleanup. |
| +func (r *RelocationControllers) journaled() ([]participation, error) { |
| + if r.Journal == "" { |
| + return nil, nil |
| + } |
| + entries, err := os.ReadDir(r.Journal) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + var out []participation |
| + var errs []error |
| + for _, e := range entries { |
| + if e.IsDir() || !strings.HasSuffix(e.Name(), journalSuffix) { |
| + continue |
| + } |
| + raw, err := os.ReadFile(filepath.Join(r.Journal, e.Name())) |
| + if err != nil { |
| + errs = append(errs, err) |
| + continue |
| + } |
| + var f participation |
| + if err := json.Unmarshal(raw, &f); err != nil || f.V != 1 || f.Host == "" || f.Op == "" { |
| + errs = append(errs, fmt.Errorf("%w: participation journal %s", ErrClaimStopUnavailable, e.Name())) |
| + continue |
| + } |
| + out = append(out, f) |
| + } |
| + return out, errors.Join(errs...) |
| +} |
| + |
| +// Retained lists the participations whose controllers are open, and the |
| +// journaled ones whose durable record may still need clearing. A damaged |
| +// journal entry is returned as the error beside every readable one. |
| +func (r *RelocationControllers) Retained() ([]hostrelocate.Participation, error) { |
| + r.mu.Lock() |
| + seen := map[string]bool{} |
| + var out []hostrelocate.Participation |
| + for key := range r.open { |
| + host, op, _ := strings.Cut(key, "\x00") |
| + seen[key] = true |
| + out = append(out, hostrelocate.Participation{Host: host, Op: op}) |
| + } |
| + r.mu.Unlock() |
| + journaled, err := r.journaled() |
| + for _, f := range journaled { |
| + if !seen[controllerKey(f.Host, f.Op)] { |
| + seen[controllerKey(f.Host, f.Op)] = true |
| + out = append(out, hostrelocate.Participation{Host: f.Host, Op: f.Op}) |
| + } |
| + } |
| + sort.Slice(out, func(i, j int) bool { |
| + return out[i].Host < out[j].Host || (out[i].Host == out[j].Host && out[i].Op < out[j].Op) |
| + }) |
| + return out, err |
| +} |
| + |
| +// Finish closes and forgets one participation's controller, releasing the |
| +// host's lifetime lock for the next operation, and clears its durable |
| +// record. Closing runs hostexec's Cleanup hook: the relocation's |
| +// controller is opened with a cleanup scoped to the relocation's own |
| +// partial work (Task 8a), never the serving writer's session, so |
| +// finishing after a successful relocation stops nothing that serves. |
| +// |
| +// The entry stays until Close succeeds: hostexec retains the lifetime |
| +// lock on a timed-out join and needs another Close, so a controller whose |
| +// close the pass's context cut short is found by the next sweep. Then the |
| +// scoped cleanup is repeated (Cleanup) and the record is cleared only if |
| +// that cleanup succeeded in this call: a nil Close after an earlier |
| +// failed one, or a restart with no controller, proves nothing. The |
| +// journal entry goes only once the record is gone, so a restart in |
| +// between retries the cleanup and the clear through the recorded binding. |
| +func (r *RelocationControllers) Finish(ctx context.Context, host, op string) error { |
| + return r.finish(ctx, host, op, false) |
| +} |
| + |
| +// errSuperseded is finish's answer, for a successor's handover, when the |
| +// predecessor's controller is closed and cleaned up but hostexec refuses |
| +// to clear its record because the claim's running operation lists this |
| +// box (hostexec.ClearStale, controller.go: "a fresh successor operation |
| +// may exclude this box"; here it includes it). The lifetime lock is free, |
| +// and the successor's Open replaces the record under it. |
| +var errSuperseded = errors.New("relocation: the predecessor's record passes to its successor") |
| + |
| +func (r *RelocationControllers) finish(ctx context.Context, host, op string, successor bool) error { |
| + key := controllerKey(host, op) |
| + r.mu.Lock() |
| + c, ok := r.open[key] |
| + b := r.bindings[key] |
| + r.mu.Unlock() |
| + if !ok { |
| + // No controller object: a restart, or a crash before the first |
| + // Finish. Retry through the journaled binding. |
| + journaled, err := r.journaled() |
| + found := false |
| + for _, f := range journaled { |
| + if f.Host == host && f.Op == op { |
| + found, b = true, f.Binding |
| + } |
| + } |
| + if !found { |
| + return err // nil: nothing journaled; else the damaged entry may be this one |
| + } |
| + } |
| + if r.Cleanup == nil || (!ok && r.Clear == nil) { |
| + return ErrClaimStopUnavailable |
| + } |
| + if ok { |
| + if err := c.Close(ctx); err != nil { |
| + return err // cleanup failed or was cut short: the record stays, and so does the entry |
| + } |
| + } |
| + if err := r.Cleanup(ctx, b); err != nil { |
| + return err // the record stays: no clear without a cleanup that succeeded now |
| + } |
| + clear := func() error { return r.Clear(ctx, b) } |
| + if ok { |
| + clear = func() error { return c.Clear(ctx) } |
| + } |
| + if !ok { |
| + // No controller object: when the record is verifiably not this |
| + // binding's (gone, or a successor's that holds the host's lock), |
| + // there is nothing to clear, and ClearStale would only wait out |
| + // the pass on the successor's lock. |
| + if mine, verr := r.recordIsMine(host, b); verr == nil && !mine { |
| + return r.forget(host, op) |
| + } |
| + } |
| + superseded := false |
| + if err := clear(); err != nil { |
| + // Whatever the error, the participation goes only if the record |
| + // itself shows it is not this binding's (round 13 finding 2: |
| + // ClearStale's ErrState also means an absent claim or an invalid |
| + // local record, and those leave this binding's record in place). |
| + mine, verr := r.recordIsMine(host, b) |
| + switch { |
| + case verr != nil: |
| + return errors.Join(err, verr) |
| + case !mine: |
| + // Verified: no record, or another binding's (a successor |
| + // opened over it). |
| + case successor && errors.Is(err, execctl.ErrInactive): |
| + superseded = true // the lock is free; the journal stays until the successor opens |
| + default: |
| + return err |
| + } |
| + } |
| + r.mu.Lock() |
| + if ok && r.open[key] == c { |
| + delete(r.open, key) |
| + delete(r.bindings, key) |
| + } |
| + r.mu.Unlock() |
| + if superseded { |
| + return errSuperseded |
| + } |
| + return r.forget(host, op) |
| +} |
| + |
| +// recordIsMine reads hostexec's durable record for host through |
| +// hostexec's own validated reader (ReadLocal) and reports whether it is |
| +// b's binding (box, enrollment, host, incarnation, operation: what |
| +// ClearStale matches). Only an absent record, or a valid record of another |
| +// binding, is "not mine"; a record hostexec would refuse (one missing its |
| +// enrollment or incarnation, say) is an error, and the participation is |
| +// kept (round 14). |
| +func (r *RelocationControllers) recordIsMine(host string, b execctl.Binding) (bool, error) { |
| + if r.DataDir == "" { |
| + return false, fmt.Errorf("%w: no hostexec data directory to verify the record", ErrClaimStopUnavailable) |
| + } |
| + st, err := execctl.ReadLocal(r.DataDir, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, fmt.Errorf("hostexec record for %s: %w", host, err) |
| + } |
| + return st.Box == b.Box && st.Enrollment == b.Enrollment && st.Incarnation == b.Incarnation && st.Op == b.Op, nil |
| +} |
| + |
| +// live returns the open controller for {host, op}, retiring it first if it |
| +// has lost its lease (round 13 finding 1): hostexec latches the first |
| +// failure, so a lost controller never becomes ready again. Retiring joins |
| +// it (Close, bounded by ctx), repeats the scoped cleanup, and forgets the |
| +// object but keeps the journal: the record on disk stays "lost" (durable |
| +// denial) until a fresh controller, opened with fresh authority and its |
| +// own lease, replaces it. ok false: none open (or just retired). |
| +func (r *RelocationControllers) live(ctx context.Context, host, op string) (RelocationController, bool, error) { |
| + key := controllerKey(host, op) |
| + r.mu.Lock() |
| + c, ok := r.open[key] |
| + b := r.bindings[key] |
| + r.mu.Unlock() |
| + if !ok { |
| + return nil, false, nil |
| + } |
| + select { |
| + case <-c.Lost(): |
| + default: |
| + return c, true, nil |
| + } |
| + if r.Cleanup == nil { |
| + return nil, false, ErrClaimStopUnavailable |
| + } |
| + if err := c.Close(ctx); err != nil { |
| + return nil, false, err // joined later; the entry stays |
| + } |
| + if err := r.Cleanup(ctx, b); err != nil { |
| + return nil, false, err // the entry stays: the next pass repeats both |
| + } |
| + r.mu.Lock() |
| + if r.open[key] == c { |
| + delete(r.open, key) |
| + delete(r.bindings, key) |
| + } |
| + r.mu.Unlock() |
| + return nil, false, nil |
| +} |
| + |
| +// forget removes one participation's journal entry. |
| +func (r *RelocationControllers) forget(host, op string) error { |
| + if r.Journal != "" { |
| + if err := os.Remove(r.journalPath(host, op)); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// handover retires every other participation of host this box still holds |
| +// before op's controller opens (round 12 finding 1): hostexec keeps one |
| +// controller per host, and a predecessor whose sweep has not yet run still |
| +// holds the host's lifetime lock, so the successor's Open would wait on it |
| +// for the whole pass. Each predecessor is closed and its scoped cleanup |
| +// repeated under the pass's context (bounded). One whose record hostexec |
| +// will not clear because the successor lists this box (errSuperseded) |
| +// keeps its journal entry: the successor's Open replaces the record under |
| +// the freed lock, and a later sweep forgets the entry once the record is |
| +// verifiably the successor's (rev 14). |
| +func (r *RelocationControllers) handover(ctx context.Context, host, op string) error { |
| + retained, _ := r.Retained() // a damaged entry is Sweep's to report |
| + for _, p := range retained { |
| + if p.Host != host || p.Op == op { |
| + continue |
| + } |
| + if err := r.finish(ctx, host, p.Op, true); err != nil && !errors.Is(err, errSuperseded) { |
| + return fmt.Errorf("relocation: retiring %s's controller for %s before %s: %w", p.Op, host, op, err) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// writeFileSynced writes path whole: a synced temporary file renamed into |
| +// place, then the directory synced, so a committed entry survives a crash. |
| +func writeFileSynced(path string, body []byte, mode os.FileMode) error { |
| + tmp := path + ".tmp" |
| + if err := os.WriteFile(tmp, body, mode); err != nil { |
| + return err |
| + } |
| + f, err := os.Open(tmp) |
| + if err != nil { |
| + return err |
| + } |
| + if err := f.Sync(); err != nil { |
| + _ = f.Close() |
| + return err |
| + } |
| + _ = f.Close() |
| + if err := os.Rename(tmp, path); err != nil { |
| + return err |
| + } |
| + dir, err := os.Open(filepath.Dir(path)) |
| + if err != nil { |
| + return err |
| + } |
| + defer dir.Close() |
| + return dir.Sync() |
| +} |
| + |
| +// Close closes every retained controller (agent shutdown). |
| +func (r *RelocationControllers) Close(ctx context.Context) error { |
| + r.mu.Lock() |
| + open := r.open |
| + r.open = nil |
| + r.mu.Unlock() |
| + var first error |
| + for _, c := range open { |
| + if err := c.Close(ctx); err != nil && first == nil { |
| + first = err |
| + } |
| + } |
| + return first |
| +} |
| + |
| +// ReleasePositions keeps the flushed positions of one release durably, |
| +// bound to the exact stop request (host, incarnation, stamp, session, |
| +// operation, phase, box, enrollment, boot, controller), from before root |
| +// completes its receipt. A replayed receipt (root's durable fence after a |
| +// lost response or a crash before JournalRelease) is paired with them; |
| +// without a record for that exact request the release is refused rather |
| +// than guessed. |
| +// Recorded matches the stop's installation and session (host, incarnation, |
| +// stamp, session, operation, phase, obligation, box, enrollment), not its |
| +// controller identity: a restarted agent asks with a new boot and |
| +// controller id, and gets back the request the flush was made under, so |
| +// it can ask root for that stop's durable receipt. |
| +type ReleasePositions interface { |
| + Record(ctx context.Context, r StopRequest, txid uint64, seq int64) error |
| + Recorded(ctx context.Context, r StopRequest) (recorded StopRequest, txid uint64, seq int64, ok bool, err error) |
| +} |
| + |
| +// RelocationEffects is hostrelocate.Effects over providers Plan 6b's box |
| +// assembly supplies. It owns only the order of §5.8's release sequence and |
| +// the shape of each record; every provider acts under the host guard. |
| +type RelocationEffects struct { |
| + // Lifetime is the agent's context: controllers are opened under it, |
| + // never under a pass's. |
| + Lifetime context.Context |
| + Controllers *RelocationControllers |
| + Identity func(context.Context) (RelocationIdentity, error) |
| + // Controller is Plan 8's execution controller for one binding |
| + // (hostexec.Open with the assembly's CloseAdmission, CancelWork and |
| + // Cleanup). It writes pending state, checks root's update-begun marker, |
| + // and takes the execution lease (§5.10, claim D6). |
| + Controller func(context.Context, execctl.Binding) (RelocationController, error) |
| + // Producers is the admitted session's drain (ClaimWakeAssembly.Producers). |
| + Producers func(context.Context, hostclaim.Observed) (StopDrainer, error) |
| + // Collect asks root for full stop evidence (Plan 6b's CollectClaimStop |
| + // over the instance-control socket). |
| + Collect func(context.Context, StopRequest, StopDrainer) (StopResult, error) |
| + // StopWriters is release step 1's action (§5.8, A4-1): close admission, |
| + // settle spawn attempts, drain in-flight timer deliveries, stop the |
| + // host's process tree and wait for it to empty, keeping the replicator |
| + // and the files publisher open. Writers reports what it left. |
| + StopWriters func(context.Context, hostclaim.Observed) error |
| + // Writers reports the drained facts and the replicator's descriptors |
| + // for release step 1's observation; Observe makes it. The complete |
| + // descriptor scan reads every process's table, which the agent (uid |
| + // carlos) cannot: production's Observe asks root over the |
| + // instance-control socket (R3); an agent-side scan fails closed on |
| + // the first unreadable table. |
| + // |
| + // Root's scan (stopevidence.WritersStopped over /proc) must run in the |
| + // host PID namespace with CAP_SYS_PTRACE: kcmp(2) takes pids in the |
| + // caller's own namespace and needs ptrace read access to every task, |
| + // and without either the scan is ErrIncomplete. The agent and root |
| + // must also see the database path the same way in /proc (one mount |
| + // namespace view): the allow-list compares DBPaths and the |
| + // replicator's descriptors as /proc shows them to each reader, so a |
| + // different view is ErrForeignHolder. Either mismatch fails every |
| + // release closed, never open. |
| + Writers func(context.Context, hostclaim.Observed) (stopevidence.WritersInputs, error) |
| + Observe func(context.Context, stopevidence.WritersInputs) (stopevidence.WritersEvidence, error) |
| + // FlushDB is release step 2: final checkpoint and SyncAndWait, then the |
| + // stopped database's final TXID N, returned only once the newest |
| + // level-0 file's MaxTXID is N. A sync error is returned, never swallowed. |
| + FlushDB func(context.Context, hostclaim.Observed) (uint64, error) |
| + // FlushFiles is release step 3: publish every pending file, the wake |
| + // state and the env file into the session's generation, whatever |
| + // files_durability says (ReleaseFlushFiles), and return the manifest's |
| + // final sequence. |
| + FlushFiles func(context.Context, hostclaim.Observed) (int64, error) |
| + // Positions is the durable record of the flushed positions. |
| + Positions ReleasePositions |
| + // BuildP, Install and Admit are the target's (§5.8): moves' shared |
| + // install routine for P, the local install from P's files manifest, |
| + // and one cold probe admission followed by the probe. |
| + BuildP func(context.Context, hostclaim.Observed) error |
| + Install func(context.Context, hostclaim.Observed) (hostclaim.LocalInstall, error) |
| + // VerifyLocal reports whether the local install the receipt names is |
| + // complete on this box now: the files directory as P's prepared |
| + // manifest lists it, the env file and the wake state, under the |
| + // receipt's install_stamp (Task 8c's production body). |
| + VerifyLocal func(context.Context, hostclaim.Observed, hostclaim.LocalInstall) (bool, error) |
| + // Receipt reads this box's durable local install receipt for the |
| + // claim's host (Task 8c: the install journal written under its |
| + // install_stamp); false when there is none. |
| + Receipt func(context.Context, hostclaim.Observed) (hostclaim.LocalInstall, bool, error) |
| + Admit func(context.Context, hostclaim.Observed, string) (uint64, bool, error) |
| +} |
| + |
| +var _ hostrelocate.Effects = (*RelocationEffects)(nil) |
| + |
| +// Acknowledge opens this box's execution controller for the operation |
| +// under the agent's lifetime, retains it, and returns the lease it took. |
| +// A controller already open for the participation is reused. |
| +func (e *RelocationEffects) Acknowledge(ctx context.Context, obs hostclaim.Observed) (hostclaim.ExecutionAcknowledgement, error) { |
| + if e.Identity == nil || e.Controller == nil || e.Controllers == nil || e.Lifetime == nil || obs.Claim == nil || obs.Claim.Holder == nil { |
| + return hostclaim.ExecutionAcknowledgement{}, ErrClaimStopUnavailable |
| + } |
| + c := obs.Claim |
| + ctl, ok, err := e.Controllers.live(ctx, c.Host, c.Op.ID) |
| + if err != nil { |
| + return hostclaim.ExecutionAcknowledgement{}, err |
| + } |
| + if !ok { |
| + id, err := e.Identity(ctx) |
| + if err != nil { |
| + return hostclaim.ExecutionAcknowledgement{}, err |
| + } |
| + b := execctl.Binding{Box: id.Actor.Box, Enrollment: id.Actor.Enrollment, Host: c.Host, Incarnation: c.Holder.Incarnation, Op: c.Op.ID, BootID: id.BootID} |
| + // Journaled before the open: hostexec writes its durable record |
| + // inside Open, so a crash or a failed Open leaves a record only |
| + // the journal lets a restarted agent find and clear. |
| + if err := e.Controllers.record(c.Host, c.Op.ID, b); err != nil { |
| + return hostclaim.ExecutionAcknowledgement{}, err |
| + } |
| + // A predecessor this box has not swept yet still holds the host's |
| + // lifetime lock: retire it first, under this pass's context. |
| + if err := e.Controllers.handover(ctx, c.Host, c.Op.ID); err != nil { |
| + return hostclaim.ExecutionAcknowledgement{}, err |
| + } |
| + opened, err := openBounded(ctx, e.Lifetime, func(octx context.Context) (RelocationController, error) { return e.Controller(octx, b) }) |
| + if err != nil { |
| + return hostclaim.ExecutionAcknowledgement{}, err |
| + } |
| + if ctl, ok = e.Controllers.put(c.Host, c.Op.ID, b, opened); !ok { |
| + _ = opened.Close(ctx) // another pass opened one first |
| + } |
| + } |
| + st, err := ctl.Ready(ctx) |
| + if err != nil { |
| + return hostclaim.ExecutionAcknowledgement{}, err |
| + } |
| + return hostclaim.ExecutionAcknowledgement{Op: c.Op.ID, Enrollment: st.Enrollment, BootID: st.BootID, ControllerID: st.ControllerID, LeaseETag: st.LeaseETag, Expires: st.Expires}, nil |
| +} |
| + |
| +// openBounded opens a controller that lives under lifetime (claim D6) but |
| +// waits for the host's lifetime lock no longer than the pass's ctx: |
| +// hostexec.Open takes one context for both. If ctx ends first, the open is |
| +// cancelled (Open returns, releasing what it took); a controller that |
| +// opened in the same instant is closed, never kept with a cancelled life. |
| +func openBounded(ctx, lifetime context.Context, open func(context.Context) (RelocationController, error)) (RelocationController, error) { |
| + octx, cancel := context.WithCancel(lifetime) |
| + type result struct { |
| + c RelocationController |
| + err error |
| + } |
| + ch := make(chan result, 1) |
| + // hostexec.Open waits on its Cleanup hook without a context, so the |
| + // open runs apart and the pass waits for it no longer than ctx. |
| + go func() { |
| + c, err := open(octx) |
| + ch <- result{c, err} |
| + }() |
| + select { |
| + case res := <-ch: |
| + if res.err != nil { |
| + cancel() |
| + return nil, res.err |
| + } |
| + context.AfterFunc(lifetime, cancel) // octx ends only with lifetime |
| + return res.c, nil |
| + case <-ctx.Done(): |
| + cancel() |
| + go func() { |
| + if res := <-ch; res.c != nil { |
| + _ = res.c.Close(context.Background()) // opened too late: never kept |
| + } |
| + }() |
| + return nil, ctx.Err() |
| + } |
| +} |
| + |
| +// Ready checks the retained controller for the operation is active now. |
| +func (e *RelocationEffects) Ready(ctx context.Context, obs hostclaim.Observed) error { |
| + if e.Controllers == nil || obs.Claim == nil { |
| + return ErrClaimStopUnavailable |
| + } |
| + ctl, ok, err := e.Controllers.live(ctx, obs.Claim.Host, obs.Claim.Op.ID) |
| + if err != nil { |
| + return err |
| + } |
| + if !ok { |
| + return hostrelocate.ErrNoController // the participant reopens one (Acknowledge) |
| + } |
| + _, err = ctl.Ready(ctx) |
| + return err |
| +} |
| + |
| +// Release runs the release sequence inside root's stop collection: root |
| +// records its durable fence and asks the agent to drain; the drain does |
| +// steps 1 to 3 before it closes the producers and acknowledges (§5.8, |
| +// claim P6-D1). The receipt is step 4. The flushed positions are durable |
| +// before the producers close, so a receipt root replays later still |
| +// carries them. |
| +func (e *RelocationEffects) Release(ctx context.Context, obs hostclaim.Observed) (hostclaim.Release, error) { |
| + if e.Identity == nil || e.Producers == nil || e.Collect == nil || e.StopWriters == nil || e.Writers == nil || e.Observe == nil || e.FlushDB == nil || e.FlushFiles == nil || e.Positions == nil || obs.Claim == nil || obs.Claim.Holder == nil { |
| + return hostclaim.Release{}, ErrClaimStopUnavailable |
| + } |
| + id, err := e.Identity(ctx) |
| + if err != nil { |
| + return hostclaim.Release{}, err |
| + } |
| + c, h := obs.Claim, obs.Claim.Holder |
| + req := StopRequest{Region: id.Actor.Region, Host: c.Host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, WriterSession: h.Session, |
| + OperationID: c.Op.ID, Phase: c.Op.Phase, ObligationID: RelocationReleaseObligation, Box: id.Actor.Box, Enrollment: id.Actor.Enrollment, |
| + BootID: id.BootID, ControllerID: id.ControllerID, ExpectedClaimETag: obs.ETag, ExpectedDirectoryRevision: fmt.Sprint(id.Actor.Revision)} |
| + // A flush this box already made for this stop, by any controller of |
| + // this installation: after a restart the request is made under the |
| + // identity that flushed, so root finds that stop's durable receipt. |
| + recorded, txid, seq, flushed, err := e.Positions.Recorded(ctx, req) |
| + if err != nil { |
| + return hostclaim.Release{}, err |
| + } |
| + if flushed { |
| + // The stop is the recorded one: root's fence and its inventory |
| + // check name the directory revision the flush was made under, so |
| + // a retry keeps it even when the roster has moved on since |
| + // (round 8 finding 2), whether or not this is a recovery. |
| + req.ExpectedDirectoryRevision = recorded.ExpectedDirectoryRevision |
| + } |
| + var d StopDrainer = RecoveryReleaseDrain{} |
| + drain := &releaseDrain{e: e, obs: obs} |
| + if flushed && (recorded.BootID != req.BootID || recorded.ControllerID != req.ControllerID) { |
| + // The flushing process is gone with its producers: only root's |
| + // durable receipt for the recorded identity can finish this |
| + // release. The request names the recorded owner (boot, controller, |
| + // directory revision) and the drain refuses; the Collect provider |
| + // marks it a recovery request (6b's authenticated Recover path, |
| + // Task 8a), under which root accepts another tuple for this box's |
| + // session and replays the receipt. |
| + req.BootID, req.ControllerID = recorded.BootID, recorded.ControllerID |
| + } else { |
| + if drain.inner, err = e.Producers(ctx, obs); err != nil { |
| + return hostclaim.Release{}, err |
| + } |
| + if flushed { |
| + // This process already flushed and recorded this stop, and |
| + // root has no durable receipt yet (it failed after the drain |
| + // acknowledged, before CompleteFence: claimstop.go). Steps 1 |
| + // to 3 are done and the replicator may be closed: resume at |
| + // the producers' idempotent drain with the recorded positions, |
| + // and let root verify and persist the receipt. |
| + drain.resumed, drain.txid, drain.seq = true, txid, seq |
| + } |
| + d = drain |
| + } |
| + result, err := e.Collect(ctx, req, d) |
| + if err != nil { |
| + return hostclaim.Release{}, err |
| + } |
| + if drain.done { |
| + txid, seq = drain.txid, drain.seq |
| + } else if !flushed { |
| + // Root replayed a durable receipt and nothing recorded a flush for |
| + // this stop: never guess. |
| + return hostclaim.Release{}, fmt.Errorf("%w: a replayed receipt has no recorded flushed positions for this stop", ErrClaimStopReceipt) |
| + } |
| + return hostclaim.Release{ReleasedBy: id.Actor.Box, Enrollment: id.Actor.Enrollment, Generation: h.Generation, Session: h.Session, |
| + FinalTXID: txid, ManifestSeq: seq, Receipt: result.Key, ReceiptSHA256: result.Digest}, nil |
| +} |
| + |
| +// releaseDrain is the agent's drain for a release: step 1's action, its |
| +// observation, steps 2 and 3, the durable positions, and only then the |
| +// producers' own drain and acknowledgement. |
| +type releaseDrain struct { |
| + e *RelocationEffects |
| + obs hostclaim.Observed |
| + inner StopDrainer |
| + txid uint64 |
| + seq int64 |
| + done bool |
| + // resumed: this stop's positions are already durable (an earlier |
| + // attempt by this controller flushed and recorded them), so the |
| + // drain goes straight to the producers' own drain. |
| + resumed bool |
| +} |
| + |
| +// RecoveryReleaseDrain refuses: a restarted agent's producers are not the |
| +// stop's, and root must replay the durable receipt or nothing. The Collect |
| +// provider recognises it and submits the request as a recovery (6b). It is |
| +// main's recoveryStopDrain (claimwakestop.go), the one drain a recovered |
| +// stop is offered, under the name Task 8a's Collect matches. |
| +type RecoveryReleaseDrain = recoveryStopDrain |
| + |
| +func (d *releaseDrain) DrainClaim(ctx context.Context, r StopRequest, fence string) (DrainAcknowledgement, error) { |
| + if d.resumed { |
| + ack, err := d.inner.DrainClaim(ctx, r, fence) |
| + if err != nil { |
| + return DrainAcknowledgement{}, err |
| + } |
| + d.done = true |
| + return ack, nil |
| + } |
| + if err := d.e.StopWriters(ctx, d.obs); err != nil { |
| + return DrainAcknowledgement{}, err |
| + } |
| + in, err := d.e.Writers(ctx, d.obs) |
| + if err != nil { |
| + return DrainAcknowledgement{}, err |
| + } |
| + // Root's complete descriptor scan (see Writers): host PID namespace, |
| + // CAP_SYS_PTRACE, and the agent's view of DBPaths, or it refuses. |
| + if _, err := d.e.Observe(ctx, in); err != nil { |
| + return DrainAcknowledgement{}, err |
| + } |
| + if d.txid, err = d.e.FlushDB(ctx, d.obs); err != nil { |
| + return DrainAcknowledgement{}, err |
| + } |
| + if d.seq, err = d.e.FlushFiles(ctx, d.obs); err != nil { |
| + return DrainAcknowledgement{}, err |
| + } |
| + if err := d.e.Positions.Record(ctx, r, d.txid, d.seq); err != nil { |
| + return DrainAcknowledgement{}, err |
| + } |
| + ack, err := d.inner.DrainClaim(ctx, r, fence) |
| + if err != nil { |
| + return DrainAcknowledgement{}, err |
| + } |
| + d.done = true |
| + return ack, nil |
| +} |
| + |
| +func (e *RelocationEffects) Retained() ([]hostrelocate.Participation, error) { |
| + if e.Controllers == nil { |
| + return nil, nil |
| + } |
| + return e.Controllers.Retained() |
| +} |
| + |
| +func (e *RelocationEffects) Finish(ctx context.Context, host, op string) error { |
| + if e.Controllers == nil { |
| + return ErrClaimStopUnavailable |
| + } |
| + return e.Controllers.Finish(ctx, host, op) |
| +} |
| + |
| +func (e *RelocationEffects) Build(ctx context.Context, obs hostclaim.Observed) error { |
| + if e.BuildP == nil { |
| + return ErrClaimStopUnavailable |
| + } |
| + return e.BuildP(ctx, obs) |
| +} |
| + |
| +func (e *RelocationEffects) InstallLocal(ctx context.Context, obs hostclaim.Observed) (hostclaim.LocalInstall, error) { |
| + if e.Install == nil { |
| + return hostclaim.LocalInstall{}, ErrClaimStopUnavailable |
| + } |
| + return e.Install(ctx, obs) |
| +} |
| + |
| +// EnsureLocal is hostrelocate.Effects' check before P's first admission: |
| +// the journaled receipt's install is verified and, if a lost lease's scoped |
| +// cleanup retired it, restored by the same Install provider under the same |
| +// install_stamp, then verified again. An opened P is never reinstalled. |
| +func (e *RelocationEffects) EnsureLocal(ctx context.Context, obs hostclaim.Observed) error { |
| + if e.VerifyLocal == nil || e.Install == nil || obs.Claim == nil { |
| + return ErrClaimStopUnavailable |
| + } |
| + c := obs.Claim |
| + if hostclaim.PreparedOpened(c) { |
| + return nil |
| + } |
| + var l hostclaim.LocalInstall |
| + if err := json.Unmarshal([]byte(c.Op.Journal["local_install"]), &l); err != nil || l.InstallStamp == 0 { |
| + return fmt.Errorf("%w: no local install receipt to verify", ErrClaimStopReceipt) |
| + } |
| + if ok, err := e.VerifyLocal(ctx, obs, l); err != nil || ok { |
| + return err |
| + } |
| + got, err := e.Install(ctx, obs) |
| + if err != nil { |
| + return err |
| + } |
| + if got.Box != l.Box || got.Enrollment != l.Enrollment || got.InstallStamp != l.InstallStamp { |
| + return fmt.Errorf("%w: the restored install is not the receipt's", ErrClaimStopReceipt) |
| + } |
| + ok, err := e.VerifyLocal(ctx, obs, l) |
| + if err == nil && !ok { |
| + err = fmt.Errorf("%w: the restored install did not verify", ErrClaimStopReceipt) |
| + } |
| + return err |
| +} |
| + |
| +// VerifyReceipt is hostrelocate.Effects' check before every admission |
| +// after P is opened: the box's durable receipt must be the one the claim |
| +// journaled, exactly. It reads the receipt alone: the files, env and |
| +// timers are the admitted writers' now, and nothing is restored. |
| +func (e *RelocationEffects) VerifyReceipt(ctx context.Context, obs hostclaim.Observed) (bool, error) { |
| + if e.Receipt == nil || obs.Claim == nil { |
| + return false, ErrClaimStopUnavailable |
| + } |
| + var want hostclaim.LocalInstall |
| + if err := json.Unmarshal([]byte(obs.Claim.Op.Journal["local_install"]), &want); err != nil || want.InstallStamp == 0 { |
| + return false, nil // no journaled receipt to match: nothing vouches for the local state |
| + } |
| + got, ok, err := e.Receipt(ctx, obs) |
| + if err != nil { |
| + return false, err |
| + } |
| + return ok && got == want, nil |
| +} |
| + |
| +func (e *RelocationEffects) Probe(ctx context.Context, obs hostclaim.Observed, coverage string) (uint64, bool, error) { |
| + if e.Admit == nil { |
| + return 0, false, ErrClaimStopUnavailable |
| + } |
| + return e.Admit(ctx, obs, coverage) |
| +} |
| + |
| +// execController is hostexec.Controller as a RelocationController: Clear |
| +// is hostexec.ClearStale with the controller's own config, the durable |
| +// record's removal once the claim no longer runs the operation with this |
| +// box (CC9). It is the Controller provider's production value (Task 8a |
| +// fills the assembly's hooks into cfg). |
| +type execController struct { |
| + *execctl.Controller |
| + cfg execctl.Config |
| +} |
| + |
| +func (c *execController) Clear(ctx context.Context) error { return execctl.ClearStale(ctx, c.cfg) } |
| + |
| +func (c *execController) Lost() <-chan struct{} { return c.Controller.Lost() } |
| + |
| +// OpenRelocationController opens hostexec's controller for cfg and wraps |
| +// it with its durable clear. |
| +func OpenRelocationController(ctx context.Context, cfg execctl.Config) (RelocationController, error) { |
| + c, err := execctl.Open(ctx, cfg) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &execController{Controller: c, cfg: cfg}, nil // a pointer: the retained set compares controllers by identity |
| +} |
diff --git a/internal/host/relocationeffects_test.go b/internal/host/relocationeffects_test.go| new file mode 100644 |
| index 000000000..5408488f3 |
| --- /dev/null |
| +++ b/internal/host/relocationeffects_test.go |
| @@ -0,0 +1,726 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + execctl "github.com/carlosframework/platform/internal/hostexec" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| + "github.com/carlosframework/platform/internal/hostrelocate" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/stopevidence" |
| +) |
| + |
| +type emptyGroup struct{} |
| + |
| +func (emptyGroup) Populated() (bool, error) { return false, nil } |
| +func (emptyGroup) Members() ([]int, error) { return nil, nil } |
| + |
| +type recordingDrain struct{ calls *[]string } |
| + |
| +func (d recordingDrain) DrainClaim(context.Context, StopRequest, string) (DrainAcknowledgement, error) { |
| + *d.calls = append(*d.calls, "producers") |
| + return DrainAcknowledgement{FenceID: "fence"}, nil |
| +} |
| + |
| +// readyController is one opened controller: it reports the context it was |
| +// opened under, so a test can see it was the agent's, not a pass's. |
| +type readyController struct { |
| + st execctl.State |
| + opened context.Context |
| + closed bool |
| + cleared bool |
| + lost error |
| + clearErr error |
| + closeErr error |
| +} |
| + |
| +func (c *readyController) Clear(context.Context) error { |
| + if c.clearErr != nil { |
| + return c.clearErr |
| + } |
| + c.cleared = true |
| + return nil |
| +} |
| + |
| +func (c *readyController) Lost() <-chan struct{} { |
| + ch := make(chan struct{}) |
| + if c.lost != nil { |
| + close(ch) |
| + } |
| + return ch |
| +} |
| + |
| +func (c *readyController) Ready(context.Context) (execctl.State, error) { |
| + if c.lost != nil { |
| + return execctl.State{}, c.lost |
| + } |
| + return c.st, nil |
| +} |
| +func (c *readyController) Close(ctx context.Context) error { |
| + if err := ctx.Err(); err != nil { |
| + return err // hostexec retains the lifetime lock on a cut-short join |
| + } |
| + if c.closed { |
| + return nil // hostexec: every Close after the first returns nil |
| + } |
| + c.closed = true |
| + return c.closeErr // hostexec: a cleanup failure is returned once |
| +} |
| + |
| +// testControllers is a RelocationControllers with a journal on disk and a |
| +// scoped cleanup that succeeds. |
| +func testControllers(t *testing.T) *RelocationControllers { |
| + return &RelocationControllers{Journal: filepath.Join(t.TempDir(), "participations"), Cleanup: func(context.Context, execctl.Binding) error { return nil }} |
| +} |
| + |
| +// kept is Retained's list, for tests whose journal holds nothing damaged. |
| +func kept(r interface { |
| + Retained() ([]hostrelocate.Participation, error) |
| +}) []hostrelocate.Participation { |
| + got, _ := r.Retained() |
| + return got |
| +} |
| + |
| +type memPositions struct { |
| + calls *[]string |
| + req StopRequest |
| + txid uint64 |
| + seq int64 |
| + ok bool |
| +} |
| + |
| +func (m *memPositions) Record(_ context.Context, r StopRequest, txid uint64, seq int64) error { |
| + *m.calls = append(*m.calls, "record") |
| + m.req, m.txid, m.seq, m.ok = r, txid, seq, true |
| + return nil |
| +} |
| +func (m *memPositions) Recorded(_ context.Context, r StopRequest) (StopRequest, uint64, int64, bool, error) { |
| + if !m.ok || !sameStop(m.req, r) { |
| + return StopRequest{}, 0, 0, false, nil |
| + } |
| + return m.req, m.txid, m.seq, true, nil |
| +} |
| + |
| +func effectsFixture(t *testing.T) (*RelocationEffects, hostclaim.Observed, *[]string, *StopRequest) { |
| + t.Helper() |
| + calls := &[]string{} |
| + seen := &StopRequest{} |
| + proc := t.TempDir() // no process holds anything |
| + exp := time.Date(2026, 10, 7, 10, 0, 0, 0, time.UTC) |
| + e := &RelocationEffects{ |
| + Lifetime: context.Background(), |
| + Controllers: testControllers(t), |
| + Identity: func(context.Context) (RelocationIdentity, error) { |
| + return RelocationIdentity{Actor: hostbox.Actor{Box: "src", Enrollment: "enr", Revision: 4}, BootID: "boot", ControllerID: "ctl"}, nil |
| + }, |
| + Controller: func(ctx context.Context, b execctl.Binding) (RelocationController, error) { |
| + return &readyController{opened: ctx, st: execctl.State{Binding: execctl.Binding{Enrollment: "enr", BootID: "boot", ControllerID: "c9"}, LeaseETag: "lease-1", Expires: exp}}, nil |
| + }, |
| + Producers: func(context.Context, hostclaim.Observed) (StopDrainer, error) { return recordingDrain{calls}, nil }, |
| + StopWriters: func(context.Context, hostclaim.Observed) error { |
| + *calls = append(*calls, "stop-writers") |
| + return nil |
| + }, |
| + Writers: func(context.Context, hostclaim.Observed) (stopevidence.WritersInputs, error) { |
| + *calls = append(*calls, "writers") |
| + return stopevidence.WritersInputs{Host: "shop.example", FenceClosed: true, SpawnsSettled: true, WorkDrained: true, Cgroup: emptyGroup{}, |
| + DBPaths: []string{"/data/shop.db"}, ReplicatorPID: 9, ReplicatorStart: "1", ProcRoot: proc}, nil |
| + }, |
| + FlushDB: func(context.Context, hostclaim.Observed) (uint64, error) { |
| + *calls = append(*calls, "db") |
| + return 41, nil |
| + }, |
| + FlushFiles: func(context.Context, hostclaim.Observed) (int64, error) { |
| + *calls = append(*calls, "files") |
| + return 8, nil |
| + }, |
| + Positions: &memPositions{calls: calls}, |
| + Observe: stopevidence.WritersStopped, |
| + Collect: func(ctx context.Context, r StopRequest, d StopDrainer) (StopResult, error) { |
| + *seen = r |
| + if _, err := d.DrainClaim(ctx, r, "fence"); err != nil { |
| + return StopResult{}, err |
| + } |
| + *calls = append(*calls, "receipt") |
| + return StopResult{Key: "control/hosts/shop.example/evidence/rel-1/src/r.json", Digest: strings.Repeat("a", 64)}, nil |
| + }, |
| + } |
| + obs := hostclaim.Observed{Host: "shop.example", ETag: "etag-7", Claim: &hostclaim.Claim{Host: "shop.example", |
| + Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 5, Session: 6, Generation: "g6", WriterBox: "src"}, |
| + Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseReleasing, Exec: "src"}}} |
| + return e, obs, calls, seen |
| +} |
| + |
| +func TestRelocationReleaseRunsStepsOneToThreeInsideTheDrain(t *testing.T) { |
| + e, obs, calls, seen := effectsFixture(t) |
| + r, err := e.Release(context.Background(), obs) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := []string{"stop-writers", "writers", "db", "files", "record", "producers", "receipt"}; !slices.Equal(*calls, want) { |
| + t.Fatalf("order %v, want %v", *calls, want) |
| + } |
| + if r.FinalTXID != 41 || r.ManifestSeq != 8 || r.Session != 6 || r.Generation != "g6" || r.ReleasedBy != "src" || r.Receipt == "" { |
| + t.Fatalf("release: %+v", r) |
| + } |
| + if seen.ObligationID != RelocationReleaseObligation || seen.Phase != hostclaim.PhaseReleasing || seen.ExpectedClaimETag != "etag-7" || seen.ExpectedDirectoryRevision != "4" { |
| + t.Fatalf("stop request: %+v", *seen) |
| + } |
| +} |
| + |
| +func TestRelocationReleaseStopsAtTheFirstFailedStep(t *testing.T) { |
| + failure := errors.New("sync failed") |
| + for _, tc := range []struct { |
| + name string |
| + change func(*RelocationEffects) |
| + want []string |
| + }{ |
| + {"the writers cannot be stopped", func(e *RelocationEffects) { |
| + e.StopWriters = func(context.Context, hostclaim.Observed) error { return failure } |
| + }, nil}, |
| + {"writers still running", func(e *RelocationEffects) { |
| + w := e.Writers |
| + e.Writers = func(ctx context.Context, o hostclaim.Observed) (stopevidence.WritersInputs, error) { |
| + in, err := w(ctx, o) |
| + in.WorkDrained = false |
| + return in, err |
| + } |
| + }, []string{"stop-writers", "writers"}}, |
| + {"the database flush", func(e *RelocationEffects) { |
| + e.FlushDB = func(context.Context, hostclaim.Observed) (uint64, error) { return 0, failure } |
| + }, []string{"stop-writers", "writers"}}, |
| + {"the files flush", func(e *RelocationEffects) { |
| + e.FlushFiles = func(context.Context, hostclaim.Observed) (int64, error) { return 0, failure } |
| + }, []string{"stop-writers", "writers", "db"}}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + e, obs, calls, _ := effectsFixture(t) |
| + tc.change(e) |
| + if _, err := e.Release(context.Background(), obs); err == nil { |
| + t.Fatal("released") |
| + } |
| + if !slices.Equal(*calls, tc.want) { |
| + t.Fatalf("ran %v, want %v: nothing after a failed step", *calls, tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRelocationReleaseReplaysOnlyRecordedPositions(t *testing.T) { |
| + replayed := func(context.Context, StopRequest, StopDrainer) (StopResult, error) { |
| + return StopResult{Key: "k", Digest: strings.Repeat("a", 64)}, nil // root replayed; no drain ran here |
| + } |
| + t.Run("no record", func(t *testing.T) { |
| + e, obs, _, _ := effectsFixture(t) |
| + e.Collect = replayed |
| + if _, err := e.Release(context.Background(), obs); !errors.Is(err, ErrClaimStopReceipt) { |
| + t.Fatalf("got %v", err) |
| + } |
| + }) |
| + t.Run("the lost response", func(t *testing.T) { |
| + // The first call flushed and recorded; root completed its receipt; |
| + // the response was lost. The retry pairs the replayed receipt with |
| + // the recorded positions. |
| + e, obs, _, _ := effectsFixture(t) |
| + collect := e.Collect |
| + e.Collect = func(ctx context.Context, r StopRequest, d StopDrainer) (StopResult, error) { |
| + _, err := collect(ctx, r, d) |
| + return StopResult{}, errors.Join(err, errors.New("connection reset")) |
| + } |
| + if _, err := e.Release(context.Background(), obs); err == nil { |
| + t.Fatal("the lost response released") |
| + } |
| + e.Collect = replayed |
| + r, err := e.Release(context.Background(), obs) |
| + if err != nil || r.FinalTXID != 41 || r.ManifestSeq != 8 { |
| + t.Fatalf("replay: %+v %v", r, err) |
| + } |
| + }) |
| + t.Run("root fails after the producers closed", func(t *testing.T) { |
| + // Round 7: the drain flushed, recorded and closed the producers; |
| + // root then failed before its receipt was durable (kernel |
| + // verification or CompleteFence). The retry, same controller, no |
| + // restart, resumes at the producers' drain with the recorded |
| + // positions: no step of the release runs again. |
| + e, obs, calls, seen := effectsFixture(t) |
| + collect := e.Collect |
| + e.Collect = func(ctx context.Context, r StopRequest, d StopDrainer) (StopResult, error) { |
| + if _, err := d.DrainClaim(ctx, r, "fence"); err != nil { |
| + return StopResult{}, err |
| + } |
| + return StopResult{}, errors.New("verify stopped: cgroup not yet empty") // no receipt persisted |
| + } |
| + if _, err := e.Release(context.Background(), obs); err == nil { |
| + t.Fatal("a release without a receipt succeeded") |
| + } |
| + *calls = nil |
| + e.Collect = collect |
| + // Round 8 finding 2: the roster moved on between the attempts |
| + // (same boot, same controller); the retry still names the |
| + // revision the stop was flushed under. |
| + e.Identity = func(context.Context) (RelocationIdentity, error) { |
| + return RelocationIdentity{Actor: hostbox.Actor{Box: "src", Enrollment: "enr", Revision: 5}, BootID: "boot", ControllerID: "ctl"}, nil |
| + } |
| + r, err := e.Release(context.Background(), obs) |
| + if err != nil || r.FinalTXID != 41 || r.ManifestSeq != 8 { |
| + t.Fatalf("retry: %+v %v", r, err) |
| + } |
| + if want := []string{"producers", "receipt"}; !slices.Equal(*calls, want) { |
| + t.Fatalf("the retry ran %v, want %v (no flush, no record, no writers stop again)", *calls, want) |
| + } |
| + if seen.ExpectedDirectoryRevision != "4" || seen.BootID != "boot" || seen.ControllerID != "ctl" { |
| + t.Fatalf("the retry asked root under another stop: %+v", *seen) |
| + } |
| + }) |
| + t.Run("after a restart the recorded identity asks root", func(t *testing.T) { |
| + // The agent flushed and recorded under controller "ctl", root |
| + // completed its receipt, the agent restarted. The new process asks |
| + // root under the recorded boot and controller (so root finds that |
| + // stop's durable receipt), offers no drain of its own, and pairs |
| + // the replayed receipt with the recorded positions. |
| + e, obs, _, _ := effectsFixture(t) |
| + if _, err := e.Release(context.Background(), obs); err != nil { |
| + t.Fatal(err) |
| + } |
| + e.Identity = func(context.Context) (RelocationIdentity, error) { |
| + return RelocationIdentity{Actor: hostbox.Actor{Box: "src", Enrollment: "enr", Revision: 5}, BootID: "boot-2", ControllerID: "ctl-2"}, nil |
| + } |
| + var asked StopRequest |
| + recovery := false |
| + e.Collect = func(_ context.Context, r StopRequest, d StopDrainer) (StopResult, error) { |
| + asked = r |
| + _, recovery = d.(RecoveryReleaseDrain) // Task 8a sets req.Recover from this |
| + if _, err := d.DrainClaim(context.Background(), r, "fence"); !errors.Is(err, ErrClaimStopIdentity) { |
| + return StopResult{}, errors.New("a restarted agent offered a drain for another process's stop") |
| + } |
| + return StopResult{Key: "k", Digest: strings.Repeat("a", 64)}, nil |
| + } |
| + r, err := e.Release(context.Background(), obs) |
| + if err != nil || r.FinalTXID != 41 || r.ManifestSeq != 8 { |
| + t.Fatalf("after restart: %+v %v", r, err) |
| + } |
| + if asked.BootID != "boot" || asked.ControllerID != "ctl" || asked.ExpectedDirectoryRevision != "4" || !recovery { |
| + t.Fatalf("root was not asked as the recorded owner's recovery: %+v recovery=%v", asked, recovery) |
| + } |
| + }) |
| + t.Run("a record of another session", func(t *testing.T) { |
| + e, obs, _, _ := effectsFixture(t) |
| + if _, err := e.Release(context.Background(), obs); err != nil { |
| + t.Fatal(err) |
| + } |
| + e.Collect = replayed |
| + obs.Claim.Holder.Session = 7 |
| + if _, err := e.Release(context.Background(), obs); !errors.Is(err, ErrClaimStopReceipt) { |
| + t.Fatalf("another session's positions were used: %v", err) |
| + } |
| + }) |
| +} |
| + |
| +func TestReleasePositionsFileBindsTheExactStop(t *testing.T) { |
| + ctx := context.Background() |
| + f := ReleasePositionsFile{Dir: filepath.Join(t.TempDir(), "relocate")} |
| + r := StopRequest{Host: "shop.example", Incarnation: "inc", InstallationStamp: 5, WriterSession: 6, OperationID: "rel-1", Phase: "releasing", ObligationID: RelocationReleaseObligation, |
| + Box: "src", Enrollment: "enr", BootID: "boot", ControllerID: "ctl", ExpectedClaimETag: "e1", ExpectedDirectoryRevision: "4"} |
| + if _, _, _, ok, err := f.Recorded(ctx, r); ok || err != nil { |
| + t.Fatalf("before any record: %v %v", ok, err) |
| + } |
| + if err := f.Record(ctx, r, 41, 8); err != nil { |
| + t.Fatal(err) |
| + } |
| + moved := r |
| + moved.ExpectedClaimETag, moved.ExpectedDirectoryRevision = "e2", "5" // a target replacement moved the claim |
| + if rec, txid, seq, ok, err := f.Recorded(ctx, moved); !ok || err != nil || txid != 41 || seq != 8 || rec.ControllerID != "ctl" { |
| + t.Fatalf("recorded: %d %d %v %v", txid, seq, ok, err) |
| + } |
| + // A restarted controller of the same installation finds the record, |
| + // and gets the identity the flush was made under. |
| + restarted := r |
| + restarted.BootID, restarted.ControllerID = "boot-2", "ctl-2" |
| + if rec, _, _, ok, err := f.Recorded(ctx, restarted); !ok || err != nil || rec.BootID != "boot" || rec.ControllerID != "ctl" { |
| + t.Fatalf("after a restart: %+v %v %v", rec, ok, err) |
| + } |
| + for name, change := range map[string]func(*StopRequest){ |
| + "session": func(r *StopRequest) { r.WriterSession = 7 }, |
| + "stamp": func(r *StopRequest) { r.InstallationStamp = 6 }, |
| + "operation": func(r *StopRequest) { r.OperationID = "rel-2" }, |
| + "phase": func(r *StopRequest) { r.Phase = "probe-failed" }, |
| + "box": func(r *StopRequest) { r.Box = "dst" }, |
| + } { |
| + other := r |
| + change(&other) |
| + if _, _, _, ok, _ := f.Recorded(ctx, other); ok { |
| + t.Fatalf("%s: another stop's positions were returned", name) |
| + } |
| + } |
| + if err := os.WriteFile(filepath.Join(f.Dir, "shop.example.json"), []byte("{"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, _, _, err := f.Recorded(ctx, r); !errors.Is(err, ErrClaimStopReceipt) { |
| + t.Fatalf("a damaged record: %v", err) |
| + } |
| +} |
| + |
| +func TestRelocationAcknowledgeRetainsTheControllerUnderTheAgentLifetime(t *testing.T) { |
| + e, obs, _, _ := effectsFixture(t) |
| + var opened []*readyController |
| + exp := time.Date(2026, 10, 7, 10, 0, 0, 0, time.UTC) |
| + var bound execctl.Binding |
| + e.Controller = func(ctx context.Context, b execctl.Binding) (RelocationController, error) { |
| + bound = b |
| + c := &readyController{opened: ctx, st: execctl.State{Binding: execctl.Binding{Enrollment: "enr", BootID: "boot", ControllerID: "c9"}, LeaseETag: "lease-1", Expires: exp}} |
| + opened = append(opened, c) |
| + return c, nil |
| + } |
| + pass, cancel := context.WithCancel(context.Background()) |
| + ack, err := e.Acknowledge(pass, obs) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cancel() // the pass ends; the controller must not |
| + if bound.Op != "rel-1" || bound.Host != "shop.example" || bound.Incarnation != "inc" || ack.LeaseETag != "lease-1" || ack.ControllerID != "c9" || !ack.Expires.Equal(exp) { |
| + t.Fatalf("binding %+v ack %+v", bound, ack) |
| + } |
| + if opened[0].opened.Err() != nil { |
| + t.Fatal("the controller was opened under the pass's context") |
| + } |
| + // A second pass reuses the retained controller; Ready sees it. |
| + if _, err := e.Acknowledge(context.Background(), obs); err != nil || len(opened) != 1 { |
| + t.Fatalf("second pass: %v opened=%d", err, len(opened)) |
| + } |
| + if err := e.Ready(context.Background(), obs); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A lost lease is recovered without a restart (round 13 finding 1): |
| + // Ready joins the lost controller and repeats the scoped cleanup, |
| + // reports no controller, and the participant's Acknowledge opens a |
| + // fresh one; a cleanup that fails keeps the lost one for the next pass. |
| + opened[0].lost = execctl.ErrLost |
| + cleanupErr := errors.New("cleanup: a process would not stop") |
| + e.Controllers.Cleanup = func(context.Context, execctl.Binding) error { return cleanupErr } |
| + if err := e.Ready(context.Background(), obs); !errors.Is(err, cleanupErr) || !opened[0].closed { |
| + t.Fatalf("lost controller, cleanup failing: %v closed=%v", err, opened[0].closed) |
| + } |
| + e.Controllers.Cleanup = func(context.Context, execctl.Binding) error { return nil } |
| + if err := e.Ready(context.Background(), obs); !errors.Is(err, hostrelocate.ErrNoController) { |
| + t.Fatalf("lost controller: %v", err) |
| + } |
| + if _, err := e.Acknowledge(context.Background(), obs); err != nil || len(opened) != 2 { |
| + t.Fatalf("recovery: %v opened=%d", err, len(opened)) |
| + } |
| + if err := e.Ready(context.Background(), obs); err != nil { |
| + t.Fatalf("after recovery: %v", err) |
| + } |
| + // An agent restart has no retained controller: Ready says so, and |
| + // Acknowledge opens a fresh one. |
| + restarted := *e |
| + restarted.Controllers = testControllers(t) |
| + if err := restarted.Ready(context.Background(), obs); !errors.Is(err, hostrelocate.ErrNoController) { |
| + t.Fatalf("after a restart: %v", err) |
| + } |
| + if _, err := restarted.Acknowledge(context.Background(), obs); err != nil || len(opened) != 3 { |
| + t.Fatalf("reopen: %v opened=%d", err, len(opened)) |
| + } |
| + // Finishing a participation closes its controller and forgets it; a |
| + // later Acknowledge for the same host opens a new one. |
| + if got := kept(&restarted); len(got) != 1 || got[0] != (hostrelocate.Participation{Host: "shop.example", Op: "rel-1"}) { |
| + t.Fatalf("retained: %+v", got) |
| + } |
| + // A close cut short by the pass's context keeps the entry, so a later |
| + // sweep can finish it; then it is forgotten. |
| + cut, cancel := context.WithCancel(context.Background()) |
| + cancel() |
| + if err := restarted.Finish(cut, "shop.example", "rel-1"); err == nil || opened[2].closed || len(kept(&restarted)) != 1 { |
| + t.Fatalf("a cut-short close forgot the controller: %v closed=%v retained=%v", err, opened[2].closed, kept(&restarted)) |
| + } |
| + if err := restarted.Finish(context.Background(), "shop.example", "rel-1"); err != nil || !opened[2].closed || !opened[2].cleared || len(kept(&restarted)) != 0 { |
| + t.Fatalf("finish: %v closed=%v cleared=%v retained=%v", err, opened[2].closed, opened[2].cleared, kept(&restarted)) |
| + } |
| + if err := restarted.Ready(context.Background(), obs); !errors.Is(err, hostrelocate.ErrNoController) { |
| + t.Fatalf("after finish: %v", err) |
| + } |
| + obs.Claim.Op.ID = "rel-2" |
| + if _, err := restarted.Acknowledge(context.Background(), obs); err != nil || len(opened) != 4 { |
| + t.Fatalf("the next relocation: %v opened=%d", err, len(opened)) |
| + } |
| + if err := restarted.Controllers.Close(context.Background()); err != nil || !opened[3].closed { |
| + t.Fatalf("close: %v closed=%v", err, opened[3].closed) |
| + } |
| +} |
| + |
| +func TestReleaseFlushFilesCarriesTheLastWakeStateAndEnv(t *testing.T) { |
| + ctx := context.Background() |
| + data := release.NewDirStore(t.TempDir()) |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + const host = "shop.example" |
| + if err := reg.Upsert(registry.Route{Host: host, Kind: "service", Addr: "10.0.0.1:3000", Status: "ready", Hibernate: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + dir, envDir := t.TempDir(), t.TempDir() |
| + if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(envDir, host+".env"), []byte("KEY=v\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + f := ReleaseFlushFiles{Data: data, Registry: reg, FilesDir: func(string) string { return dir }, EnvPath: func(h string) string { return filepath.Join(envDir, h+".env") }} |
| + obs := hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Host: host, Holder: &hostclaim.Holder{Location: "instances/" + host + "/inc", Generation: "g6"}}} |
| + first, err := f.Flush(ctx, obs) |
| + if err != nil || first == 0 { |
| + t.Fatalf("first flush: %d %v", first, err) |
| + } |
| + // A timer registered immediately before the release, and a new file. |
| + if err := reg.ReplaceTimers(host, []registry.Timer{{Host: host, Name: "late", Kind: "schedule", Spec: "0 3 * * *", Path: "/n", NextAt: 5000, CreatedAt: 10, DueAt: 5000, Registration: "reg-late"}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetNextDue(host, 7777); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, "b.txt"), []byte("later"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + seq, err := f.Flush(ctx, obs) |
| + if err != nil || seq <= first { |
| + t.Fatalf("second flush: %d %v", seq, err) |
| + } |
| + prefix := "instances/" + host + "/inc/files/g6/" |
| + m, ok, err := hostfiles.Load(ctx, data, prefix) |
| + if err != nil || !ok || m.Seq != seq { |
| + t.Fatalf("manifest: %+v %v %v", m, ok, err) |
| + } |
| + if _, ok := m.Entries["b.txt"]; !ok { |
| + t.Fatal("the file written before the release is not in the manifest") |
| + } |
| + ws, ok, err := hostfiles.LoadWake(ctx, data, prefix, m, nil) |
| + if err != nil || !ok || len(ws.Timers) != 1 || ws.Timers[0].Name != "late" || ws.NextDue != 7777 { |
| + t.Fatalf("wake state: %+v %v %v", ws, ok, err) |
| + } |
| + env, err := hostfiles.ReadPlatform(ctx, data, prefix, m, hostfiles.EnvItem, nil) |
| + if err != nil || string(env) != "KEY=v\n" { |
| + t.Fatalf("env: %q %v", env, err) |
| + } |
| + // Nothing changed: the same sequence, still the final state. |
| + again, err := f.Flush(ctx, obs) |
| + if err != nil || again != seq { |
| + t.Fatalf("repeat: %d %v", again, err) |
| + } |
| + // The env file is removed before a later release: the flush stages its |
| + // deletion, so the final state carries no env, not the old KEY=v. |
| + if err := os.Remove(filepath.Join(envDir, host+".env")); err != nil { |
| + t.Fatal(err) |
| + } |
| + gone, err := f.Flush(ctx, obs) |
| + if err != nil || gone <= seq { |
| + t.Fatalf("flush after the env file was removed: %d %v", gone, err) |
| + } |
| + m, ok, err = hostfiles.Load(ctx, data, prefix) |
| + if err != nil || !ok || m.Seq != gone { |
| + t.Fatalf("manifest after removal: %+v %v %v", m, ok, err) |
| + } |
| + if env, err := hostfiles.ReadPlatform(ctx, data, prefix, m, hostfiles.EnvItem, nil); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("a removed env file was still published: %q %v", env, err) |
| + } |
| + if again, err := f.Flush(ctx, obs); err != nil || again != gone { |
| + t.Fatalf("repeat after removal: %d %v", again, err) |
| + } |
| + var raw map[string]any |
| + body, _, _ := data.Get(ctx, hostfiles.ManifestKey(prefix)) |
| + _ = json.Unmarshal(body, &raw) |
| + if raw["v"] != float64(hostfiles.ManifestVersion) { |
| + t.Fatalf("manifest version: %v", raw["v"]) |
| + } |
| +} |
| + |
| +func TestFinishClearsTheDurableRecordAndRetriesAcrossARestart(t *testing.T) { |
| + journal := filepath.Join(t.TempDir(), "participations") |
| + e, obs, _, _ := effectsFixture(t) |
| + cleanups := 0 |
| + var cleanupErr error |
| + cleanup := func(_ context.Context, b execctl.Binding) error { |
| + if b.Op != "rel-1" || b.Box != "src" || b.Host != "shop.example" || b.Incarnation != "inc" { |
| + t.Fatalf("cleanup for another binding: %+v", b) |
| + } |
| + cleanups++ |
| + return cleanupErr |
| + } |
| + e.Controllers = &RelocationControllers{Journal: journal, Cleanup: cleanup} |
| + var ctl *readyController |
| + exp := time.Date(2026, 10, 7, 10, 0, 0, 0, time.UTC) |
| + e.Controller = func(ctx context.Context, b execctl.Binding) (RelocationController, error) { |
| + ctl = &readyController{opened: ctx, st: execctl.State{Binding: b, LeaseETag: "lease-1", Expires: exp}} |
| + return ctl, nil |
| + } |
| + // Without a journal nothing opens: a record no restart could find. |
| + unjournaled := *e |
| + unjournaled.Controllers = &RelocationControllers{Cleanup: cleanup} |
| + if _, err := unjournaled.Acknowledge(context.Background(), obs); !errors.Is(err, ErrClaimStopUnavailable) || ctl != nil { |
| + t.Fatalf("a controller opened without a journal: %v", err) |
| + } |
| + if _, err := e.Acknowledge(context.Background(), obs); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The controller's cleanup fails. hostexec's Close reports it once. |
| + ctl.closeErr = errors.New("cleanup: a process would not stop") |
| + cleanupErr = ctl.closeErr |
| + if err := e.Finish(context.Background(), "shop.example", "rel-1"); err == nil || !ctl.closed || ctl.cleared || len(kept(e)) != 1 { |
| + t.Fatalf("a failed cleanup: %v closed=%v cleared=%v retained=%v", err, ctl.closed, ctl.cleared, kept(e)) |
| + } |
| + // The next sweep: Close now returns nil, which proves nothing. The |
| + // repeated cleanup still fails, so the record stays. |
| + if err := e.Finish(context.Background(), "shop.example", "rel-1"); err == nil || ctl.cleared || len(kept(e)) != 1 { |
| + t.Fatalf("a nil Close after a failed cleanup cleared the record: %v cleared=%v retained=%v", err, ctl.cleared, kept(e)) |
| + } |
| + // The clear itself fails: the entry and the journal stay. |
| + cleanupErr = nil |
| + ctl.clearErr = errors.New("claim read failed") |
| + if err := e.Finish(context.Background(), "shop.example", "rel-1"); err == nil || ctl.cleared || len(kept(e)) != 1 { |
| + t.Fatalf("a failed clear: %v cleared=%v retained=%v", err, ctl.cleared, kept(e)) |
| + } |
| + // The agent restarts: no controller object, the journal remains. The |
| + // cleanup is repeated first; while it fails nothing is cleared. |
| + cleared := []execctl.Binding{} |
| + clear := func(_ context.Context, b execctl.Binding) error { cleared = append(cleared, b); return nil } |
| + again := &RelocationControllers{Journal: journal, Cleanup: cleanup, Clear: clear} |
| + if got := kept(again); len(got) != 1 || got[0].Op != "rel-1" { |
| + t.Fatalf("after a restart the journaled participation is forgotten: %+v", got) |
| + } |
| + cleanupErr = errors.New("cleanup: still failing") |
| + if err := again.Finish(context.Background(), "shop.example", "rel-1"); err == nil || len(cleared) != 0 || len(kept(again)) != 1 { |
| + t.Fatalf("a restart cleared without a successful cleanup: %v cleared=%+v", err, cleared) |
| + } |
| + cleanupErr = nil |
| + before := cleanups |
| + if err := again.Finish(context.Background(), "shop.example", "rel-1"); err != nil || cleanups != before+1 || len(cleared) != 1 || cleared[0].Op != "rel-1" || cleared[0].Box != "src" { |
| + t.Fatalf("retry after restart: %v cleanups=%d cleared=%+v", err, cleanups-before, cleared) |
| + } |
| + if got := kept(again); len(got) != 0 { |
| + t.Fatalf("the journal entry outlived the clear: %+v", got) |
| + } |
| +} |
| + |
| +// A crash before the first Finish (or a failed Open after hostexec wrote |
| +// its record) leaves no controller and no finishing state, only the |
| +// participation journaled before the open: a restart finds it, and an |
| +// uncommitted, truncated journal write beside it hides nothing. |
| +func TestARestartFindsAParticipationThatNeverReachedFinish(t *testing.T) { |
| + journal := filepath.Join(t.TempDir(), "participations") |
| + e, obs, _, _ := effectsFixture(t) |
| + e.Controllers = &RelocationControllers{Journal: journal, Cleanup: func(context.Context, execctl.Binding) error { return nil }} |
| + if _, err := e.Acknowledge(context.Background(), obs); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The crash: the controller object is gone. Another participation's |
| + // journal write was cut short, leaving a truncated temporary file. |
| + if err := os.WriteFile(filepath.Join(journal, "spa.example~rel-9.json.tmp"), []byte(`{"v":1,"ho`), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + cleaned, cleared := []execctl.Binding{}, []execctl.Binding{} |
| + again := &RelocationControllers{Journal: journal, |
| + Cleanup: func(_ context.Context, b execctl.Binding) error { cleaned = append(cleaned, b); return nil }, |
| + Clear: func(_ context.Context, b execctl.Binding) error { cleared = append(cleared, b); return nil }} |
| + got, err := again.Retained() |
| + if err != nil || len(got) != 1 || got[0] != (hostrelocate.Participation{Host: "shop.example", Op: "rel-1"}) { |
| + t.Fatalf("restart with a truncated temporary file: %+v %v", got, err) |
| + } |
| + // A committed entry that cannot be read is reported, never hiding the |
| + // readable one. |
| + if err := os.WriteFile(filepath.Join(journal, "bad.example~rel-3.json"), []byte("{"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err = again.Retained() |
| + if !errors.Is(err, ErrClaimStopUnavailable) || len(got) != 1 || got[0].Host != "shop.example" { |
| + t.Fatalf("a damaged committed entry: %+v %v", got, err) |
| + } |
| + if err := again.Finish(context.Background(), "shop.example", "rel-1"); err != nil || len(cleaned) != 1 || len(cleared) != 1 || cleared[0].Host != "shop.example" || cleared[0].Incarnation != "inc" { |
| + t.Fatalf("finish after the crash: %v cleaned=%+v cleared=%+v", err, cleaned, cleared) |
| + } |
| + if got, _ := again.Retained(); len(got) != 0 { |
| + t.Fatalf("the participation outlived its clear: %+v", got) |
| + } |
| +} |
| + |
| +// Round 14 finding 2: EnsureLocal verifies the receipt's install, restores |
| +// a retired one under the same stamp and verifies it again, refuses a |
| +// restore that is not the receipt's, and never touches an opened P. |
| +func TestEnsureLocalRestoresARetiredInstallOnlyBeforePOpens(t *testing.T) { |
| + ctx := context.Background() |
| + e, obs, _, _ := effectsFixture(t) |
| + receipt := hostclaim.LocalInstall{Box: "dst", Enrollment: "enr-d", InstallStamp: 9, FilesManifestVersion: 1, JournalSHA256: strings.Repeat("e", 64)} |
| + raw, _ := json.Marshal(receipt) |
| + obs.Claim.Op.Phase = hostclaim.PhaseProbing |
| + obs.Claim.Op.Journal = map[string]string{"local_install": string(raw), "prepared": "P1", "install_stamp": "9"} |
| + present, installs := true, 0 |
| + e.VerifyLocal = func(context.Context, hostclaim.Observed, hostclaim.LocalInstall) (bool, error) { return present, nil } |
| + restored := receipt |
| + e.Install = func(context.Context, hostclaim.Observed) (hostclaim.LocalInstall, error) { |
| + installs++ |
| + present = true |
| + return restored, nil |
| + } |
| + if err := e.EnsureLocal(ctx, obs); err != nil || installs != 0 { |
| + t.Fatalf("a complete install: %v installs=%d", err, installs) |
| + } |
| + present = false |
| + if err := e.EnsureLocal(ctx, obs); err != nil || installs != 1 || !present { |
| + t.Fatalf("a retired install: %v installs=%d", err, installs) |
| + } |
| + present, restored.InstallStamp = false, 10 |
| + if err := e.EnsureLocal(ctx, obs); !errors.Is(err, ErrClaimStopReceipt) { |
| + t.Fatalf("a restore under another stamp: %v", err) |
| + } |
| + present, installs = false, 0 |
| + obs.Claim.Op.Journal["prepared_opened"] = "P1" |
| + if err := e.EnsureLocal(ctx, obs); err != nil || installs != 0 { |
| + t.Fatalf("an opened P was reinstalled: %v installs=%d", err, installs) |
| + } |
| +} |
| + |
| +// Round 15 finding 1: after P opens, VerifyReceipt compares the box's |
| +// durable receipt with the journaled one and nothing else: a missing or |
| +// different receipt is false (the participant marks the operation stuck); |
| +// changed files never trigger a restore. |
| +func TestVerifyReceiptChecksTheReceiptOnly(t *testing.T) { |
| + ctx := context.Background() |
| + e, obs, _, _ := effectsFixture(t) |
| + receipt := hostclaim.LocalInstall{Box: "dst", Enrollment: "enr-d", InstallStamp: 9, FilesManifestVersion: 1, JournalSHA256: strings.Repeat("e", 64)} |
| + raw, _ := json.Marshal(receipt) |
| + obs.Claim.Op.Journal = map[string]string{"local_install": string(raw), "prepared": "P1", "prepared_opened": "P1", "install_stamp": "9"} |
| + stored, present := receipt, true |
| + e.Receipt = func(context.Context, hostclaim.Observed) (hostclaim.LocalInstall, bool, error) { |
| + return stored, present, nil |
| + } |
| + touched := 0 |
| + e.VerifyLocal = func(context.Context, hostclaim.Observed, hostclaim.LocalInstall) (bool, error) { |
| + touched++ |
| + return false, nil |
| + } |
| + e.Install = func(context.Context, hostclaim.Observed) (hostclaim.LocalInstall, error) { |
| + touched++ |
| + return receipt, nil |
| + } |
| + if ok, err := e.VerifyReceipt(ctx, obs); !ok || err != nil || touched != 0 { |
| + t.Fatalf("an intact receipt over changed files: %v %v touched=%d", ok, err, touched) |
| + } |
| + present = false |
| + if ok, err := e.VerifyReceipt(ctx, obs); ok || err != nil { |
| + t.Fatalf("a missing receipt: %v %v", ok, err) |
| + } |
| + present, stored.InstallStamp = true, 8 |
| + if ok, err := e.VerifyReceipt(ctx, obs); ok || err != nil { |
| + t.Fatalf("another installation's receipt: %v %v", ok, err) |
| + } |
| +} |
diff --git a/internal/host/relocationflush.go b/internal/host/relocationflush.go| new file mode 100644 |
| index 000000000..fe7770d44 |
| --- /dev/null |
| +++ b/internal/host/relocationflush.go |
| @@ -0,0 +1,179 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ReleaseFlushFiles is release step 3 (§5.8, §5.6 "Release flush"): one |
| +// complete publish of the host's files directory, its wake state (timer |
| +// rows, NextDue, Replication) and its env file into the session's |
| +// generation, as one manifest revision, whatever the app's |
| +// files_durability. hostfiles.Publish leaves platform items out, so this |
| +// is the Open, StagePlatform, SyncDir, Commit sequence the contracts note |
| +// §2 names. It runs after StopWriters, so nothing changes under it. |
| +type ReleaseFlushFiles struct { |
| + Data release.Store |
| + Registry *registry.Registry |
| + FilesDir func(host string) string |
| + EnvPath func(host string) string |
| + // Skip is the files publisher's own exclusion (nil publishes every |
| + // regular file). |
| + Skip func(rel string, isDir bool) bool |
| +} |
| + |
| +// Flush publishes and returns the committed manifest sequence. The env |
| +// file is staged when it exists; when it does not, its deletion is staged |
| +// (StagePlatformDelete), so a host whose env file was removed after an |
| +// earlier flush publishes none rather than the old bytes. A |
| +// manifest revision is committed even when nothing changed (Commit |
| +// returns the current sequence), so the release's manifest_seq is always |
| +// the sequence that holds this box's final state. |
| +func (f ReleaseFlushFiles) Flush(ctx context.Context, obs hostclaim.Observed) (int64, error) { |
| + if f.Data == nil || f.Registry == nil || f.FilesDir == nil || f.EnvPath == nil || obs.Claim == nil || obs.Claim.Holder == nil { |
| + return 0, ErrClaimStopUnavailable |
| + } |
| + h := obs.Claim.Holder |
| + if h.Generation == "" || h.Location == "" || !controlname.ValidHostShape(obs.Host) { |
| + return 0, ErrClaimStopUnavailable |
| + } |
| + p, err := hostfiles.Open(ctx, f.Data, h.Location+"/files/"+h.Generation+"/") |
| + if err != nil { |
| + return 0, err |
| + } |
| + ws, err := hostfiles.ExportWake(f.Registry, obs.Host) |
| + if err != nil { |
| + return 0, err |
| + } |
| + if err := hostfiles.StageWake(ctx, p, ws); err != nil { |
| + return 0, err |
| + } |
| + env, err := os.ReadFile(f.EnvPath(obs.Host)) |
| + switch { |
| + case err == nil: |
| + if err := p.StagePlatform(ctx, hostfiles.EnvItem, env); err != nil { |
| + return 0, err |
| + } |
| + case errors.Is(err, os.ErrNotExist): |
| + // The env file is gone: the release's final state has none, so a |
| + // previously published one is deleted, never carried forward. |
| + if err := p.StagePlatformDelete(hostfiles.EnvItem); err != nil { |
| + return 0, err |
| + } |
| + default: |
| + return 0, err |
| + } |
| + st, err := hostfiles.SyncDir(ctx, p, f.FilesDir(obs.Host), f.Skip) |
| + if err != nil { |
| + return 0, err |
| + } |
| + if len(st.Unreadable) > 0 || len(st.Retry) > 0 || len(st.Torn) > 0 { |
| + // Writers are stopped (step 1), so an unstable file is a fault, |
| + // not a race: the release does not vouch for an image it could |
| + // not read whole. |
| + return 0, fmt.Errorf("hostfiles: release flush of %s: unreadable %q replaced %q torn %q", obs.Host, st.Unreadable, st.Retry, st.Torn) |
| + } |
| + return p.Commit(ctx) |
| +} |
| + |
| +// releasePosition is one recorded release: the exact stop request and |
| +// the positions its flush reached. |
| +type releasePosition struct { |
| + V int `json:"v"` |
| + Request StopRequest `json:"request"` |
| + TXID uint64 `json:"txid"` |
| + Seq int64 `json:"seq"` |
| +} |
| + |
| +// ReleasePositionsFile is ReleasePositions on disk: one file per host under |
| +// Dir, written whole and renamed into place, read back against the exact |
| +// stop request. The claim's ETag and the directory revision are not part |
| +// of the match: a target replacement moves both while the source's stop |
| +// stays the same stop. |
| +type ReleasePositionsFile struct { |
| + Dir string |
| +} |
| + |
| +func (f ReleasePositionsFile) path(host string) (string, error) { |
| + if f.Dir == "" || !controlname.ValidHostShape(host) { |
| + return "", ErrClaimStopUnavailable |
| + } |
| + return filepath.Join(f.Dir, host+".json"), nil |
| +} |
| + |
| +func (f ReleasePositionsFile) Record(_ context.Context, r StopRequest, txid uint64, seq int64) error { |
| + path, err := f.path(r.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if err := os.MkdirAll(f.Dir, 0o700); err != nil { |
| + return err |
| + } |
| + raw, err := json.Marshal(releasePosition{V: 1, Request: r, TXID: txid, Seq: seq}) |
| + if err != nil { |
| + return err |
| + } |
| + tmp := path + ".tmp" |
| + w, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := w.Write(raw); err != nil { |
| + _ = w.Close() |
| + return err |
| + } |
| + if err := w.Sync(); err != nil { |
| + _ = w.Close() |
| + return err |
| + } |
| + if err := w.Close(); err != nil { |
| + return err |
| + } |
| + if err := os.Rename(tmp, path); err != nil { |
| + return err |
| + } |
| + dir, err := os.Open(f.Dir) |
| + if err != nil { |
| + return err |
| + } |
| + defer dir.Close() |
| + return dir.Sync() |
| +} |
| + |
| +// sameStop is the installation and session, not the controller. |
| +func sameStop(a, b StopRequest) bool { |
| + return a.Host == b.Host && a.Incarnation == b.Incarnation && a.InstallationStamp == b.InstallationStamp && a.WriterSession == b.WriterSession && |
| + a.OperationID == b.OperationID && a.Phase == b.Phase && a.ObligationID == b.ObligationID && a.Box == b.Box && a.Enrollment == b.Enrollment |
| +} |
| + |
| +func (f ReleasePositionsFile) Recorded(_ context.Context, r StopRequest) (StopRequest, uint64, int64, bool, error) { |
| + path, err := f.path(r.Host) |
| + if err != nil { |
| + return StopRequest{}, 0, 0, false, err |
| + } |
| + raw, err := os.ReadFile(path) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return StopRequest{}, 0, 0, false, nil |
| + } |
| + if err != nil { |
| + return StopRequest{}, 0, 0, false, err |
| + } |
| + var p releasePosition |
| + if err := json.Unmarshal(raw, &p); err != nil || p.V != 1 { |
| + return StopRequest{}, 0, 0, false, fmt.Errorf("%w: release positions for %s", ErrClaimStopReceipt, r.Host) |
| + } |
| + if !sameStop(p.Request, r) { |
| + return StopRequest{}, 0, 0, false, nil |
| + } |
| + return p.Request, p.TXID, p.Seq, true, nil |
| +} |
diff --git a/internal/host/relocationlifecycle_linux_test.go b/internal/host/relocationlifecycle_linux_test.go| new file mode 100644 |
| index 000000000..2ad97642c |
| --- /dev/null |
| +++ b/internal/host/relocationlifecycle_linux_test.go |
| @@ -0,0 +1,788 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "math/rand" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + execctl "github.com/carlosframework/platform/internal/hostexec" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostrelocate" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// lifeSeeds is the property test's seed count (rev 14: 200 by default). |
| +// Run it niced; it is not a sweep. |
| +const lifeSeeds = 200 |
| + |
| +// lifeStore is the bucket with faults: lease puts that fail (a renewal |
| +// that cannot reach the store) and claim reads that fail or find nothing. |
| +type lifeStore struct { |
| + release.Store |
| + mu sync.Mutex |
| + failLease bool |
| + claimErr error |
| + claimKey string |
| +} |
| + |
| +func (s *lifeStore) Put(ctx context.Context, key string, b []byte, etag string) (string, error) { |
| + s.mu.Lock() |
| + fail := s.failLease && strings.Contains(key, "/exec/") |
| + s.mu.Unlock() |
| + if fail { |
| + return "", errors.New("lease put: store unreachable") |
| + } |
| + return s.Store.Put(ctx, key, b, etag) |
| +} |
| + |
| +func (s *lifeStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + s.mu.Lock() |
| + err := s.claimErr |
| + s.mu.Unlock() |
| + if err != nil && key == s.claimKey { |
| + return nil, "", err |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +func (s *lifeStore) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| + return release.DeleteIfMatch(ctx, s.Store, key, etag) |
| +} |
| + |
| +// lifeClock is hostexec's clock under the test's hand: every After fires |
| +// on tick, so a renewal happens exactly when a step says so. |
| +type lifeClock struct { |
| + mu sync.Mutex |
| + wall time.Time |
| + boot int64 |
| + waiters []chan time.Time |
| +} |
| + |
| +func (c *lifeClock) Now() (time.Time, int64, error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + return c.wall, c.boot, nil |
| +} |
| + |
| +func (c *lifeClock) After(time.Duration) <-chan time.Time { |
| + ch := make(chan time.Time, 1) |
| + c.mu.Lock() |
| + c.waiters = append(c.waiters, ch) |
| + c.mu.Unlock() |
| + return ch |
| +} |
| + |
| +func (c *lifeClock) tick() { |
| + c.mu.Lock() |
| + c.wall, c.boot = c.wall.Add(time.Second), c.boot+int64(time.Second) |
| + ws := c.waiters |
| + c.waiters = nil |
| + now := c.wall |
| + c.mu.Unlock() |
| + for _, w := range ws { |
| + w <- now |
| + } |
| +} |
| + |
| +// lifeWorld is one box ("src") taking part in a host's relocations over |
| +// real hostexec controllers, with the faults a seed injects. |
| +type lifeWorld struct { |
| + t *testing.T |
| + ctx context.Context |
| + inner release.Store |
| + store *lifeStore |
| + clock *lifeClock |
| + dataDir string |
| + host string |
| + cfg execctl.Config |
| + e *RelocationEffects |
| + p hostrelocate.Participant |
| + ops int |
| + mu sync.Mutex |
| + cleanErr bool // both cleanups fail |
| + block chan struct{} // non-nil: both cleanups wait for it (a close that times out) |
| + cleanOK map[string]bool |
| + // installed is this box's local install per operation (rev 15): the |
| + // Install provider makes it, a scoped cleanup of an operation whose P |
| + // is unopened retires it, VerifyLocal reads it. |
| + installed map[string]bool |
| + // gen is the agent process's generation: a crash starts the next one. |
| + // A dead generation's hooks never complete (rev 16). |
| + gen int |
| + // clears and opens count durable records cleared and controllers |
| + // opened in the current step, for invariant (h). |
| + clears, opens int |
| + // the record as the previous step left it. |
| + prevExists, prevReadable bool |
| + prevOp string |
| + saved []byte // a record the seed corrupted, to restore on recovery |
| + violated []string |
| +} |
| + |
| +const lifeDeadline = 400 * time.Millisecond |
| + |
| +func newLifeWorld(t *testing.T) *lifeWorld { |
| + t.Helper() |
| + ctx := context.Background() |
| + w := &lifeWorld{t: t, ctx: ctx, host: "shop.bab.oncarlos.com", clock: &lifeClock{wall: time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC), boot: int64(time.Hour)}, cleanOK: map[string]bool{}, installed: map[string]bool{}} |
| + w.dataDir = t.TempDir() |
| + for _, name := range []string{"hostguard", ".hostexec", "locks", "exec-locks"} { |
| + if err := os.Mkdir(filepath.Join(w.dataDir, name), 0o770); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + w.inner = release.NewDirStore(t.TempDir()) |
| + key, _ := hostclaim.Key(w.host) |
| + w.store = &lifeStore{Store: w.inner, claimKey: key} |
| + created := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC) |
| + nh := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + w.host + ".json", Account: "bab", App: "jam", Host: w.host, Region: "ie", RecordRegion: "ie", CreatedAt: created} |
| + body := []byte(`{"host":"` + w.host + `","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`) |
| + w.claim(hostclaim.BeginCreateOn(nh, body, "c1", "console", created, hostclaim.ExecutorSelection{Box: "src", Enrollment: strings.Repeat("a", 32)})) |
| + w.claim(hostclaim.CompleteCreate("c1", "src")) |
| + w.claim(hostclaim.AdmitWriter(hostclaim.Admission{Box: "src", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true})) |
| + w.cfg = execctl.Config{Store: w.store, Guard: hostguard.Guard{DataDir: w.dataLink(0), LockDir: filepath.Join(w.dataDir, "locks"), Claims: hostguard.StoreReader{Store: w.store}}, |
| + Directory: activeDirectory{}, Clock: w.clock, LockDir: filepath.Join(w.dataDir, "exec-locks"), |
| + CloseAdmission: func() {}, CancelWork: func() {}} |
| + lifetime, stop := context.WithCancel(ctx) |
| + t.Cleanup(stop) |
| + w.e = &RelocationEffects{Lifetime: lifetime, |
| + Identity: func(context.Context) (RelocationIdentity, error) { |
| + return RelocationIdentity{Actor: hostbox.Actor{Box: "src", Enrollment: strings.Repeat("a", 32), Revision: 4}, BootID: "boot", ControllerID: "ctl"}, nil |
| + }, |
| + Controller: func(ctx context.Context, b execctl.Binding) (RelocationController, error) { |
| + c := w.cfg |
| + c.Binding = b |
| + // hostexec's own Cleanup hook is the same scoped cleanup, |
| + // bound to the binding (Task 8a); it does not count for (a). |
| + if cur := w.current(); cur.Op.ID == b.Op && !slices.Contains(cur.Op.Participants, "src") { |
| + w.violate(fmt.Sprintf("(i) %s opened again after this box was excluded from it", b.Op)) |
| + } |
| + gen := w.generation() |
| + c.Cleanup = func(context.Context) error { return w.cleanupScoped(context.Background(), b.Op, false, gen) } |
| + opened, err := OpenRelocationController(ctx, c) |
| + if err != nil { |
| + return nil, err |
| + } |
| + w.mu.Lock() |
| + w.opens++ |
| + w.mu.Unlock() |
| + return &lifeController{RelocationController: opened, w: w, op: b.Op}, nil |
| + }, |
| + Install: func(_ context.Context, o hostclaim.Observed) (hostclaim.LocalInstall, error) { |
| + c := w.current() |
| + if hostclaim.PreparedOpened(c) { |
| + w.violate("(g) the local install was remade after P was opened") |
| + } |
| + w.mu.Lock() |
| + w.installed[c.Op.ID] = true |
| + w.mu.Unlock() |
| + n, _ := strconv.ParseUint(c.Op.Journal["install_stamp"], 10, 64) |
| + return hostclaim.LocalInstall{Box: "src", Enrollment: strings.Repeat("a", 32), InstallStamp: n, FilesManifestVersion: 1, JournalSHA256: strings.Repeat("e", 64)}, nil |
| + }, |
| + Receipt: func(_ context.Context, o hostclaim.Observed) (hostclaim.LocalInstall, bool, error) { |
| + var l hostclaim.LocalInstall |
| + _ = json.Unmarshal([]byte(o.Claim.Op.Journal["local_install"]), &l) |
| + w.mu.Lock() |
| + defer w.mu.Unlock() |
| + return l, w.installed[o.Claim.Op.ID], nil |
| + }, |
| + VerifyLocal: func(_ context.Context, o hostclaim.Observed, _ hostclaim.LocalInstall) (bool, error) { |
| + w.mu.Lock() |
| + defer w.mu.Unlock() |
| + return w.installed[o.Claim.Op.ID], nil |
| + }, |
| + Admit: func(_ context.Context, o hostclaim.Observed, _ string) (uint64, bool, error) { |
| + c := w.current() |
| + w.mu.Lock() |
| + present := w.installed[c.Op.ID] |
| + w.mu.Unlock() |
| + if !hostclaim.PreparedOpened(c) && !present { |
| + w.violate("(f) P's first admission over a missing local install") |
| + } |
| + // The admission opens P (the claim journals it). |
| + if _, err := hostclaim.Update(context.Background(), w.inner, o, func(c *hostclaim.Claim) error { |
| + c.Op.Journal["prepared_opened"] = c.Op.Journal["prepared"] |
| + return nil |
| + }); err != nil { |
| + return 0, false, err |
| + } |
| + return 0, false, nil // the probe is still running |
| + }} |
| + w.e.Controllers = w.controllers() |
| + w.p = hostrelocate.Participant{Claims: w.store, Box: "src", Enrollment: strings.Repeat("a", 32), Effects: w.e, Now: func() time.Time { return w.clock.wall }} |
| + return w |
| +} |
| + |
| +// lifeController checks invariant (a) at every clear of a live |
| +// controller's record. |
| +type lifeController struct { |
| + RelocationController |
| + w *lifeWorld |
| + op string |
| +} |
| + |
| +func (c *lifeController) Clear(ctx context.Context) error { |
| + c.w.clearing(c.op) |
| + err := c.RelocationController.Clear(ctx) |
| + c.w.cleared(err) |
| + return err |
| +} |
| + |
| +func (w *lifeWorld) cleared(err error) { |
| + if err == nil { |
| + w.mu.Lock() |
| + w.clears++ |
| + w.mu.Unlock() |
| + } |
| +} |
| + |
| +func (w *lifeWorld) generation() int { |
| + w.mu.Lock() |
| + defer w.mu.Unlock() |
| + return w.gen |
| +} |
| + |
| +func (w *lifeWorld) controllers() *RelocationControllers { |
| + gen := w.generation() |
| + return &RelocationControllers{Journal: filepath.Join(w.dataDir, "relocate", "participations"), DataDir: w.dataDir, |
| + Cleanup: func(ctx context.Context, b execctl.Binding) error { return w.cleanupScoped(ctx, b.Op, true, gen) }, |
| + Clear: func(ctx context.Context, b execctl.Binding) error { |
| + w.clearing(b.Op) |
| + c := w.cfg |
| + c.Binding = b |
| + err := execctl.ClearStale(ctx, c) |
| + w.cleared(err) |
| + return err |
| + }} |
| +} |
| + |
| +// cleanupScoped is the scoped cleanup (Task 8a's relocationCleanup): |
| +// it retires the operation's local install unless the claim has opened |
| +// that operation's P (it never stops what serves). counts says whether it |
| +// is RelocationControllers' own call, the one invariant (a) asks for. |
| +func (w *lifeWorld) cleanupScoped(ctx context.Context, op string, counts bool, gen int) error { |
| + w.mu.Lock() |
| + block, fail, dead := w.block, w.cleanErr, gen != w.gen |
| + w.mu.Unlock() |
| + if dead { |
| + select {} // a crashed process's hooks never complete |
| + } |
| + w.mu.Lock() |
| + if counts { |
| + w.cleanOK[op] = false |
| + } |
| + w.mu.Unlock() |
| + if block != nil { |
| + select { |
| + case <-block: |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + } |
| + } |
| + if gen != w.generation() { |
| + select {} // the process died while its cleanup waited |
| + } |
| + if fail { |
| + return errors.New("cleanup: a partial install would not go") |
| + } |
| + c, err := hostclaim.Read(context.Background(), w.inner, w.host) |
| + opened := err == nil && !c.Absent && c.Claim.Op.ID == op && hostclaim.PreparedOpened(c.Claim) |
| + w.mu.Lock() |
| + if !opened { |
| + w.installed[op] = false |
| + } |
| + if counts { |
| + w.cleanOK[op] = true |
| + } |
| + w.mu.Unlock() |
| + return nil |
| +} |
| + |
| +// clearing is invariant (a): a record is cleared only straight after a |
| +// scoped cleanup of the same participation succeeded, in the same call. |
| +func (w *lifeWorld) clearing(op string) { |
| + w.mu.Lock() |
| + defer w.mu.Unlock() |
| + if !w.cleanOK[op] { |
| + w.violated = append(w.violated, "(a) clear of "+op+" without a successful scoped cleanup first") |
| + } |
| + w.cleanOK[op] = false |
| +} |
| + |
| +func (w *lifeWorld) claim(tr func(*hostclaim.Claim) error) { |
| + w.t.Helper() |
| + o, err := hostclaim.Read(w.ctx, w.inner, w.host) |
| + if err != nil { |
| + w.t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(w.ctx, w.inner, o, tr); err != nil { |
| + w.t.Fatal(err) |
| + } |
| +} |
| + |
| +func (w *lifeWorld) current() *hostclaim.Claim { |
| + w.t.Helper() |
| + o, err := hostclaim.Read(w.ctx, w.inner, w.host) |
| + if err != nil { |
| + w.t.Fatal(err) |
| + } |
| + return o.Claim |
| +} |
| + |
| +// startRelocation starts the next relocation, src taking part (as source |
| +// or as target), whatever the claim was doing. |
| +func (w *lifeWorld) startRelocation() { |
| + w.ops++ |
| + id := fmt.Sprintf("rel-%d", w.ops) |
| + from, to := "src", "dst" |
| + if exec := w.current().Op.Exec; exec != "" && exec != "src" && exec != "none" { |
| + from, to = exec, "src" |
| + } |
| + w.claim(func(c *hostclaim.Claim) error { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: id, Phase: hostclaim.PhaseStarting, Exec: from, Participants: []string{from, to}, |
| + Intent: map[string]string{"region": "ie", "source_box": from, "target_box": to, "target_enrollment": map[string]string{"src": strings.Repeat("a", 32), "dst": strings.Repeat("d", 32), "sp2": strings.Repeat("e", 32)}[to]}} |
| + return nil |
| + }) |
| +} |
| + |
| +// replaced is the worker replacing this box, the target, within the same |
| +// operation (rev 19, round 18 finding 1): after its release in |
| +// probe-failed, or at any target phase, the relocation goes on with sp2 |
| +// and this box is no longer a participant. Its controller, record and |
| +// journal must go, and nothing for this operation may open again here. |
| +func (w *lifeWorld) replaced() bool { |
| + c := w.current() |
| + if c.Op.Kind != hostclaim.KindRelocating || c.Op.Intent["target_box"] != "src" { |
| + return false |
| + } |
| + w.claim(func(c *hostclaim.Claim) error { |
| + if c.Op.Journal == nil { |
| + c.Op.Journal = map[string]string{} |
| + } |
| + c.Op.Participants = append(slices.DeleteFunc(slices.Clone(c.Op.Participants), func(b string) bool { return b == "src" }), "sp2") |
| + c.Op.Intent["target_box"], c.Op.Intent["target_enrollment"] = "sp2", strings.Repeat("e", 32) |
| + if c.Op.Phase != hostclaim.PhaseStarting && c.Op.Phase != hostclaim.PhaseReleasing { |
| + c.Op.Exec, c.Op.Phase = "none", hostclaim.PhasePinning |
| + for _, k := range []string{"local_install", "probe", "prepared", "prepared_opened", "install_stamp", "release", "release.phase", "probing_since", "probe_failed_since"} { |
| + delete(c.Op.Journal, k) |
| + } |
| + } |
| + c.Op.Journal["excluded_targets"] = `["src"]` |
| + c.Op.Journal["exec_ack.sp2"] = "{}" |
| + return nil |
| + }) |
| + return true |
| +} |
| + |
| +// probeFailedAndReleased moves this box, the target, to probe-failed with |
| +// its release journaled (the worker's and its own half, in short). |
| +func (w *lifeWorld) probeFailedAndReleased() { |
| + w.claim(func(c *hostclaim.Claim) error { |
| + if c.Op.Journal == nil { |
| + c.Op.Journal = map[string]string{} |
| + } |
| + c.Op.Phase = hostclaim.PhaseProbeFailed |
| + c.Op.Journal["probe_failed_since"] = w.clock.wall.Format(time.RFC3339Nano) |
| + c.Op.Journal["release"] = `{"released_by":"src"}` |
| + c.Op.Journal["release.phase"] = hostclaim.PhaseProbeFailed |
| + return nil |
| + }) |
| +} |
| + |
| +// install is the worker's half while this box is the target: P built and |
| +// the operation handed to it (installed), its local install still to make. |
| +func (w *lifeWorld) install() { |
| + c := w.current() |
| + if c.Op.Kind != hostclaim.KindRelocating || c.Op.Intent["target_box"] != "src" || (c.Op.Phase != hostclaim.PhaseStarting && c.Op.Phase != hostclaim.PhaseReleasing) { |
| + return |
| + } |
| + w.claim(func(c *hostclaim.Claim) error { |
| + c.Op.Phase, c.Op.Exec = hostclaim.PhaseInstalled, "src" |
| + if c.Op.Journal == nil { |
| + c.Op.Journal = map[string]string{} |
| + } |
| + c.Op.Journal["install_stamp"] = strconv.FormatUint(c.Gen+1, 10) |
| + c.Op.Journal["prepared"] = "P-" + c.Op.ID |
| + if c.Op.Journal["exec_ack.dst"] == "" { |
| + c.Op.Journal["exec_ack.dst"] = "{}" |
| + } |
| + return nil |
| + }) |
| +} |
| + |
| +func (w *lifeWorld) complete() { |
| + if c := w.current(); c.Op.Kind == hostclaim.KindRelocating { |
| + box := c.Op.Intent["target_box"] |
| + w.claim(func(c *hostclaim.Claim) error { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: box, Participants: []string{box}} |
| + return nil |
| + }) |
| + } |
| +} |
| + |
| +// pass is one participant pass, Step then Sweep as the agent runs them, |
| +// under a deadline; (d) is that it returns within it. |
| +func (w *lifeWorld) pass() { |
| + w.mu.Lock() |
| + w.cleanOK = map[string]bool{} // "the same call": nothing carries over from an earlier pass |
| + w.mu.Unlock() |
| + done := make(chan struct{}) |
| + start := time.Now() |
| + go func() { |
| + defer close(done) |
| + ctx, cancel := context.WithTimeout(w.ctx, lifeDeadline) |
| + defer cancel() |
| + _, _ = w.p.Step(ctx, w.host) |
| + _ = w.p.Sweep(ctx) |
| + }() |
| + select { |
| + case <-done: |
| + if took := time.Since(start); took > lifeDeadline+time.Second { |
| + w.violate(fmt.Sprintf("(d) a pass took %v past its %v deadline", took, lifeDeadline)) |
| + } |
| + case <-time.After(lifeDeadline + 3*time.Second): |
| + w.t.Fatalf("(d) a pass never returned within %v", lifeDeadline+3*time.Second) |
| + } |
| +} |
| + |
| +func (w *lifeWorld) violate(s string) { |
| + w.mu.Lock() |
| + w.violated = append(w.violated, s) |
| + w.mu.Unlock() |
| +} |
| + |
| +// renew fires hostexec's timers so every controller renews (or loses). |
| +func (w *lifeWorld) renew() { |
| + w.clock.tick() |
| + time.Sleep(20 * time.Millisecond) |
| +} |
| + |
| +func (w *lifeWorld) record() (execctl.State, bool, bool) { |
| + raw, err := os.ReadFile(filepath.Join(w.dataDir, ".hostexec", w.host+".json")) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return execctl.State{}, false, true |
| + } |
| + var st execctl.State |
| + if err != nil || json.Unmarshal(raw, &st) != nil { |
| + return st, true, false |
| + } |
| + return st, true, true |
| +} |
| + |
| +func (w *lifeWorld) open() []RelocationController { |
| + r := w.e.Controllers |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + var out []RelocationController |
| + for _, c := range r.open { |
| + out = append(out, c) |
| + } |
| + return out |
| +} |
| + |
| +// check is invariants (a) to (c) after a step. |
| +func (w *lifeWorld) check(step string) { |
| + st, exists, readable := w.record() |
| + if exists && readable { |
| + retained, _ := w.e.Controllers.Retained() |
| + if !slices.ContainsFunc(retained, func(p hostrelocate.Participation) bool { return p.Host == w.host && p.Op == st.Op }) { |
| + w.violate(fmt.Sprintf("(b) after %s: %s's record exists but its participation is forgotten (retained %+v)", step, st.Op, retained)) |
| + } |
| + } |
| + if n := len(w.open()); n > 1 { |
| + w.violate(fmt.Sprintf("(c) after %s: %d controllers for one host", step, n)) |
| + } |
| + // (h): a record goes only by a clear or by a successor's open over it, |
| + // never by a crash or any other step; while one exists, an executable |
| + // update is refused (root's §5.10 check). |
| + w.mu.Lock() |
| + clears, opens := w.clears, w.opens |
| + w.clears, w.opens = 0, 0 |
| + w.mu.Unlock() |
| + if w.prevExists && w.prevReadable && (!exists || (readable && st.Op != w.prevOp)) && clears == 0 && opens == 0 { |
| + w.violate(fmt.Sprintf("(h) after %s: %s's record went with no clear and no successor's open", step, w.prevOp)) |
| + } |
| + if exists && hostexecParticipation(w.dataDir) == nil { |
| + w.violate(fmt.Sprintf("(h) after %s: a record exists but an executable update is not refused", step)) |
| + } |
| + w.prevExists, w.prevReadable, w.prevOp = exists, readable, st.Op |
| +} |
| + |
| +// dataLink is generation gen's path to the data directory: a symlink, so |
| +// a crash can cut a dead generation off from it. A dead process writes |
| +// nothing; in-process, a dead controller's goroutines are still running |
| +// (hostexec's save loop retries a "lost" write until it lands), so the |
| +// link it opens through is removed. |
| +func (w *lifeWorld) dataLink(gen int) string { |
| + link := filepath.Join(filepath.Dir(w.dataDir), fmt.Sprintf("data-%d", gen)) |
| + if err := os.Symlink(w.dataDir, link); err != nil && !os.IsExist(err) { |
| + w.t.Fatal(err) |
| + } |
| + return link |
| +} |
| + |
| +// crash is the agent process dying abruptly (rev 16, round 15): no |
| +// controller is closed and none of its hooks run or complete; its durable |
| +// records stay, and every external fault (a blocked or failing cleanup, a |
| +// store that refuses) stays as it was. The kernel releases a dead |
| +// process's locks: the next generation uses fresh lock files, and the dead |
| +// generation's clock never fires again, so its controllers never renew, |
| +// lose or clean up. Recovery is a separate, later event. |
| +func (w *lifeWorld) crash() { |
| + w.mu.Lock() |
| + w.gen++ |
| + gen := w.gen |
| + w.mu.Unlock() |
| + now, boot, _ := w.clock.Now() |
| + w.clock = &lifeClock{wall: now, boot: boot} |
| + for _, d := range []string{fmt.Sprintf("exec-locks-%d", gen), fmt.Sprintf("locks-%d", gen)} { |
| + if err := os.MkdirAll(filepath.Join(w.dataDir, d), 0o770); err != nil { |
| + w.t.Fatal(err) |
| + } |
| + } |
| + w.cfg.Clock = w.clock |
| + w.cfg.LockDir = filepath.Join(w.dataDir, fmt.Sprintf("exec-locks-%d", gen)) |
| + w.cfg.Guard.LockDir = filepath.Join(w.dataDir, fmt.Sprintf("locks-%d", gen)) |
| + if err := os.Remove(w.cfg.Guard.DataDir); err != nil { |
| + w.t.Fatal(err) |
| + } |
| + w.cfg.Guard.DataDir = w.dataLink(gen) |
| + w.e.Controllers = w.controllers() |
| +} |
| + |
| +func (w *lifeWorld) recoverCleanup() { |
| + w.mu.Lock() |
| + if w.block != nil { |
| + close(w.block) |
| + w.block = nil |
| + } |
| + w.cleanErr = false |
| + w.mu.Unlock() |
| +} |
| + |
| +func (w *lifeWorld) recoverAll() { |
| + w.recoverCleanup() |
| + w.store.mu.Lock() |
| + w.store.failLease, w.store.claimErr = false, nil |
| + w.store.mu.Unlock() |
| + if w.saved != nil { |
| + path := filepath.Join(w.dataDir, ".hostexec", w.host+".json") |
| + if raw, err := os.ReadFile(path); err == nil && string(raw) == "{" { |
| + _ = os.WriteFile(path, w.saved, 0o660) |
| + // hostexec retries a "lost" write every 100ms through a |
| + // directory it already holds open, so a controller that met |
| + // the invalid record (a dead generation's included: in-process |
| + // its goroutine outlives the crash) lands its write now, as |
| + // part of the repair, never after a later clear. |
| + time.Sleep(300 * time.Millisecond) |
| + } |
| + w.saved = nil |
| + } |
| +} |
| + |
| +func (w *lifeWorld) event(r *rand.Rand) string { |
| + switch n := r.Intn(16); n { |
| + case 15: |
| + if w.replaced() { |
| + return "this box replaced in the same operation" |
| + } |
| + return "no replacement (not the target)" |
| + case 14: |
| + w.install() |
| + return "the target's installation (P built)" |
| + case 0, 1, 2, 3: |
| + w.pass() |
| + return "pass" |
| + case 4: |
| + w.renew() |
| + return "renew" |
| + case 5: |
| + w.store.mu.Lock() |
| + w.store.failLease = true |
| + w.store.mu.Unlock() |
| + w.renew() |
| + return "lease renewal fails" |
| + case 6: |
| + w.mu.Lock() |
| + if w.block == nil { |
| + w.block = make(chan struct{}) |
| + } |
| + w.mu.Unlock() |
| + return "cleanup blocks (close times out)" |
| + case 7: |
| + w.mu.Lock() |
| + w.cleanErr = true |
| + w.mu.Unlock() |
| + return "cleanup fails" |
| + case 8: |
| + w.store.mu.Lock() |
| + if r.Intn(2) == 0 { |
| + w.store.claimErr = errors.New("claim read: store unreachable") |
| + } else { |
| + w.store.claimErr = os.ErrNotExist // ClearStale: ErrState for an absent claim |
| + } |
| + w.store.mu.Unlock() |
| + return "claim reads fail" |
| + case 9: |
| + path := filepath.Join(w.dataDir, ".hostexec", w.host+".json") |
| + if raw, err := os.ReadFile(path); err == nil && w.saved == nil { |
| + w.saved = raw |
| + _ = os.WriteFile(path, []byte("{"), 0o660) // ClearStale: an invalid local record |
| + } |
| + return "local record invalid" |
| + case 10: |
| + w.crash() |
| + return "agent crash (no hooks run, faults persist)" |
| + case 11: |
| + w.complete() |
| + w.startRelocation() // the successor starts before this box's sweep |
| + return "completion, successor before the sweep" |
| + case 12: |
| + w.complete() |
| + return "completion" |
| + default: |
| + w.recoverAll() |
| + return "storage recovers" |
| + } |
| +} |
| + |
| +// TestRelocationControllerLifecycleProperties drives seeded random |
| +// sequences of lease-renewal failures, close timeouts, cleanup failures, |
| +// ClearStale failures (claim unreadable, claim absent, invalid local |
| +// record, a successor that lists this box), agent restarts, successors |
| +// before the sweep, completions and recovery, over real hostexec |
| +// controllers, and checks after every step: (a) no clear without a |
| +// successful scoped cleanup in the same call; (b) no participation |
| +// forgotten while its record exists; (c) at most one controller per host; |
| +// (d) no pass past its deadline; and (e) once faults stop, within eight |
| +// passes the current relocation is ready again, then after completion |
| +// every record is cleared and no controller remains, and a new relocation |
| +// opens. |
| +func TestRelocationControllerLifecycleProperties(t *testing.T) { |
| + seeds := lifeSeeds |
| + if testing.Short() { |
| + seeds = 20 |
| + } |
| + for seed := 1; seed <= seeds; seed++ { |
| + t.Run(fmt.Sprint(seed), func(t *testing.T) { |
| + r := rand.New(rand.NewSource(int64(seed))) |
| + w := newLifeWorld(t) |
| + defer w.recoverAll() |
| + w.startRelocation() |
| + var trace []string |
| + if seed%2 == 0 { |
| + // Every other seed starts as the target with its local |
| + // install journaled (rev 15), so the random events fall on |
| + // an installation that a lost lease's cleanup can retire |
| + // before P's first admission. |
| + w.complete() |
| + w.startRelocation() // dst back to src: this box is the target |
| + w.pass() |
| + w.install() |
| + w.pass() |
| + trace = append(trace, "target, installed, local install journaled") |
| + if w.current().Op.Journal["local_install"] == "" { |
| + t.Fatalf("seed %d: the local install was not journaled: %+v", seed, w.current().Op) |
| + } |
| + if seed%4 == 2 { |
| + // Round 18's trace: the target's probe fails, it |
| + // releases, it is replaced while the relocation goes |
| + // on elsewhere, its cleanup blocks, the agent crashes. |
| + w.pass() |
| + w.probeFailedAndReleased() |
| + if !w.replaced() { |
| + t.Fatalf("seed %d: this box was not the target to replace: %+v", seed, w.current().Op) |
| + } |
| + w.mu.Lock() |
| + w.block = make(chan struct{}) |
| + w.mu.Unlock() |
| + w.pass() |
| + w.check("replaced with its cleanup blocked") |
| + w.crash() |
| + w.check("agent crash after the replacement") |
| + trace = append(trace, "probe failed, released, replaced, cleanup blocked, crash") |
| + } |
| + } |
| + for i := 0; i < 30; i++ { |
| + ev := w.event(r) |
| + trace = append(trace, ev) |
| + w.check(ev) |
| + if len(w.violated) > 0 { |
| + t.Fatalf("seed %d: %v\ntrace: %v", seed, w.violated, trace) |
| + } |
| + } |
| + // (e) Faults stop. |
| + w.recoverAll() |
| + trace = append(trace, "faults stop") |
| + // A participating box is ready again; an excluded one (rev 19) |
| + // has finished its cleanup: no controller, no record, no |
| + // journaled participation, while the relocation goes on. |
| + ready := func() bool { |
| + c := w.current() |
| + if c.Op.Kind != hostclaim.KindRelocating { |
| + return true |
| + } |
| + if !slices.Contains(c.Op.Participants, "src") { |
| + _, exists, _ := w.record() |
| + retained, err := w.e.Controllers.Retained() |
| + return !exists && err == nil && len(retained) == 0 && len(w.open()) == 0 |
| + } |
| + if c.Op.Journal["exec_ack.src"] == "" { |
| + return false |
| + } |
| + obs, err := hostclaim.Read(w.ctx, w.inner, w.host) |
| + return err == nil && w.e.Ready(w.ctx, obs) == nil |
| + } |
| + ok := false |
| + for i := 0; i < 8 && !ok; i++ { |
| + w.renew() |
| + w.pass() |
| + w.check("recovery pass") |
| + ok = ready() |
| + } |
| + if !ok || len(w.violated) > 0 { |
| + t.Fatalf("seed %d: the running relocation did not recover within 8 passes: %v\ntrace: %v", seed, w.violated, trace) |
| + } |
| + w.complete() |
| + clean := false |
| + for i := 0; i < 8 && !clean; i++ { |
| + w.renew() |
| + w.pass() |
| + w.check("sweep pass") |
| + _, exists, _ := w.record() |
| + retained, err := w.e.Controllers.Retained() |
| + clean = !exists && len(retained) == 0 && err == nil && len(w.open()) == 0 |
| + } |
| + if !clean || len(w.violated) > 0 { |
| + st, exists, _ := w.record() |
| + retained, _ := w.e.Controllers.Retained() |
| + t.Fatalf("seed %d: not clean within 8 passes after completion: record %v %+v retained %+v open %d %v\ntrace: %v", seed, exists, st, retained, len(w.open()), w.violated, trace) |
| + } |
| + w.startRelocation() |
| + for i := 0; i < 3; i++ { |
| + w.pass() |
| + } |
| + w.check("new relocation") |
| + if !ready() || len(w.open()) != 1 || len(w.violated) > 0 { |
| + t.Fatalf("seed %d: a new relocation did not open: open %d %v\ntrace: %v", seed, len(w.open()), w.violated, trace) |
| + } |
| + for _, c := range w.open() { |
| + _ = c.Close(context.Background()) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/relocationreleases.go b/internal/host/relocationreleases.go| new file mode 100644 |
| index 000000000..038f7c708 |
| --- /dev/null |
| +++ b/internal/host/relocationreleases.go |
| @@ -0,0 +1,53 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// RelocationReleaseObligation is the stop request's obligation component |
| +// for a relocation's release (§5.8 release step 4). A release retires |
| +// nothing, so it names no park-local chore; root's FenceAndStop requires |
| +// the component, and the receipt carries it so a retirement stop can never |
| +// be offered as a release, nor a release as a retirement. |
| +const RelocationReleaseObligation = "relocation-release" |
| + |
| +// StopReceiptReleases is hostclaim.ReleaseAuthority over this package's |
| +// stop receipts (ClaimStopCoordinator.Publish): a relocation's journaled |
| +// release counts only when the immutable receipt it names hashes to the |
| +// journaled digest and is the full stop of exactly that box, enrollment, |
| +// writer session, host, incarnation, operation and phase (region HA spec 1 |
| +// §5.8 release step 4, claim §6.1). |
| +type StopReceiptReleases struct { |
| + Evidence release.Store |
| +} |
| + |
| +var _ hostclaim.ReleaseAuthority = StopReceiptReleases{} |
| + |
| +func (a StopReceiptReleases) VerifyRelease(ctx context.Context, c hostclaim.Claim, r hostclaim.Release) error { |
| + if a.Evidence == nil || c.Holder == nil { |
| + return ErrClaimStopUnavailable |
| + } |
| + raw, _, err := a.Evidence.Get(ctx, r.Receipt) |
| + if err != nil { |
| + return err |
| + } |
| + sum := sha256.Sum256(raw) |
| + var receipt StopReceipt |
| + if hex.EncodeToString(sum[:]) != r.ReceiptSHA256 || json.Unmarshal(raw, &receipt) != nil || !bytes.Equal(raw, mustMarshalStopReceipt(receipt)) { |
| + return ErrClaimStopReceipt |
| + } |
| + key := stopEvidenceKey(receipt.Host, receipt.OperationID, receipt.Box, receipt.ID) |
| + if receipt.V != 1 || receipt.Kind != "stopped" || receipt.OperationID == "" || r.Receipt != key || receipt.Host != c.Host || receipt.Incarnation != c.Holder.Incarnation || |
| + receipt.OperationID != c.Op.ID || receipt.Phase != c.Op.Phase || receipt.ObligationID != RelocationReleaseObligation || receipt.Box != r.ReleasedBy || receipt.Enrollment != r.Enrollment || |
| + receipt.WriterSession != r.Session || receipt.EvidenceAt.IsZero() { |
| + return ErrClaimStopReceipt |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/relocationreleases_test.go b/internal/host/relocationreleases_test.go| new file mode 100644 |
| index 000000000..df07964c0 |
| --- /dev/null |
| +++ b/internal/host/relocationreleases_test.go |
| @@ -0,0 +1,62 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestStopReceiptReleasesBindsTheReceiptToTheRelease(t *testing.T) { |
| + ctx := context.Background() |
| + inc := strings.Repeat("a", 64) |
| + c := hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Incarnation: inc}, Op: hostclaim.Op{ID: "rel-1", Kind: hostclaim.KindRelocating, Phase: hostclaim.PhaseReleasing}} |
| + receipt := StopReceipt{V: 1, ID: "r1", Host: "shop.example", Incarnation: inc, WriterSession: 12, OperationID: "rel-1", Phase: hostclaim.PhaseReleasing, ObligationID: RelocationReleaseObligation, |
| + Box: "src", Enrollment: "enr", Kind: "stopped", EvidenceAt: time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)} |
| + put := func(s release.Store, r StopReceipt) hostclaim.Release { |
| + body := mustMarshalStopReceipt(r) |
| + key := "control/hosts/shop.example/evidence/" + r.OperationID + "/" + r.Box + "/" + r.ID + ".json" |
| + if _, err := s.Put(ctx, key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(body) |
| + return hostclaim.Release{ReleasedBy: "src", Enrollment: "enr", Session: 12, Receipt: key, ReceiptSHA256: hex.EncodeToString(sum[:])} |
| + } |
| + s := release.NewDirStore(t.TempDir()) |
| + a := StopReceiptReleases{Evidence: s} |
| + if err := a.VerifyRelease(ctx, c, put(s, receipt)); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, tc := range []struct { |
| + name string |
| + mutate func(*StopReceipt, *hostclaim.Release) |
| + }{ |
| + {"another session", func(r *StopReceipt, _ *hostclaim.Release) { r.WriterSession = 11 }}, |
| + {"another box", func(r *StopReceipt, _ *hostclaim.Release) { r.Box = "dst" }}, |
| + {"another operation", func(r *StopReceipt, _ *hostclaim.Release) { r.OperationID = "rel-0" }}, |
| + {"a retirement's stop", func(r *StopReceipt, _ *hostclaim.Release) { r.ObligationID = "park-1" }}, |
| + {"another phase", func(r *StopReceipt, _ *hostclaim.Release) { r.Phase = hostclaim.PhaseProbeFailed }}, |
| + {"another incarnation", func(r *StopReceipt, _ *hostclaim.Release) { r.Incarnation = strings.Repeat("b", 64) }}, |
| + {"a digest that is not the bytes", func(_ *StopReceipt, rel *hostclaim.Release) { rel.ReceiptSHA256 = strings.Repeat("0", 64) }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + r := receipt |
| + var mutated hostclaim.Release |
| + tc.mutate(&r, &mutated) |
| + rel := put(s, r) |
| + if mutated.ReceiptSHA256 != "" { |
| + rel.ReceiptSHA256 = mutated.ReceiptSHA256 |
| + } |
| + if err := (StopReceiptReleases{Evidence: s}).VerifyRelease(ctx, c, rel); !errors.Is(err, ErrClaimStopReceipt) { |
| + t.Fatalf("accepted: %v", err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostexec/readlocal.go b/internal/hostexec/readlocal.go| new file mode 100644 |
| index 000000000..778bce234 |
| --- /dev/null |
| +++ b/internal/hostexec/readlocal.go |
| @@ -0,0 +1,17 @@ |
| +package hostexec |
| + |
| +// ReadLocal reads host's durable local record (<dataDir>/.hostexec/<host>.json) |
| +// with the validation Open, CheckLocal and ClearStale apply to it: a |
| +// regular file, a complete binding (box, enrollment, host, incarnation, |
| +// op, boot and controller ids), a known state. An absent record is |
| +// os.ErrNotExist; anything it would refuse is an error, never a State. |
| +// It takes no lock and grants nothing: a caller deciding whether a record |
| +// is still its own uses it, and keeps its own state on any error. |
| +func ReadLocal(dataDir, host string) (State, error) { |
| + d, err := openLocal(dataDir) |
| + if err != nil { |
| + return State{}, err |
| + } |
| + defer d.close() |
| + return d.read(host) |
| +} |
diff --git a/internal/hostfiles/platform.go b/internal/hostfiles/platform.go| new file mode 100644 |
| index 000000000..18dbbe48e |
| --- /dev/null |
| +++ b/internal/hostfiles/platform.go |
| @@ -0,0 +1,33 @@ |
| +package hostfiles |
| + |
| +import ( |
| + "fmt" |
| + "strings" |
| +) |
| + |
| +// EnvItem is the host's env file (<host>.env) among the manifest's |
| +// platform items. A release flush stages it beside the wake state so a |
| +// relocation's prepared manifest carries it (region HA spec 1 §5.6: |
| +// always continuous, whatever the setting); the local install restores it |
| +// with ReadPlatform. |
| +const EnvItem = "host.env" |
| + |
| +// StagePlatformDelete records that a platform item is gone: the next |
| +// Commit publishes a revision without it, at a new sequence, so a reader |
| +// of that revision gets os.ErrNotExist from ReadPlatform instead of the |
| +// last bytes it published. Deleting an item the manifest does not hold |
| +// is a no-op, like StageDelete. |
| +func (p *Publisher) StagePlatformDelete(name string) error { |
| + if name == "" || strings.ContainsAny(name, "/\x00") { |
| + return fmt.Errorf("hostfiles: platform item %q is not a plain name", name) |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if _, ok := p.man.Platform[name]; !ok { |
| + return nil |
| + } |
| + p.next++ |
| + delete(p.man.Platform, name) |
| + p.dirty = true |
| + return nil |
| +} |
diff --git a/internal/hostrelocate/participant.go b/internal/hostrelocate/participant.go| index 8031b7012..c44ccabfd 100644 |
| --- a/internal/hostrelocate/participant.go |
| +++ b/internal/hostrelocate/participant.go |
| @@ -25,7 +25,9 @@ type Effects interface { |
| Acknowledge(context.Context, hostclaim.Observed) (hostclaim.ExecutionAcknowledgement, error) |
| // Ready reports whether this box's execution controller for the |
| // operation is open and active now: ErrNoController when none is |
| - // retained (the agent restarted), hostexec's error when it was lost. |
| + // retained (the agent restarted, or a controller that lost its lease |
| + // was joined and its scoped cleanup repeated), and the join's or the |
| + // cleanup's error while a lost one cannot be retired yet. |
| // Every effect after the acknowledgement runs only after Ready. |
| Ready(context.Context, hostclaim.Observed) error |
| // Release runs §5.8's release sequence for this box's current writer |