carlos / platform Public

seeds: the floor object, published by CAS and only rising (Plan 3 Task 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 56bbe4251dd21f46430443e3ab99411b7bc3860a parent 3f3de6f
2 files changed, +242 −0
  • internal/seeds/floor.go +124 −0
  • internal/seeds/floor_test.go +118 −0

CI passed — run details

diff --git a/internal/seeds/floor.go b/internal/seeds/floor.go
new file mode 100644
index 000000000..0f0ad76df
--- /dev/null
+++ b/internal/seeds/floor.go
@@ -0,0 +1,124 @@
+package seeds
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ProtocolVersion is the seeds protocol this build speaks. 0 until the box
+// can place, swap and roll back a seed (Plan 7 raises it to 1): a box that
+// reports 0 can never satisfy the console's RequiredProtocol.
+const ProtocolVersion = 0
+
+// RequiredProtocol is what the console requires every box in the region to
+// report before it writes any seed state (spec §3.3).
+const RequiredProtocol = 1
+
+// FloorKey is the operator-published seeds floor (spec §3.3).
+const FloorKey = "control/seeds-protocol.json"
+
+// The box's local copy of the floor and its verdict on carlos.prev, beside
+// the moves floor's files under the same floor.lock
+// (internal/host/movesfloor.go). The two system units read the second and
+// third by fixed path.
+const (
+ FloorFile = ".moves/seeds-floor.json" // under the box's DataDir
+ FloorMinReleaseFile = ".moves/seeds-floor.min-release" // under the box's DataDir
+ PrevAllowedFile = "carlos.prev.seeds-allowed" // beside carlos.prev in the opt dir
+)
+
+// Floor is the seeds floor: the protocol every box must speak, and the
+// first release that speaks it.
+type Floor struct {
+ Min int `json:"min"`
+ MinRelease string `json:"min_release"`
+}
+
+// ReadFloor reads the published floor. Absent is (nil, nil); present but
+// unreadable is an error, never a silent "no floor".
+func ReadFloor(ctx context.Context, s release.Store) (*Floor, error) {
+ f, _, err := readFloorETag(ctx, s)
+ return f, err
+}
+
+func readFloorETag(ctx context.Context, s release.Store) (*Floor, string, error) {
+ body, etag, err := s.Get(ctx, FloorKey)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, "", nil
+ }
+ if err != nil {
+ return nil, "", fmt.Errorf("seeds: read floor: %w", err)
+ }
+ var f Floor
+ if err := json.Unmarshal(body, &f); err != nil {
+ return nil, "", fmt.Errorf("seeds: parse floor: %w", err)
+ }
+ return &f, etag, nil
+}
+
+// ReleaseAtOrAbove reports whether version is at or above minRelease, both
+// vMAJOR.MINOR.PATCH. It fails closed: a prerelease or a malformed value is
+// false with an error.
+func ReleaseAtOrAbove(version, minRelease string) (bool, error) {
+ min, ok := release.ParseSemVer(strings.TrimPrefix(minRelease, "v"))
+ if !ok {
+ return false, fmt.Errorf("seeds: malformed minimum release %q", minRelease)
+ }
+ v, ok := release.ParseSemVer(strings.TrimPrefix(version, "v"))
+ if !ok {
+ return false, fmt.Errorf("seeds: unparseable release %q", version)
+ }
+ return v.Compare(min) >= 0, nil
+}
+
+// FloorLowerError is PublishFloor refusing to lower the floor.
+type FloorLowerError struct{ Current string }
+
+func (e *FloorLowerError) Error() string {
+ return "seeds: the published floor is already " + e.Current
+}
+
+// publishAttempts bounds the CAS retry. Each lost race means another
+// publisher moved the floor, so a few rounds are plenty.
+const publishAttempts = 5
+
+// PublishFloor writes the floor by CAS. A floor only rises: a lower
+// min_release or a lower min is a *FloorLowerError; the same one is a no-op
+// success. A lost CAS re-reads and decides again against the new floor, so a
+// lower publish that raced a higher one is refused, never written over it.
+func PublishFloor(ctx context.Context, s release.Store, f Floor) error {
+ if ok, err := ReleaseAtOrAbove(f.MinRelease, f.MinRelease); !ok || err != nil {
+ return fmt.Errorf("seeds: %q is not a release", f.MinRelease)
+ }
+ body, err := json.Marshal(f)
+ if err != nil {
+ return err
+ }
+ for range publishAttempts {
+ cur, etag, err := readFloorETag(ctx, s)
+ if err != nil {
+ return err
+ }
+ if cur != nil {
+ if ok, err := ReleaseAtOrAbove(f.MinRelease, cur.MinRelease); err != nil {
+ return fmt.Errorf("seeds: published floor is unusable: %w", err)
+ } else if !ok || f.Min < cur.Min {
+ return &FloorLowerError{Current: cur.MinRelease}
+ }
+ if *cur == f {
+ return nil
+ }
+ }
+ _, err = s.Put(ctx, FloorKey, body, etag)
+ if !errors.Is(err, release.ErrCASConflict) {
+ return err
+ }
+ }
+ return fmt.Errorf("seeds: publish floor: %w", release.ErrCASConflict)
+}
diff --git a/internal/seeds/floor_test.go b/internal/seeds/floor_test.go
new file mode 100644
index 000000000..373ce0889
--- /dev/null
+++ b/internal/seeds/floor_test.go
@@ -0,0 +1,118 @@
+package seeds
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestReadFloorAbsentIsNil(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ f, err := ReadFloor(context.Background(), s)
+ if f != nil || err != nil {
+ t.Fatalf("absent floor = %+v, %v; want nil, nil", f, err)
+ }
+ if _, err := s.Put(context.Background(), FloorKey, []byte("{not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ReadFloor(context.Background(), s); err == nil {
+ t.Fatal("garbage at the floor key read as no floor")
+ }
+}
+
+func TestPublishFloorOnlyRises(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: "v0.27.0"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: "v0.27.0"}); err != nil {
+ t.Fatalf("the same floor again: %v", err)
+ }
+ var low *FloorLowerError
+ if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: "v0.26.9"}); !errors.As(err, &low) || low.Current != "v0.27.0" {
+ t.Fatalf("a lower floor: err = %v, want FloorLowerError naming v0.27.0", err)
+ }
+ if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: "v0.28.0"}); err != nil {
+ t.Fatalf("a higher floor: %v", err)
+ }
+ f, err := ReadFloor(ctx, s)
+ if err != nil || f == nil || f.MinRelease != "v0.28.0" || f.Min != RequiredProtocol {
+ t.Fatalf("floor = %+v, %v", f, err)
+ }
+ if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: "v0.29.0-rc.1"}); err == nil {
+ t.Fatal("a prerelease floor published")
+ }
+}
+
+func TestPublishFloorLowerProtocolRefused(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ if err := PublishFloor(ctx, s, Floor{Min: 2, MinRelease: "v0.27.0"}); err != nil {
+ t.Fatal(err)
+ }
+ var low *FloorLowerError
+ if err := PublishFloor(ctx, s, Floor{Min: 1, MinRelease: "v0.28.0"}); !errors.As(err, &low) {
+ t.Fatalf("a lower protocol on a higher release: err = %v, want FloorLowerError", err)
+ }
+ if f, err := ReadFloor(ctx, s); err != nil || *f != (Floor{Min: 2, MinRelease: "v0.27.0"}) {
+ t.Fatalf("floor = %+v, %v; want it unchanged", f, err)
+ }
+}
+
+// Concurrent publishes of different floors must leave the highest one, never
+// a lower one that read the object before a higher one landed.
+func TestPublishFloorConcurrentLowerNeverWins(t *testing.T) {
+ ctx := context.Background()
+ for round := 0; round < 20; round++ {
+ s := release.NewDirStore(t.TempDir())
+ versions := []string{"v0.27.0", "v0.28.0", "v0.29.0", "v0.30.0", "v0.31.0"}
+ var wg sync.WaitGroup
+ for _, v := range versions {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ var low *FloorLowerError
+ if err := PublishFloor(ctx, s, Floor{Min: RequiredProtocol, MinRelease: v}); err != nil && !errors.As(err, &low) {
+ t.Errorf("publish %s: %v", v, err)
+ }
+ }()
+ }
+ wg.Wait()
+ f, err := ReadFloor(ctx, s)
+ if err != nil || f == nil || f.MinRelease != "v0.31.0" {
+ t.Fatalf("round %d: floor = %+v, %v; want v0.31.0", round, f, err)
+ }
+ }
+}
+
+func TestReleaseAtOrAbove(t *testing.T) {
+ for _, c := range []struct {
+ v, min string
+ want bool
+ err bool
+ }{
+ {"v0.27.0", "v0.27.0", true, false},
+ {"v0.27.1", "v0.27.0", true, false},
+ {"v0.26.9", "v0.27.0", false, false},
+ {"v0.28.0-rc.1", "v0.27.0", false, true},
+ {"v0.27.0", "garbage", false, true},
+ } {
+ got, err := ReleaseAtOrAbove(c.v, c.min)
+ if got != c.want || (err != nil) != c.err {
+ t.Errorf("ReleaseAtOrAbove(%s, %s) = %v, %v", c.v, c.min, got, err)
+ }
+ }
+}
+
+func TestProtocolStartsBelowWhatTheConsoleRequires(t *testing.T) {
+ // Plan 3 ships the floor dark: no box built from it can satisfy the
+ // console's check. Plan 7 Task 7 raises ProtocolVersion and rewrites
+ // this test.
+ if ProtocolVersion >= RequiredProtocol {
+ t.Fatalf("ProtocolVersion = %d, RequiredProtocol = %d", ProtocolVersion, RequiredProtocol)
+ }
+}