seeds: the floor object, published by CAS and only rising (Plan 3 Task 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+242
−0
internal/seeds/floor.go+124 −0internal/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) |
| + } |
| +} |