claim Plan 14 Part 1: claims-off boxes with no root census update and recover exactly as on main
Task D3 (claim participation) applies only with CARLOS_HOST_CLAIM on or a root census present; it was wrongly folded into the approved D3 health tightening. Tests and a mutation witness pin the claims-off/no-census path.
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+482
−47
internal/host/guardtransaction.go+24 −1internal/host/participation_flagoff_test.go+165 −0internal/host/rootexecution_recovery.go+10 −3internal/host/systemparticipation.go+41 −7internal/hostfloor/disk.go+55 −13internal/hostfloor/participation.go+13 −3internal/hostfloor/participation_flagoff_test.go+111 −0internal/hostfloor/participation_test.go+9 −4internal/provision/testdata/plan14-root-reads-merge.tsv+18 −16internal/rootparticipation/production.go+36 −0
diff --git a/internal/host/guardtransaction.go b/internal/host/guardtransaction.go| index 57430230d..30ef1013d 100644 |
| --- a/internal/host/guardtransaction.go |
| +++ b/internal/host/guardtransaction.go |
| @@ -5,6 +5,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "github.com/carlosframework/platform/internal/hostactors" |
| + execlease "github.com/carlosframework/platform/internal/hostexec" |
| "github.com/carlosframework/platform/internal/hostfloor" |
| "github.com/carlosframework/platform/internal/release" |
| "os" |
| @@ -316,12 +317,34 @@ func (a *systemApplier) controllersRecovered() error { |
| if a.floor != nil { |
| guardDir = a.floor.Dir |
| } |
| - if recoveryErr := recoverPendingControllers(context.Background(), a.dataDir, guardDir); recoveryErr != nil { |
| + required, checkErr := a.participationDisk().ParticipationRequired(context.Background()) |
| + if checkErr != nil { |
| + return errors.Join(err, checkErr) |
| + } |
| + recover := recoverPendingControllers |
| + if !required { |
| + recover = execlease.RecoverPending |
| + } |
| + if recoveryErr := recover(context.Background(), a.dataDir, guardDir); recoveryErr != nil { |
| return errors.Join(err, recoveryErr) |
| } |
| return a.controllersIdle() |
| } |
| func (a *systemApplier) closeGuardJournal() error { |
| + required, err := a.participationDisk().ParticipationRequired(context.Background()) |
| + if err != nil { |
| + return err |
| + } |
| + if !required { |
| + if !a.guarded() { |
| + a.removeJournal() |
| + return nil |
| + } |
| + if err := hostfloor.RemoveDurable(a.file("journal.json")); err != nil { |
| + return err |
| + } |
| + return a.endGuard() |
| + } |
| active, err := a.activeFloor() |
| if err != nil { |
| return err |
diff --git a/internal/host/participation_flagoff_test.go b/internal/host/participation_flagoff_test.go| new file mode 100644 |
| index 000000000..b223e42dc |
| --- /dev/null |
| +++ b/internal/host/participation_flagoff_test.go |
| @@ -0,0 +1,165 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func TestPlan14ParticipationFlagOffUpdate(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "") |
| + for _, region := range []bool{false, true} { |
| + for _, mirror := range []string{"absent", "empty", "pending", "symlink"} { |
| + t.Run(map[bool]string{false: "region-off", true: "region-on"}[region]+"/"+mirror, func(t *testing.T) { |
| + f, _ := applyHappy(t) |
| + f.a.regionHA = region |
| + if err := f.a.participation.State.Remove("census.json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.a.participation.VerifyLaunches = nil |
| + switch mirror { |
| + case "empty": |
| + hostexec(t, f) |
| + case "pending": |
| + if err := os.WriteFile(filepath.Join(hostexec(t, f), "pending.json"), []byte(`{}`), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "symlink": |
| + if err := os.Symlink(t.TempDir(), filepath.Join(f.a.dataDir, hostexecDir)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := f.a.Run(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := systemOutcomeApplied |
| + if region && (mirror == "pending" || mirror == "symlink") { |
| + want = systemOutcomeDeferred |
| + } |
| + if got := f.result(); got == nil || got.Outcome != want { |
| + t.Fatalf("result=%+v want=%s", got, want) |
| + } |
| + if _, err := f.a.participation.UpdateID(); !os.IsNotExist(err) { |
| + t.Fatalf("legacy update created root reservation: %v", err) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestPlan14ParticipationFlagOffRecovery(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "") |
| + for _, journal := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "marker", true: "journal"}[journal], func(t *testing.T) { |
| + f := newApplyFixture(t, "stable", "v1") |
| + if err := f.a.participation.State.Remove("census.json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.a.participation.VerifyLaunches = nil |
| + if err := os.MkdirAll(systemGuardDir(f.a.optDir), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(updateBegunPath(f.a.optDir), []byte(`{"target":"v2"}`), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if journal { |
| + if err := f.a.journal(systemJournal{Phase: systemPhaseBackingUp, Target: "v2", PrevBuild: "v1", StartedAt: f.now}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + old := recoverPendingControllers |
| + t.Cleanup(func() { recoverPendingControllers = old }) |
| + recoverPendingControllers = func(context.Context, string, string) error { |
| + t.Fatal("legacy recovery called root participation recovery") |
| + return nil |
| + } |
| + if err := f.a.Run(t.Context(), true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(updateBegunPath(f.a.optDir)); !os.IsNotExist(err) { |
| + t.Fatalf("legacy marker retained: %v", err) |
| + } |
| + if j, err := readSystemJournal(f.a.optDir); err != nil || j != nil { |
| + t.Fatalf("journal=%+v err=%v", j, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14ParticipationFlagAndCensusExclusion(t *testing.T) { |
| + for _, flag := range []string{"", "1"} { |
| + for _, census := range []bool{false, true} { |
| + t.Run(flag+map[bool]string{false: "/missing", true: "/present"}[census], func(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", flag) |
| + f := newApplyFixture(t, "stable", "old") |
| + if !census { |
| + if err := f.a.participation.State.Remove("census.json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + f.a.participation.VerifyLaunches = nil |
| + err := f.a.beginExecutableChange("new", f.now) |
| + if (err != nil) != (flag != "" || census) { |
| + t.Fatalf("admission error=%v flag=%q census=%t", err, flag, census) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestPlan14ParticipationFlagOffControllersRecovered(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "") |
| + f := newApplyFixture(t, "stable", "v1") |
| + if err := f.a.participation.State.Remove("census.json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + old := recoverPendingControllers |
| + t.Cleanup(func() { recoverPendingControllers = old }) |
| + recoverPendingControllers = func(context.Context, string, string) error { |
| + t.Fatal("legacy controller invoked root recovery") |
| + return nil |
| + } |
| + dir := hostexec(t, f) |
| + if err := f.a.controllersRecovered(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, "pending.json"), []byte(`{}`), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.a.controllersRecovered(); err == nil { |
| + t.Fatal("malformed legacy controller admitted") |
| + } |
| +} |
| + |
| +func TestPlan14ParticipationFlagOffGuardRecovery(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "") |
| + f, id := sharedGuardFixture(t) |
| + a := f.a |
| + if err := a.participation.State.Remove("census.json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + a.participation.VerifyLaunches = nil |
| + if err := a.floor.Begin(id, a.dataDir); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := a.beginExecutableChange("new-build", f.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := a.journal(systemJournal{Phase: systemPhaseBackingUp, Target: "new-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := a.Run(t.Context(), true); err != nil { |
| + t.Fatal(err) |
| + } |
| + completed, err := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json")) |
| + if err != nil || string(completed) != id { |
| + t.Fatalf("completion=%q err=%v", completed, err) |
| + } |
| + if _, err := os.Stat(updateBegunPath(a.optDir)); !os.IsNotExist(err) { |
| + t.Fatalf("legacy floor marker retained: %v", err) |
| + } |
| + if _, err := a.participation.UpdateID(); !os.IsNotExist(err) { |
| + t.Fatalf("legacy recovery created root reservation: %v", err) |
| + } |
| +} |
diff --git a/internal/host/rootexecution_recovery.go b/internal/host/rootexecution_recovery.go| index 3c943733e..4edc29977 100644 |
| --- a/internal/host/rootexecution_recovery.go |
| +++ b/internal/host/rootexecution_recovery.go |
| @@ -6,18 +6,25 @@ import ( |
| "path/filepath" |
| "time" |
| + execlease "github.com/carlosframework/platform/internal/hostexec" |
| "github.com/carlosframework/platform/internal/rootparticipation" |
| ) |
| // Recovery constructs only root's local readers and settlement machinery. |
| // It never starts the monitor, contacts instance-control, or calls Execute. |
| func recoverRootExecution(ctx context.Context, dataDir, guardDir string) (result error) { |
| - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) |
| - defer cancel() |
| - ledger, err := rootparticipation.Open() |
| + ledger, required, err := rootparticipation.OpenForUpdate() |
| if err != nil { |
| return err |
| } |
| + if !required { |
| + if ledger.State != nil { |
| + ledger.State.Close() |
| + } |
| + return execlease.RecoverPending(ctx, dataDir, guardDir) |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) |
| + defer cancel() |
| census, err := ledger.RecoveryCensus() |
| ledger.State.Close() |
| if err != nil { |
diff --git a/internal/host/systemparticipation.go b/internal/host/systemparticipation.go| index ca1f9bca6..5041b7add 100644 |
| --- a/internal/host/systemparticipation.go |
| +++ b/internal/host/systemparticipation.go |
| @@ -35,9 +35,9 @@ type updateBegun struct { |
| At time.Time `json:"at"` |
| } |
| -// hostexecParticipation requires root participation and launch completeness. |
| +// hostexecParticipation preserves the legacy region-HA check until root owns a census. |
| func hostexecParticipation(dataDir string) error { |
| - return hostfloor.CheckControllersIdle(dataDir) |
| + return hostfloor.CheckParticipation(dataDir, false) |
| } |
| // beginExecutableChange is §5.10's root side, run under the apply lock |
| @@ -49,6 +49,13 @@ func hostexecParticipation(dataDir string) error { |
| // update is refused and retried later). The agent's leg re-requests a |
| // deferred apply on its backoff ladder, at most every five minutes. |
| func (a *systemApplier) beginExecutableChange(target string, promotedAt time.Time) error { |
| + required, err := a.participationDisk().ParticipationRequired(context.Background()) |
| + if err != nil { |
| + return err |
| + } |
| + if !required && !a.regionHA { |
| + return nil |
| + } |
| active, err := a.activeFloor() |
| if err != nil { |
| return err |
| @@ -78,13 +85,25 @@ func (a *systemApplier) beginExecutableChange(target string, promotedAt time.Tim |
| if !a.regionHA { |
| return a.participationDisk().WithParticipation(context.Background(), func(l rootparticipation.Ledger) error { |
| return l.BeginUpdate(context.Background(), "system-"+target) |
| - }) |
| + }, func() error { return nil }) |
| } |
| - err = a.participationDisk().BeginParticipation(context.Background(), "system-"+target, a.dataDir, func() error { |
| + writeMarker := func() error { |
| if err := os.MkdirAll(systemGuardDir(a.optDir), 0o755); err != nil { |
| return err |
| } |
| return writeJSONDurable(updateBegunPath(a.optDir), updateBegun{Target: target, PromotedAt: promotedAt, At: a.ops.now().UTC()}, 0o644) |
| + } |
| + err = a.participationDisk().BeginParticipation(context.Background(), "system-"+target, a.dataDir, writeMarker, func() error { |
| + if err := writeMarker(); err != nil { |
| + return err |
| + } |
| + if reason := a.participationDisk().CheckParticipation(context.Background(), a.dataDir, false); reason != nil { |
| + if err := a.clearLegacyUpdateBegun(); err != nil { |
| + a.ops.logf("system apply: %v", err) |
| + } |
| + return reason |
| + } |
| + return nil |
| }) |
| if err != nil { |
| return fmt.Errorf("%w: %v", errSystemDeferred, err) |
| @@ -112,7 +131,18 @@ func (a *systemApplier) clearUpdateBegun() error { |
| } |
| return hostfloor.RemoveDurable(updateBegunPath(a.optDir)) |
| }) |
| - }) |
| + }, a.clearLegacyUpdateBegun) |
| +} |
| + |
| +func (a *systemApplier) clearLegacyUpdateBegun() error { |
| + err := os.Remove(updateBegunPath(a.optDir)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } |
| + if err != nil { |
| + return fmt.Errorf("remove %s: %w", updateBegunPath(a.optDir), err) |
| + } |
| + return syncDir(systemGuardDir(a.optDir)) |
| } |
| // settleUpdateBegun clears the marker once no update is in progress: no |
| @@ -165,6 +195,10 @@ func (a *systemApplier) participationDisk() hostfloor.Disk { |
| } |
| func (a *systemApplier) checkUnarmedParticipationRecovery() error { |
| + required, err := a.participationDisk().ParticipationRequired(context.Background()) |
| + if err != nil || !required { |
| + return err |
| + } |
| j, err := readSystemJournal(a.optDir) |
| if err != nil { |
| return err |
| @@ -182,7 +216,7 @@ func (a *systemApplier) checkUnarmedParticipationRecovery() error { |
| } |
| recover = true |
| return nil |
| - }) |
| + }, func() error { return nil }) |
| if err != nil { |
| return err |
| } |
| @@ -192,7 +226,7 @@ func (a *systemApplier) checkUnarmedParticipationRecovery() error { |
| } |
| } |
| if reserve { |
| - return a.participationDisk().WithParticipation(context.Background(), func(l rootparticipation.Ledger) error { return l.BeginUpdate(context.Background(), "recover-system") }) |
| + return a.participationDisk().WithParticipation(context.Background(), func(l rootparticipation.Ledger) error { return l.BeginUpdate(context.Background(), "recover-system") }, func() error { return nil }) |
| } |
| return nil |
| } |
diff --git a/internal/hostfloor/disk.go b/internal/hostfloor/disk.go| index 989306f84..a4a9862a8 100644 |
| --- a/internal/hostfloor/disk.go |
| +++ b/internal/hostfloor/disk.go |
| @@ -149,7 +149,20 @@ func (d Disk) Begin(id, dataDir string) error { |
| raw, _ := json.Marshal(struct { |
| ID string `json:"id"` |
| }{id}) |
| - return d.BeginParticipation(context.Background(), id, dataDir, func() error { return Durable(marker, raw, 0644) }) |
| + return d.WithParticipation(context.Background(), func(l rootparticipation.Ledger) error { |
| + if err := checkLegacyParticipation(dataDir, false); err != nil { |
| + return err |
| + } |
| + return l.BeginUpdateWith(context.Background(), id, func() error { return Durable(marker, raw, 0644) }) |
| + }, func() error { |
| + if err := Durable(marker, raw, 0644); err != nil { |
| + return err |
| + } |
| + if err := checkLegacyParticipation(dataDir, true); err != nil { |
| + return errors.Join(err, RemoveDurable(marker)) |
| + } |
| + return nil |
| + }) |
| } |
| // CheckControllersIdle refuses while any controller state is present. The |
| @@ -181,16 +194,33 @@ func UpdateIdle(guardDir, journal string) error { |
| return nil |
| } |
| -func (d Disk) WithParticipation(ctx context.Context, fn func(rootparticipation.Ledger) error) error { |
| - if d.Participation != nil { |
| - return fn(*d.Participation) |
| +func (d Disk) WithParticipation(ctx context.Context, fn func(rootparticipation.Ledger) error, legacy func() error) error { |
| + l := d.Participation |
| + var required bool |
| + var err error |
| + if l != nil { |
| + required, err = l.UpdateRequired() |
| + } else { |
| + var opened rootparticipation.Ledger |
| + opened, required, err = rootparticipation.OpenForUpdate() |
| + l = &opened |
| + if l.State != nil { |
| + defer l.State.Close() |
| + } |
| } |
| - l, err := rootparticipation.Open() |
| if err != nil { |
| return err |
| } |
| - defer l.State.Close() |
| - return fn(l) |
| + if !required { |
| + return legacy() |
| + } |
| + return fn(*l) |
| +} |
| + |
| +func (d Disk) ParticipationRequired(ctx context.Context) (bool, error) { |
| + required := false |
| + err := d.WithParticipation(ctx, func(rootparticipation.Ledger) error { required = true; return nil }, func() error { return nil }) |
| + return required, err |
| } |
| func (d Disk) ControllersIdle(ctx context.Context, dataDir ...string) error { |
| @@ -199,17 +229,24 @@ func (d Disk) ControllersIdle(ctx context.Context, dataDir ...string) error { |
| return err |
| } |
| if len(dataDir) == 1 { |
| - return checkLegacyParticipationHint(dataDir[0]) |
| + return checkLegacyParticipation(dataDir[0], false) |
| } |
| return nil |
| + }, func() error { |
| + if len(dataDir) != 1 { |
| + return ErrFloor |
| + } |
| + return checkLegacyParticipation(dataDir[0], true) |
| }) |
| } |
| -func (d Disk) BeginParticipation(ctx context.Context, id, dataDir string, commit func() error) error { |
| - if err := checkLegacyParticipationHint(dataDir); err != nil { |
| - return err |
| - } |
| - return d.WithParticipation(ctx, func(l rootparticipation.Ledger) error { return l.BeginUpdateWith(ctx, id, commit) }) |
| +func (d Disk) BeginParticipation(ctx context.Context, id, dataDir string, commit, legacy func() error) error { |
| + return d.WithParticipation(ctx, func(l rootparticipation.Ledger) error { |
| + if err := checkLegacyParticipation(dataDir, false); err != nil { |
| + return err |
| + } |
| + return l.BeginUpdateWith(ctx, id, commit) |
| + }, legacy) |
| } |
| func (d Disk) EndParticipation(ctx context.Context, id string, commit func() error) error { |
| @@ -222,5 +259,10 @@ func (d Disk) EndParticipation(ctx context.Context, id string, commit func() err |
| return err |
| } |
| return l.EndUpdateWith(ctx, id, commit) |
| + }, func() error { |
| + if commit != nil { |
| + return commit() |
| + } |
| + return nil |
| }) |
| } |
diff --git a/internal/hostfloor/participation.go b/internal/hostfloor/participation.go| index 4c7387539..9d24596ec 100644 |
| --- a/internal/hostfloor/participation.go |
| +++ b/internal/hostfloor/participation.go |
| @@ -9,19 +9,29 @@ import ( |
| "sort" |
| "strings" |
| + "github.com/carlosframework/platform/internal/rootparticipation" |
| "github.com/carlosframework/platform/internal/safefile" |
| ) |
| // CheckParticipation reads only directory entries. Floor-armed boxes require |
| // the controller directory; legacy region HA boxes may not have one yet. |
| func CheckParticipation(dataDir string, requireDirectory bool) error { |
| - return (Disk{}).ControllersIdle(context.Background(), dataDir) |
| + return (Disk{}).CheckParticipation(context.Background(), dataDir, requireDirectory) |
| } |
| -func checkLegacyParticipationHint(dataDir string) error { |
| +func (d Disk) CheckParticipation(ctx context.Context, dataDir string, requireDirectory bool) error { |
| + return d.WithParticipation(ctx, func(l rootparticipation.Ledger) error { |
| + if err := l.Idle(ctx); err != nil { |
| + return err |
| + } |
| + return checkLegacyParticipation(dataDir, false) |
| + }, func() error { return checkLegacyParticipation(dataDir, requireDirectory) }) |
| +} |
| + |
| +func checkLegacyParticipation(dataDir string, requireDirectory bool) error { |
| dir := filepath.Join(dataDir, ".hostexec") |
| f, err := safefile.OpenDir(dir) |
| - if errors.Is(err, os.ErrNotExist) { |
| + if errors.Is(err, os.ErrNotExist) && !requireDirectory { |
| return nil |
| } |
| if err != nil { |
diff --git a/internal/hostfloor/participation_flagoff_test.go b/internal/hostfloor/participation_flagoff_test.go| new file mode 100644 |
| index 000000000..2e72bc0c8 |
| --- /dev/null |
| +++ b/internal/hostfloor/participation_flagoff_test.go |
| @@ -0,0 +1,111 @@ |
| +package hostfloor |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func TestPlan14ParticipationFlagOffFloor(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "") |
| + for _, mirror := range []string{"absent", "empty", "pending", "symlink"} { |
| + t.Run(mirror, func(t *testing.T) { |
| + d := Disk{Dir: t.TempDir(), Participation: floorParticipationFixture(t)} |
| + if err := d.Participation.State.Remove("census.json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + d.Participation.VerifyLaunches = nil |
| + data := t.TempDir() |
| + dir := filepath.Join(data, ".hostexec") |
| + if mirror == "empty" || mirror == "pending" { |
| + if err := os.Mkdir(dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if mirror == "pending" { |
| + if err := os.WriteFile(filepath.Join(dir, "pending.json"), []byte(`{}`), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if mirror == "symlink" { |
| + if err := os.Symlink(t.TempDir(), dir); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + for _, strict := range []bool{false, true} { |
| + want := mirror == "empty" || (!strict && mirror == "absent") |
| + if err := d.CheckParticipation(t.Context(), data, strict); (err == nil) != want { |
| + t.Fatalf("strict=%t err=%v want admitted=%t", strict, err, want) |
| + } |
| + } |
| + want := mirror == "empty" |
| + if err := d.ControllersIdle(t.Context(), data); (err == nil) != want { |
| + t.Fatalf("idle=%v want admitted=%t", err, want) |
| + } |
| + if err := d.Begin("legacy-update", data); (err == nil) != want { |
| + t.Fatalf("begin=%v want admitted=%t", err, want) |
| + } |
| + marker := filepath.Join(d.Dir, "update-begun.json") |
| + if _, err := os.Stat(marker); (err == nil) != want { |
| + t.Fatalf("marker=%v", err) |
| + } |
| + if want { |
| + if err := d.EndParticipation(t.Context(), "legacy-update", func() error { return RemoveDurable(marker) }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(marker); !os.IsNotExist(err) { |
| + t.Fatalf("completion retained marker: %v", err) |
| + } |
| + } |
| + if _, err := d.Participation.UpdateID(); !os.IsNotExist(err) { |
| + t.Fatalf("legacy floor created root reservation: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14ParticipationRetainedCensusFloor(t *testing.T) { |
| + for _, flag := range []string{"", "1"} { |
| + for _, state := range []string{"idle", "pending", "no-launch-proof", "malformed", "missing"} { |
| + t.Run(flag+"/"+state, func(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", flag) |
| + d := Disk{Dir: t.TempDir(), Participation: floorParticipationFixture(t)} |
| + data := t.TempDir() |
| + if err := os.Mkdir(filepath.Join(data, ".hostexec"), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch state { |
| + case "pending": |
| + if err := d.Participation.State.Write("shop.example.json", []byte(`{"Status":"pending"}`)); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "no-launch-proof": |
| + d.Participation.VerifyLaunches = nil |
| + case "malformed": |
| + if err := d.Participation.State.Write("census.json", []byte(`{`)); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "missing": |
| + if err := d.Participation.State.Remove("census.json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + want := state == "idle" || (state == "missing" && flag == "") |
| + if err := d.ControllersIdle(t.Context(), data); (err == nil) != want { |
| + t.Fatalf("idle=%v want admitted=%t", err, want) |
| + } |
| + if err := d.Begin("update", data); (err == nil) != want { |
| + t.Fatalf("begin=%v want admitted=%t", err, want) |
| + } |
| + if state == "idle" { |
| + if err := d.Participation.Acquire(t.Context(), func() error { t.Fatal("acquired across update"); return nil }); err == nil { |
| + t.Fatal("update did not exclude acquisition") |
| + } |
| + if err := d.EndParticipation(t.Context(), "update", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + }) |
| + } |
| + } |
| +} |
diff --git a/internal/hostfloor/participation_test.go b/internal/hostfloor/participation_test.go| index 2a2383b25..a4734bb34 100644 |
| --- a/internal/hostfloor/participation_test.go |
| +++ b/internal/hostfloor/participation_test.go |
| @@ -7,18 +7,23 @@ import ( |
| ) |
| func TestParticipationPreservesFloorAndLegacyDirectoryRules(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "") |
| + d := Disk{Participation: floorParticipationFixture(t)} |
| + if err := d.Participation.State.Remove("census.json"); err != nil { |
| + t.Fatal(err) |
| + } |
| data := t.TempDir() |
| - if err := CheckParticipation(data, false); err == nil { |
| - t.Fatal("legacy directory absence replaced root completeness") |
| + if err := d.CheckParticipation(t.Context(), data, false); err != nil { |
| + t.Fatal(err) |
| } |
| - if err := CheckControllersIdle(data); err == nil { |
| + if err := d.ControllersIdle(t.Context(), data); err == nil { |
| t.Fatal("floor accepted a missing controller directory") |
| } |
| if err := os.Symlink(t.TempDir(), filepath.Join(data, ".hostexec")); err != nil { |
| t.Skip(err) |
| } |
| for _, strict := range []bool{false, true} { |
| - if err := CheckParticipation(data, strict); err == nil { |
| + if err := d.CheckParticipation(t.Context(), data, strict); err == nil { |
| t.Fatalf("symlink accepted with requireDirectory=%v", strict) |
| } |
| } |
diff --git a/internal/provision/testdata/plan14-root-reads-merge.tsv b/internal/provision/testdata/plan14-root-reads-merge.tsv| index 9ee12c675..f70dc98b9 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-merge.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-merge.tsv |
| @@ -250,7 +250,7 @@ internal/sim/hostlife/mutate.sh external legacy accepted-legacy accepted-legacy; |
| @coverage internal/console 41714f3779b343460800cb25b31782dc7d5c1e83b2aacf7d8d0a28f39c47bf76 |
| @coverage internal/console/templates 6492b7455fa3315105339cca03edb4372ac0d9bf746c018e13a308cf0b74cc05 |
| @coverage internal/edgesfile 553af147ca4882b8425afddf1ed250ee40a6cac92361d667cb4a690393be5272 |
| -@coverage internal/host d387b30eb462bab6a344e07efee7ad3954cffca8dfa9fb0dbf888dd9c41dc361 |
| +@coverage internal/host 101625bb4dc26d58bb36d71a589b631d27e6d7b2a3298a0458342826606604df |
| @coverage internal/hostbox afb08c287eaccb43adde1c488bd122482711ce4e29ecb675c9428f27130c3d93 |
| @coverage internal/hostclaim feaf701db4b49ce92733b9d6c1fa1c0ae8efc6e13439ce201fca1a9e24dcf927 |
| @coverage internal/hostfiles 554285c123758375c13388b2fa42f654819392dac0b027816a713548ec17dcc6 |
| @@ -268,7 +268,7 @@ internal/sim/hostlife/mutate.sh external legacy accepted-legacy accepted-legacy; |
| @coverage internal/status d5ef42fe4dc2cb72d1ce5e133e2c95a3e85960a222eb424f00b259b961b8d976 |
| @coverage internal/updatewords 93bb83474bad5d20281c34e72fadf56e2847e9a7eee2385a78f46a11fc39a3b6 |
| @coverage internal/hostexec 778261669d5e079b9f98d74807557fd5de5c4e0f663bd0f9d380cdad77ffbcf8 |
| -@coverage internal/rootparticipation 99764b1531e32b6065716e7b92964de53e22448cc76644e4ac9acfab02fdcf07 |
| +@coverage internal/rootparticipation aca36bd46a7879b200c765a3e66ed01329158546a258de6facc922c95e18bf75 |
| internal/host/rootexecution_authority.go ClaimRootHTTP.executionAuthority authority D Fresh claim, deployment binding and root installation authorize participation, including the retained source only. file:Read@553 107989f561409fd8c11c2666d0672b8dbeb368842fe00ad6a7b84fece2150438 |
| internal/host/rootexecution_read.go rootExecutionRead authority D Production root observer selects the descriptor-checked participation reader. c70216cd8e62430b43669d6cea5bcb9101fcb31c04aea178a951a620c369b1e5 |
| internal/hostexec/participation.go PreinstallationParticipant authority D D1 selects only the fresh claim executor or named target in its allowed phase; Exec is a claim field, not a process invocation. No installation authority. file:Exec@1035,file:Exec@1307,file:Exec@276 f14568ea08c9d439d7239ac9d5d596a134a17627e1eac878c074629ca2ba7304 |
| @@ -291,28 +291,30 @@ internal/host/rootexecution_migration.go ClaimRootHTTP.executionAuthorityWithMig |
| internal/hostexec/rootbroker.go RootBroker.recoverSettlement authority D Recovery serializes settlement with in-flight requests and requires cleanup, exact conditional release and CC9. TestPlan14ExecutionRecoverySettlesWithoutControllerToken and TestPlan14ExecutionRecoveryJoinsRemoteWrite. 88c7b2d1a863590e549a2477732badcb6e003289da8db46aee16a1d777a8f77d |
| internal/host/claimparticipation.go productionClaimLocal.participateHost authority D Production selects the broker client; the old local controller path is selected only by explicit legacy test fixtures. file:Read@121 035931959f2b44012e10624314b90bad360ca77f8fdf3d8300e2a29ffd33c0fb |
| internal/host/claimparticipation.go productionClaimLocal.prepareLegacyWith authority D Production selects the broker client; the old local controller path is selected only by explicit legacy test fixtures. file:Open@3226,file:ReadFile@2116 20a34982bf5184cba4e4691040ede402a2c93ccbc0571c9483ad28843a298673 |
| -internal/host/guardtransaction.go systemApplier.checkGuardRecovery authority D Root participation exclusion persists through guard transaction completion and recovery. file:ReadFile@408,file:ReadFile@635 b1efe974172ec1e07bb27dc98e39b56af2eb459afd28335123451dc14fa21e40 |
| +internal/host/guardtransaction.go systemApplier.checkGuardRecovery authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:ReadFile@408,file:ReadFile@635 b1efe974172ec1e07bb27dc98e39b56af2eb459afd28335123451dc14fa21e40 |
| internal/host/rootexecution_client.go productionClaimLocal.prepareWith authority D Agent selects the single root lease owner over authenticated RPC; its mirror grants no root authority. file:Read@3239 269e72c57424e72d9fdbdd9191eac625877cca4ef1eff12e66d55299c727409b |
| -internal/host/rootexecution_recovery.go recoverRootExecution authority D Root recovery reads its complete census and reconstructs local cleanup without agent or broker RPC. file:Open@179 362b728a79c7542a5ec6ac980072e9ad2358ee09ee27f8f0b2dbd842581b9019 |
| +internal/host/rootexecution_recovery.go recoverRootExecution authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. 5d0d5ecefd1fa2ca18068408dbaa7bff327a16d05d170580af0ac7c066ea2559 |
| internal/host/rootexecution_runtime.go newClaimExecutionRuntime authority D Instance-control owns broker, checked store, monitor and bounded cleanup lifetime. file:Read@205 89dd8159a5181d315a3febff26646919ec4135807ea2cd2b1f89847d9f512f47 |
| internal/host/rootexecution_runtime.go var openClaimExecutionAuthority authority D Instance-control owns broker, checked store, monitor and bounded cleanup lifetime. file:Open@76 dc9e1a3d2ccc63de2eec84fe1727d4c741372a227eb423d508749c4347731433 |
| internal/host/rootexecution_settle.go ClaimRootHTTP.beginExecutionRecoveryFence authority D Root cleanup checks exact retained installation, launch journal and kernel evidence; CC9 requires a fresh terminal or exclusion claim. file:Read@146 dfbfdf5ae974363678e8e395ef0be2a64ea8c81c3f97ba766eeadccbba9c2fe6 |
| internal/host/rootexecution_settle.go ClaimRootHTTP.cleanupExecution authority D Root cleanup checks exact retained installation, launch journal and kernel evidence; CC9 requires a fresh terminal or exclusion claim. file:Read@2438,file:Read@3761 9f715acdc31d97f33c1ab6efe227bd248a91a7a150498974c151426c943a9025 |
| internal/host/rootexecution_settle.go ClaimRootHTTP.settleExecution authority D Root cleanup checks exact retained installation, launch journal and kernel evidence; CC9 requires a fresh terminal or exclusion claim. file:Read@108 ecd07f9675915c9cc747246369f624bbdf9a6f45369f3efdc1b2b49a8bd0e69b |
| -internal/host/systemparticipation.go systemApplier.beginExecutableChange authority D Executable updates use the root census and durable shared exclusion even after flag disable; legacy markers cannot prove idle. file:ReadFile@397 f3f8d6c7c81d17987898f26d2d0d6cc2434a6d1ed416eebb93e0b5318c5efe5b |
| -internal/host/systemparticipation.go systemApplier.clearUpdateBegun authority D Executable updates use the root census and durable shared exclusion even after flag disable; legacy markers cannot prove idle. file:Lstat@240,file:Lstat@522 0bd119032eed23a907985286105fa86bce820a0e5fcb26ae5ac8317718395497 |
| -internal/hostfloor/disk.go Disk.Begin authority D Floor begin and completion serialize with root acquisition through the same durable transaction marker. file:Stat@151 6fb7ebd46a718df5d2f42531b8d62d97183c938d46f89c0ce3abdb71533494a9 |
| -internal/hostfloor/disk.go Disk.WithParticipation authority D Floor begin and completion serialize with root acquisition through the same durable transaction marker. file:Open@175 3c0aa824ccfac423f0682463fd948f168ea00ef2894a321f6abb9effbf157891 |
| -internal/hostfloor/participation.go CheckParticipation authority D Service files add refusal hints only; missing service files never establish root completeness. 0052ae112cc3cbf4acac979df4f19cd5cacdc23269d05584bd6025b515c30b8d |
| -internal/hostfloor/participation.go checkLegacyParticipationHint hint D Service files add refusal hints only; missing service files never establish root completeness. file:ReadDir@299 05ccec45f7e0b231253d024640defdf0c93e71f40303761b0fdc0cf3acb5af79 |
| +internal/host/systemparticipation.go systemApplier.beginExecutableChange authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:ReadFile@561 e60e0fbb88aec4fa4b43ec5ba819ff633e5593cb85b8475d41951b9e1f848b68 |
| +internal/host/systemparticipation.go systemApplier.clearUpdateBegun authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:Lstat@240,file:Lstat@522 971dae18d92aab847128b375532d6b8a86338fbcc03ed7e82ad91e0cd5297810 |
| +internal/hostfloor/disk.go Disk.Begin authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:Stat@151 3bafc18b88f16afd726564684adf3ef9874d8271c925bcd299b9cbcdb0ccebd0 |
| +internal/hostfloor/disk.go Disk.WithParticipation authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. 250c5c9b3e8a696c60f83a4c3d42eb8ef84a2371f55d20b7eeb540be42f5e285 |
| +internal/hostfloor/participation.go CheckParticipation authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. 2353e6bce63d2889d1fae0fb0e6b098d0fc1de665e794f01e7085e5373eae53d |
| +internal/hostfloor/participation.go checkLegacyParticipation authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:ReadDir@339 e308eb277b9071c11efc6a3f1b6cbbbd7d59ca8420a3cc142b2f27af1d762379 |
| internal/rootparticipation/ledger.go Ledger.BeginUpdateWith authority D Root census and transaction lock exclude acquisition and update; interrupted intents clear only after positive launch completeness. file:Read@314 70e9ee76d88fde902d99ed61b852bebdc5d40cc40fd10834c5be6f6095df193c |
| internal/rootparticipation/ledger.go Ledger.EndUpdateWith authority D Root census and transaction lock exclude acquisition and update; interrupted intents clear only after positive launch completeness. file:Read@247 9c7fbfb25b613c16742673c9785171394fc2a9206c3f996a2549e5d4c85a8911 |
| internal/rootparticipation/ledger.go Ledger.UpdateID authority D Root census and transaction lock exclude acquisition and update; interrupted intents clear only after positive launch completeness. file:Read@110 41c1f1a6dd5baddadc384ef78f319fc3111f88e724ef0b39b3a3752a7057281d |
| internal/rootparticipation/ledger.go Ledger.readCensus authority D Root census and transaction lock exclude acquisition and update; interrupted intents clear only after positive launch completeness. file:Read@136 db268d0dbd24db646bbdab2b88ad17b1c43b7963a51ce473ee0a8895a1f951e6 |
| -internal/rootparticipation/production.go Open authority D Production opens the fixed checked root namespace; missing E2 launch/kernel verifier refuses idle and update. file:Open@45,file:Open@5 82b11f7548ce7ace572a697f66b6ef22239fc3fdf2e85a7c5fde7b8720c4a75b |
| +internal/rootparticipation/production.go Open authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:Open@45,file:Open@5 82b11f7548ce7ace572a697f66b6ef22239fc3fdf2e85a7c5fde7b8720c4a75b |
| @remove internal/host/claimparticipation.go productionClaimLocal.prepareWith |
| -@coverage internal/hostfloor 52f6b3ff07b4e632913bfff806e74eaeb9aac0b14c6353a0b02bb1caf119baa3 |
| -internal/host/guardtransaction.go installFloorHook authority D Root update recovery requires positive participation and shared exclusion with or without an armed floor. file:Lstat@93,file:ReadDir@259 6956aea590b55d435ef0321a6e2b365f3df074bbe91d09acb6215f19e4de622c |
| -internal/host/guardtransaction.go legacyGuardEntries authority D Root update recovery requires positive participation and shared exclusion with or without an armed floor. file:ReadFile@265 2d73ba69020ae86926e29d9e82da581517da3dbc23e4364b4cad8f7746a2d278 |
| -internal/host/guardtransaction.go systemApplier.beginGuard authority D Root update recovery requires positive participation and shared exclusion with or without an armed floor. file:Read@679,file:Read@903 d5056ac9aa4bff1fc5add3e8ae59ce8352d2285446290e03663466c5097aa54c |
| -internal/host/guardtransaction.go systemApplier.guardTransaction authority D Root update recovery requires positive participation and shared exclusion with or without an armed floor. file:ReadFile@84 6d364c272ca45684115c5d61eb13249a5e4b93b92cc17ce2c306c3a7f05fa302 |
| +@coverage internal/hostfloor e1b8271eaa6e464b4b5b9a480ea71e71ceafcf9750bf2f07ef2537713ba88d1e |
| +internal/host/guardtransaction.go installFloorHook authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:Lstat@93,file:ReadDir@259 6956aea590b55d435ef0321a6e2b365f3df074bbe91d09acb6215f19e4de622c |
| +internal/host/guardtransaction.go legacyGuardEntries authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:ReadFile@265 2d73ba69020ae86926e29d9e82da581517da3dbc23e4364b4cad8f7746a2d278 |
| +internal/host/guardtransaction.go systemApplier.beginGuard authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:Read@679,file:Read@903 d5056ac9aa4bff1fc5add3e8ae59ce8352d2285446290e03663466c5097aa54c |
| +internal/host/guardtransaction.go systemApplier.guardTransaction authority D Claims-on or retained root census uses root exclusion; claims-off with no census preserves main participation and recovery behavior. file:ReadFile@84 6d364c272ca45684115c5d61eb13249a5e4b93b92cc17ce2c306c3a7f05fa302 |
| +internal/rootparticipation/production.go Ledger.UpdateRequired authority D Select root participation when claims are enabled or a census exists; only absent state with claims off selects the legacy protocol. file:Read@172 98feb072e094d618a23cd8239f182179eed4881eee5f063dc825693dbe13e064 |
| +internal/rootparticipation/production.go OpenForUpdate authority D Select root participation when claims are enabled or a census exists; only absent state with claims off selects the legacy protocol. file:Open@56 41558ce678c04427ee55c0edbe68cd652402247a0c8449975c4a47d344182053 |
diff --git a/internal/rootparticipation/production.go b/internal/rootparticipation/production.go| index 5f4820fe0..7932f52e2 100644 |
| --- a/internal/rootparticipation/production.go |
| +++ b/internal/rootparticipation/production.go |
| @@ -2,8 +2,12 @@ package rootparticipation |
| import ( |
| "context" |
| + "errors" |
| + "os" |
| "sync" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + |
| "github.com/carlosframework/platform/internal/rootstate" |
| ) |
| @@ -44,3 +48,35 @@ func VerifyLaunchCensus(ctx context.Context, revision uint64) error { |
| } |
| return verify(ctx, revision) |
| } |
| + |
| +// UpdateRequired keeps a retained census authoritative after claims are disabled. |
| +// Only an absent census with the flag off selects the legacy update protocol. |
| +func (l Ledger) UpdateRequired() (bool, error) { |
| + if hostwrite.Enabled(os.Getenv) { |
| + return true, nil |
| + } |
| + if l.State == nil { |
| + return false, ErrUnavailable |
| + } |
| + _, err := l.State.Read("census.json") |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false, nil |
| + } |
| + return true, err |
| +} |
| + |
| +func OpenForUpdate() (Ledger, bool, error) { |
| + l, err := Open() |
| + if errors.Is(err, os.ErrNotExist) && !hostwrite.Enabled(os.Getenv) { |
| + return Ledger{}, false, nil |
| + } |
| + if err != nil { |
| + return Ledger{}, false, err |
| + } |
| + required, err := l.UpdateRequired() |
| + if err != nil { |
| + l.State.Close() |
| + return Ledger{}, false, err |
| + } |
| + return l, required, nil |
| +} |