Skip to content

Commit 251946e

Browse files
merge master (count-caps #263) into scale-to-zero
# Conflicts: # internal/config/config.go # internal/config/config_test.go
2 parents 41fcef3 + 9a6fc90 commit 251946e

18 files changed

Lines changed: 712 additions & 18 deletions

internal/config/config.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,17 @@ type Config struct {
206206
// Enabling it is an operator action (see infra runbook) after a canary.
207207
DeployScaleToZeroEnabled bool
208208

209+
// ResourceCountCapsEnabled gates per-service resource-count enforcement
210+
// (Task #55). Default FALSE: when off, the count-check block in every
211+
// provision handler (db/vector/cache/nosql/storage) is skipped entirely —
212+
// zero behavior change, so shipping the caps cannot surprise-break an
213+
// existing heavy tenant with a 402. When on, each handler counts the team's
214+
// active resources of that type and rejects over-cap provisions with 402 +
215+
// agent_action, mirroring the always-on queue_count cap. Enabling it is an
216+
// operator action (kubectl set env RESOURCE_COUNT_CAPS_ENABLED=true) after a
217+
// usage audit so no current tenant is over the new per-tier caps.
218+
ResourceCountCapsEnabled bool
219+
209220
// GitHub App (P4) — install-once push-to-deploy + short-lived installation
210221
// tokens for private-repo clones. Distinct from the GitHub OAuth *login* app
211222
// above (GitHubClientID/Secret). GitHubAppEnabled gates the whole feature:
@@ -520,6 +531,17 @@ func Load() *Config {
520531
cfg.DeployScaleToZeroEnabled = false
521532
}
522533

534+
// RESOURCE_COUNT_CAPS_ENABLED: default FALSE (Task #55). Off → the per-service
535+
// count-check block in every provision handler is skipped (zero behavior
536+
// change). On → over-cap provisions get 402. Operator action after a usage
537+
// audit so no current tenant is retroactively over a new per-tier cap.
538+
switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOURCE_COUNT_CAPS_ENABLED"))) {
539+
case "true", "1", "yes":
540+
cfg.ResourceCountCapsEnabled = true
541+
default:
542+
cfg.ResourceCountCapsEnabled = false
543+
}
544+
523545
// GITHUB_APP_ENABLED: default FALSE (off until the operator registers the
524546
// App and provisions GITHUB_APP_* secrets — see infra/GITHUB-APP-RUNBOOK.md).
525547
switch strings.ToLower(strings.TrimSpace(os.Getenv("GITHUB_APP_ENABLED"))) {

internal/config/config_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ func allKeys() []string {
6464
"DELETION_CONFIRMATION_TTL_MINUTES", "FAMILY_BINDINGS_ENABLED",
6565
"DEPLOY_SOURCE_IMAGE_ENABLED", "DEPLOY_SOURCE_GIT_ENABLED",
6666
"DEPLOY_SCALE_TO_ZERO_ENABLED",
67+
"RESOURCE_COUNT_CAPS_ENABLED",
6768
"GITHUB_APP_ENABLED", "GITHUB_APP_ID", "GITHUB_APP_SLUG", "GITHUB_APP_PRIVATE_KEY",
6869
"GITHUB_APP_WEBHOOK_SECRET", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET",
6970
"BREVO_WEBHOOK_SECRET", "SES_SNS_SUBSCRIPTION_ARN",
@@ -403,6 +404,21 @@ func TestLoad_DeployScaleToZeroEnabled(t *testing.T) {
403404
}
404405
}
405406

407+
func TestLoad_ResourceCountCapsEnabled(t *testing.T) {
408+
for _, val := range []string{"true", "1", "yes", "TRUE", " Yes "} {
409+
applyBaselineEnv(t, map[string]string{"RESOURCE_COUNT_CAPS_ENABLED": val})
410+
if !Load().ResourceCountCapsEnabled {
411+
t.Errorf("RESOURCE_COUNT_CAPS_ENABLED=%q should enable", val)
412+
}
413+
}
414+
for _, val := range []string{"false", "0", "no", "maybe", ""} {
415+
applyBaselineEnv(t, map[string]string{"RESOURCE_COUNT_CAPS_ENABLED": val})
416+
if Load().ResourceCountCapsEnabled {
417+
t.Errorf("RESOURCE_COUNT_CAPS_ENABLED=%q should stay disabled (default OFF)", val)
418+
}
419+
}
420+
}
421+
406422
func TestLoad_GitHubAppEnabled(t *testing.T) {
407423
// When enabling the App, Load() fails closed unless the webhook secret +
408424
// private key + app id are present (review HIGH-1), so set them here.

internal/handlers/billing_usage.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ type usageMetric struct {
6868
LimitBytes int64 `json:"limit_bytes,omitempty"`
6969
Count int `json:"count,omitempty"`
7070
Limit int `json:"limit,omitempty"`
71+
// CountLimit is the per-tier resource-COUNT cap (Task #55) for the
72+
// byte-metered storage services (postgres/redis/mongodb), where `Limit`
73+
// already carries no value (those use LimitBytes). It lets the dashboard
74+
// show "3 / 5 databases" alongside "120 MB / 1024 MB". For the
75+
// count-metered services (deployments/webhooks/vault/members) the existing
76+
// Count/Limit pair is unchanged. -1 means unlimited.
77+
CountLimit int `json:"count_limit,omitempty"`
7178
}
7279

7380
// GetUsage handles GET /api/v1/billing/usage.
@@ -152,9 +159,15 @@ func (h *BillingUsageHandler) computeUsage(ctx context.Context, teamID uuid.UUID
152159
return usageSummary{}, sumErr
153160
}
154161
limitMB := h.plans.StorageLimitMB(tier, svc)
162+
// Task #55: also surface the active-resource COUNT + per-tier count cap
163+
// so the dashboard can render "3 / 5 databases" next to the byte gauge.
164+
// Best-effort: a count error must not fail the byte rows.
165+
count, _ := models.CountActiveResourcesByTeamAndType(ctx, h.db, teamID, svc)
155166
usage[svc] = usageMetric{
156167
Bytes: bytes,
157168
LimitBytes: mbToBytes(limitMB),
169+
Count: count,
170+
CountLimit: h.plans.ResourceCountLimit(tier, svc),
158171
}
159172
}
160173

internal/handlers/cache.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,12 @@ func (h *CacheHandler) newCacheAuthenticated(
333333
tier = "growth"
334334
}
335335

336+
// Task #55: per-tier redis count cap (flag-gated, default OFF). Redis is the
337+
// binding COGS constraint ($6.50/GB) so this is the most-conservative cap.
338+
if handled, capErr := h.enforceResourceCountCap(c, teamUUID, team.PlanTier, models.ResourceTypeRedis, requestID); handled {
339+
return capErr
340+
}
341+
336342
parentRootID, perr := resolveFamilyParent(c, h.db, parentResourceID, teamUUID, models.ResourceTypeRedis, env)
337343
if perr != nil {
338344
return perr

internal/handlers/capabilities.go

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,36 +32,43 @@ func NewCapabilitiesHandler(p *plans.Registry) *CapabilitiesHandler {
3232
}
3333

3434
type tierCapabilities struct {
35-
Tier string `json:"tier"`
36-
DisplayName string `json:"display_name"`
37-
PriceUSDMonthly int `json:"price_usd_monthly"`
38-
PaidFromDayOne bool `json:"paid_from_day_one"`
39-
StorageLimitMB map[string]int `json:"storage_limit_mb"`
40-
ConnectionsLimit map[string]int `json:"connections_limit"`
41-
Deployments int `json:"deployments_apps"`
42-
BackupRetentionDays int `json:"backup_retention_days"`
43-
BackupRestoreEnabled bool `json:"backup_restore_enabled"`
44-
ManualBackupsPerDay int `json:"manual_backups_per_day"`
35+
Tier string `json:"tier"`
36+
DisplayName string `json:"display_name"`
37+
PriceUSDMonthly int `json:"price_usd_monthly"`
38+
PaidFromDayOne bool `json:"paid_from_day_one"`
39+
StorageLimitMB map[string]int `json:"storage_limit_mb"`
40+
ConnectionsLimit map[string]int `json:"connections_limit"`
41+
// ResourceCountLimit is the per-service max number of active resources a
42+
// team may hold (Task #55). Keyed by the same service strings as
43+
// StorageLimitMB. -1 means unlimited; a positive value is the hard cap.
44+
// Enforcement is flag-gated (RESOURCE_COUNT_CAPS_ENABLED) — this surface
45+
// always advertises the cap so an agent can plan around it even while the
46+
// operator hasn't yet flipped enforcement on.
47+
ResourceCountLimit map[string]int `json:"resource_count_limit"`
48+
Deployments int `json:"deployments_apps"`
49+
BackupRetentionDays int `json:"backup_retention_days"`
50+
BackupRestoreEnabled bool `json:"backup_restore_enabled"`
51+
ManualBackupsPerDay int `json:"manual_backups_per_day"`
4552
// RPOMinutes / RTOMinutes — FIX-H #Q50 (B36). 0 means
4653
// "not promised" (no scheduled backups / no self-serve restore on
4754
// the tier). Lets an agent reason about durability requirements
4855
// per-tier without a second round-trip.
49-
RPOMinutes int `json:"rpo_minutes"`
50-
RTOMinutes int `json:"rto_minutes"`
51-
AnnualDiscountPercent int `json:"annual_discount_percent"`
56+
RPOMinutes int `json:"rpo_minutes"`
57+
RTOMinutes int `json:"rto_minutes"`
58+
AnnualDiscountPercent int `json:"annual_discount_percent"`
5259
// UpgradeURL — pointer so the terminal tier (Team — there is nothing
5360
// to upgrade to) emits an explicit JSON `null` instead of the pricing
5461
// URL. DOG-26 (QA 2026-05-29): every tier including Team used to
5562
// return the pricing page, which would have SDKs / dashboards
5663
// rendering an "Upgrade" CTA on the Team plan with no destination.
5764
// `null` is the contract-stable terminal-tier marker; a non-null
5865
// string is the "click here to upgrade" signal.
59-
UpgradeURL *string `json:"upgrade_url"`
66+
UpgradeURL *string `json:"upgrade_url"`
6067
// IsTerminalTier — explicit boolean so clients don't have to encode
6168
// the "is upgrade_url null" check at every render site. True for the
6269
// top tier (Team today), false for everything below. Pairs with
6370
// UpgradeURL — when IsTerminalTier=true, UpgradeURL is null.
64-
IsTerminalTier bool `json:"is_terminal_tier"`
71+
IsTerminalTier bool `json:"is_terminal_tier"`
6572
}
6673

6774
// capabilityResourceTypes is the list of service types the /capabilities
@@ -71,6 +78,13 @@ var capabilityResourceTypes = []string{
7178
"postgres", "redis", "mongodb", "queue", "storage", "webhook", "vector",
7279
}
7380

81+
// countCapResourceTypes is the set of services that carry a per-tier
82+
// resource-COUNT cap (Task #55). Webhook is omitted — it is byte/request-capped
83+
// via webhook_requests_stored, not count-capped. Order is contract-stable.
84+
var countCapResourceTypes = []string{
85+
"postgres", "vector", "redis", "mongodb", "storage", "queue",
86+
}
87+
7488
// upgradeURL is the marketing pricing page that every tier row in the
7589
// /capabilities response points back to. Hoisted to a package const so
7690
// the URL fragment isn't scattered as a string literal across the handler.
@@ -158,10 +172,17 @@ func (h *CapabilitiesHandler) Get(c *fiber.Ctx) error {
158172
for _, e := range entries {
159173
storage := map[string]int{}
160174
conns := map[string]int{}
175+
counts := map[string]int{}
161176
for _, rt := range capabilityResourceTypes {
162177
storage[rt] = h.plans.StorageLimitMB(e.name, rt)
163178
conns[rt] = h.plans.ConnectionsLimit(e.name, rt)
164179
}
180+
// Task #55: per-service resource-count caps. Only the count-capped
181+
// services appear (webhook is byte-capped via webhook_requests_stored,
182+
// not count-capped). ResourceCountLimit returns -1 for unlimited.
183+
for _, rt := range countCapResourceTypes {
184+
counts[rt] = h.plans.ResourceCountLimit(e.name, rt)
185+
}
165186
priceUSD := e.plan.PriceMonthly / 100 // cents → dollars
166187
// DOG-26: terminal tier marker — top of the rank ladder has
167188
// nothing to upgrade to. upgrade_url is null + is_terminal_tier
@@ -179,6 +200,7 @@ func (h *CapabilitiesHandler) Get(c *fiber.Ctx) error {
179200
PaidFromDayOne: priceUSD > 0,
180201
StorageLimitMB: storage,
181202
ConnectionsLimit: conns,
203+
ResourceCountLimit: counts,
182204
Deployments: h.plans.DeploymentsAppsLimit(e.name),
183205
BackupRetentionDays: h.plans.BackupRetentionDays(e.name),
184206
BackupRestoreEnabled: h.plans.BackupRestoreEnabled(e.name),

internal/handlers/capabilities_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type capabilityTier struct {
3434
PaidFromDayOne bool `json:"paid_from_day_one"`
3535
StorageLimitMB map[string]int `json:"storage_limit_mb"`
3636
ConnectionsLimit map[string]int `json:"connections_limit"`
37+
ResourceCountLimit map[string]int `json:"resource_count_limit"`
3738
Deployments int `json:"deployments_apps"`
3839
BackupRetentionDays int `json:"backup_retention_days"`
3940
BackupRestoreEnabled bool `json:"backup_restore_enabled"`
@@ -214,6 +215,44 @@ func TestCapabilities_LimitsResolveFromRegistry(t *testing.T) {
214215
assert.Equal(t, 5, hp.ManualBackupsPerDay, "hobby_plus manual backups/day")
215216
}
216217

218+
// TestCapabilities_SurfacesResourceCountLimit is the Task #55 rule-18 surface
219+
// guard: GET /api/v1/capabilities must expose resource_count_limit for EVERY
220+
// count-capped service on every paid tier, with the value matching the live
221+
// registry. Iterates the registry rather than hand-typing tiers so a new tier or
222+
// service can't silently ship without the cap appearing on the public matrix.
223+
func TestCapabilities_SurfacesResourceCountLimit(t *testing.T) {
224+
reg := plans.Default()
225+
app := newCapabilitiesApp(t, reg)
226+
_, body := callCapabilities(t, app)
227+
require.NotEmpty(t, body.Tiers)
228+
229+
countServices := []string{"postgres", "vector", "redis", "mongodb", "storage", "queue"}
230+
for _, tier := range body.Tiers {
231+
require.NotNil(t, tier.ResourceCountLimit,
232+
"tier %q must carry resource_count_limit", tier.Tier)
233+
for _, svc := range countServices {
234+
got, ok := tier.ResourceCountLimit[svc]
235+
require.True(t, ok, "tier %q resource_count_limit must include %q", tier.Tier, svc)
236+
assert.Equal(t, reg.ResourceCountLimit(tier.Tier, svc), got,
237+
"tier %q %s count limit must match the registry", tier.Tier, svc)
238+
}
239+
// Webhook is request-capped, not count-capped — must NOT appear.
240+
_, hasWebhook := tier.ResourceCountLimit["webhook"]
241+
assert.False(t, hasWebhook, "webhook must not appear in resource_count_limit (it is request-capped)")
242+
}
243+
244+
// Spot-pin a couple of binding values so a loosened cap is a visible diff.
245+
for _, tier := range body.Tiers {
246+
switch tier.Tier {
247+
case "pro":
248+
assert.Equal(t, 3, tier.ResourceCountLimit["redis"], "pro redis_count")
249+
assert.Equal(t, 5, tier.ResourceCountLimit["postgres"], "pro postgres_count")
250+
case "team":
251+
assert.Equal(t, 4, tier.ResourceCountLimit["redis"], "team redis_count")
252+
}
253+
}
254+
}
255+
217256
// TestCapabilities_PlansUnavailable — when the registry pointer is nil
218257
// (boot-time failure in dev with no fallback), the handler must return
219258
// 503 instead of panicking. Lifted contract from the original handler.

internal/handlers/db.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,12 @@ func (h *DBHandler) newDBAuthenticated(
381381
tier = "growth"
382382
}
383383

384+
// Task #55: per-tier postgres count cap (flag-gated, default OFF — inert
385+
// unless RESOURCE_COUNT_CAPS_ENABLED). Mirrors queue.go's A6 block.
386+
if handled, capErr := h.enforceResourceCountCap(c, teamUUID, team.PlanTier, models.ResourceTypePostgres, requestID); handled {
387+
return capErr
388+
}
389+
384390
// Family-link validation runs BEFORE provisioning so a cross-team /
385391
// cross-type / duplicate-twin parent_resource_id never causes us to
386392
// create-then-fail (which would leak a database we can't link).

internal/handlers/nosql.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,11 @@ func (h *NoSQLHandler) newNoSQLAuthenticated(
326326
tier = "growth"
327327
}
328328

329+
// Task #55: per-tier mongodb count cap (flag-gated, default OFF). Mirrors queue.go.
330+
if handled, capErr := h.enforceResourceCountCap(c, teamUUID, team.PlanTier, models.ResourceTypeMongoDB, requestID); handled {
331+
return capErr
332+
}
333+
329334
parentRootID, perr := resolveFamilyParent(c, h.db, parentResourceID, teamUUID, models.ResourceTypeMongoDB, env)
330335
if perr != nil {
331336
return perr

internal/handlers/openapi.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3292,8 +3292,9 @@ const openAPISpec = `{
32923292
"properties": {
32933293
"bytes": { "type": "integer", "format": "int64", "description": "Current storage usage in bytes. Present on postgres/redis/mongodb." },
32943294
"limit_bytes": { "type": "integer", "format": "int64", "description": "Storage cap in bytes (plans.yaml storage_mb × 1024 × 1024). -1 = unlimited." },
3295-
"count": { "type": "integer", "description": "Current count. Present on deployments/webhooks/vault/members." },
3296-
"limit": { "type": "integer", "description": "Count cap from plans.yaml. -1 = unlimited." }
3295+
"count": { "type": "integer", "description": "Current count. Present on deployments/webhooks/vault/members, and (Task #55) on postgres/redis/mongodb as the active-resource count alongside bytes." },
3296+
"limit": { "type": "integer", "description": "Count cap from plans.yaml. -1 = unlimited." },
3297+
"count_limit": { "type": "integer", "description": "Task #55: per-tier resource-COUNT cap for the byte-metered storage services (postgres/redis/mongodb), where the limit field is unused. -1 = unlimited. Enforcement is flag-gated (RESOURCE_COUNT_CAPS_ENABLED) but the cap is always advertised." }
32973298
}
32983299
},
32993300
"TeamSummaryResponse": {
@@ -3383,6 +3384,7 @@ const openAPISpec = `{
33833384
"paid_from_day_one": { "type": "boolean", "description": "True iff price_usd_monthly > 0. Mirrors project policy: no trial — paid tiers are paid from signup." },
33843385
"storage_limit_mb": { "type": "object", "additionalProperties": { "type": "integer" }, "description": "Per-service storage cap in MB. Keys: postgres, redis, mongodb, queue, storage, webhook, vector. -1 sentinel means 'unlimited'." },
33853386
"connections_limit": { "type": "object", "additionalProperties": { "type": "integer" }, "description": "Per-service concurrent-connection cap. Keys mirror storage_limit_mb. -1 = unlimited." },
3387+
"resource_count_limit": { "type": "object", "additionalProperties": { "type": "integer" }, "description": "Task #55: per-service max number of active resources a team may hold. Keys: postgres, vector, redis, mongodb, storage, queue (webhook is request-capped, not count-capped). -1 = unlimited. Enforcement is flag-gated (RESOURCE_COUNT_CAPS_ENABLED) but the cap is always advertised so an agent can plan around it." },
33863388
"deployments_apps": { "type": "integer", "description": "Max number of /deploy/new apps allowed. -1 = unlimited." },
33873389
"backup_retention_days": { "type": "integer" },
33883390
"backup_restore_enabled": { "type": "boolean" },

0 commit comments

Comments
 (0)