plans: the deployment marker, and checked pack arithmetic
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+153
−5
internal/plans/catalog.go+21 −5internal/plans/catalog_test.go+14 −0internal/plans/marker.go+60 −0internal/plans/marker_test.go+58 −0
diff --git a/internal/plans/catalog.go b/internal/plans/catalog.go| index cd9c2734b..a9a9592cb 100644 |
| --- a/internal/plans/catalog.go |
| +++ b/internal/plans/catalog.go |
| @@ -14,6 +14,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "math" |
| "os" |
| "slices" |
| ) |
| @@ -211,11 +212,15 @@ func (c *Catalog) Effective(plan string, packs map[string]int, sponsored *Limits |
| if qty < 1 || qty > pk.MaxQuantity { |
| return Limits{}, fmt.Errorf("%w: %q has %d, allowed 1 to %d", ErrPackQuantity, name, qty, pk.MaxQuantity) |
| } |
| - l.RunningThings += qty * pk.Adds.RunningThings |
| - l.DiskBytes += int64(qty) * pk.Adds.DiskBytes |
| - l.StorageBytes += int64(qty) * pk.Adds.StorageBytes |
| - l.EmailPerDay += qty * pk.Adds.EmailPerDay |
| - l.BandwidthBytesMonth += int64(qty) * pk.Adds.BandwidthBytesMonth |
| + fits := true |
| + l.RunningThings = addPack(l.RunningThings, qty, pk.Adds.RunningThings, &fits) |
| + l.DiskBytes = addPack(l.DiskBytes, qty, pk.Adds.DiskBytes, &fits) |
| + l.StorageBytes = addPack(l.StorageBytes, qty, pk.Adds.StorageBytes, &fits) |
| + l.EmailPerDay = addPack(l.EmailPerDay, qty, pk.Adds.EmailPerDay, &fits) |
| + l.BandwidthBytesMonth = addPack(l.BandwidthBytesMonth, qty, pk.Adds.BandwidthBytesMonth, &fits) |
| + if !fits { |
| + return Limits{}, fmt.Errorf("%w: %q overflows", ErrPackQuantity, name) |
| + } |
| } |
| ce := c.Ceilings |
| l.RunningThings = clamp(l.RunningThings, ce.RunningThings) |
| @@ -261,3 +266,14 @@ func (l Limits) generous(o Limits) Limits { |
| } |
| return l |
| } |
| + |
| +// addPack is v + qty*add, refusing (ok = false) anything that would overflow |
| +// int64. Values are non-negative (validate), so only the upper bound matters; |
| +// int is 64-bit on every platform this builds for. |
| +func addPack[T int | int64](v T, qty int, add T, fits *bool) T { |
| + if add != 0 && int64(qty) > (math.MaxInt64-int64(v))/int64(add) { |
| + *fits = false |
| + return v |
| + } |
| + return v + T(qty)*add |
| +} |
diff --git a/internal/plans/catalog_test.go b/internal/plans/catalog_test.go| index ce6c86c0f..3728f2649 100644 |
| --- a/internal/plans/catalog_test.go |
| +++ b/internal/plans/catalog_test.go |
| @@ -202,3 +202,17 @@ func TestEffectiveSponsoredZeroIsUnset(t *testing.T) { |
| t.Fatalf("min_schedule_seconds %d, want 60", got.MinScheduleSeconds) |
| } |
| } |
| + |
| +// Astra plans-1 finding 5: an accepted catalog with a huge pack increment |
| +// must never wrap to a negative limit that the ceiling cannot correct. |
| +func TestEffectiveRefusesOverflow(t *testing.T) { |
| + c := exampleCatalog(t) |
| + pk := c.Packs["storage"] |
| + pk.Adds.StorageBytes = 1 << 62 |
| + c.Packs["storage"] = pk |
| + c.Ceilings.StorageBytes = 100 |
| + got, err := c.Effective("personal", map[string]int{"storage": 2}, nil) |
| + if !errors.Is(err, ErrPackQuantity) { |
| + t.Fatalf("err %v (limits %+v), want ErrPackQuantity", err, got) |
| + } |
| +} |
diff --git a/internal/plans/marker.go b/internal/plans/marker.go| new file mode 100644 |
| index 000000000..d39ee9e6c |
| --- /dev/null |
| +++ b/internal/plans/marker.go |
| @@ -0,0 +1,60 @@ |
| +package plans |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// MarkerSchema is the deployment marker's wire version. |
| +const MarkerSchema = 1 |
| + |
| +// Marker says whether plans are on for the deployment (plans 2). The console |
| +// writes it once at start; boxes read it on every pass and read no limits |
| +// record while it is absent or disabled, so a deployment without a catalog |
| +// costs a box one small read per pass and changes nothing. |
| +type Marker struct { |
| + Schema int `json:"schema"` |
| + Enabled bool `json:"enabled"` |
| + CatalogID string `json:"catalog_id,omitempty"` |
| + UpdatedAt time.Time `json:"updated_at"` |
| +} |
| + |
| +// MarkerKey is where the deployment marker lives. |
| +func MarkerKey() string { return "control/plans.json" } |
| + |
| +// GetMarker reads the marker and its etag. Absent is nil, "", nil. An unknown |
| +// schema is an error, so a reader keeps whatever it last knew. |
| +func GetMarker(ctx context.Context, s release.Store) (*Marker, string, error) { |
| + raw, etag, err := s.Get(ctx, MarkerKey()) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, "", nil |
| + } |
| + return nil, "", err |
| + } |
| + var m Marker |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + return nil, "", fmt.Errorf("plans: %s: %w", MarkerKey(), err) |
| + } |
| + if m.Schema != MarkerSchema { |
| + return nil, "", fmt.Errorf("plans: %s: schema %d, want %d", MarkerKey(), m.Schema, MarkerSchema) |
| + } |
| + return &m, etag, nil |
| +} |
| + |
| +// PutMarker CAS-writes the marker: ifMatch "" is create-only, otherwise |
| +// replace-only. Schema is stamped here. |
| +func PutMarker(ctx context.Context, s release.Store, m *Marker, ifMatch string) (string, error) { |
| + m.Schema = MarkerSchema |
| + body, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + return "", err |
| + } |
| + return s.Put(ctx, MarkerKey(), append(body, '\n'), ifMatch) |
| +} |
diff --git a/internal/plans/marker_test.go b/internal/plans/marker_test.go| new file mode 100644 |
| index 000000000..b01b1ee20 |
| --- /dev/null |
| +++ b/internal/plans/marker_test.go |
| @@ -0,0 +1,58 @@ |
| +package plans |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestMarkerKey(t *testing.T) { |
| + if got := MarkerKey(); got != "control/plans.json" { |
| + t.Fatalf("MarkerKey = %q", got) |
| + } |
| +} |
| + |
| +func TestMarkerRoundTrip(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if m, etag, err := GetMarker(ctx, s); m != nil || etag != "" || err != nil { |
| + t.Fatalf("absent: %+v, %q, %v", m, etag, err) |
| + } |
| + want := &Marker{Enabled: true, CatalogID: "v1@0123456789ab", UpdatedAt: time.Unix(1_800_000_000, 0).UTC()} |
| + if _, err := PutMarker(ctx, s, want, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, etag, err := GetMarker(ctx, s) |
| + if err != nil || etag == "" { |
| + t.Fatalf("get: %q, %v", etag, err) |
| + } |
| + want.Schema = MarkerSchema |
| + if *got != *want { |
| + t.Fatalf("got %+v, want %+v", got, want) |
| + } |
| +} |
| + |
| +func TestMarkerRefusesUnknownSchema(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, err := s.Put(ctx, MarkerKey(), []byte(`{"schema": 2, "enabled": true}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if m, _, err := GetMarker(ctx, s); err == nil { |
| + t.Fatalf("accepted schema 2: %+v", m) |
| + } |
| +} |
| + |
| +func TestPutMarkerIsCAS(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, err := PutMarker(ctx, s, &Marker{Enabled: true}, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := PutMarker(ctx, s, &Marker{Enabled: false}, ""); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("second create-only put: %v", err) |
| + } |
| +} |