eleven / messenger Public

server: free-pool gauge — spend/budget/pressure recorded each meter sweep, no behavior change (#871)

Slice 1 of docs/free-pool.md (PR #854): the cloud service estimates free+demo
daily cost from a declared cost model (LCHAT_POOL_COSTS), derives the pool
budget from the X%-of-total-cost ceiling + Y% of net revenue + an operator
floor, and records one pool_meter row per sweep with a log line. GET /api/pool
(admin token) serves the latest reading, labelled an estimate. Revenue input
is an additive net_eur_year field per price-book tier. Ships dark: off unless
LCHAT_POOL_MAX_SHARE is set; nothing gates, nothing actuates — admission
control is the next slice, armed only after the gauge has been watched
(the reaper's dry-run-then-arm pattern).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 80b5b039841e17db76fd0d7f2c6e473795c74c28 parent 7e8a31f
7 files changed, +670 −10
  • billing.go +5 −0
  • cloud.go +38 −9
  • cloud_db.go +74 −1
  • docs/cloud-provisioner.md +26 −0
  • meter.go +5 −0
  • pool.go +264 −0
  • pool_test.go +258 −0
diff --git a/billing.go b/billing.go
index a5239f0..278774f 100644
--- a/billing.go
+++ b/billing.go
@@ -49,6 +49,11 @@ type billingTier struct {
Name string `json:"name"`
Label string `json:"label"`
Price string `json:"price"`
+ // NetEURYear is what one subscription nets per year after fees — the free
+ // pool's revenue input (docs/free-pool.md), keeping the standing property
+ // that the server never asks Stripe what a price costs. Optional and
+ // additive: entries without it contribute nothing to the pool budget.
+ NetEURYear float64 `json:"net_eur_year"`
}
// stripeBilling holds the cloud service's Stripe configuration. nil on a
diff --git a/cloud.go b/cloud.go
index 8ca6b1f..1c5bb0e 100644
--- a/cloud.go
+++ b/cloud.go
@@ -91,6 +91,7 @@ type cloudServer struct {
// instance's env (LCHAT_METER_URL) alongside a minted meter token so
// sidecar usage reports can flow (#857). Empty = the usage pipe is off.
usageURL string
+ pool poolConfig // free-pool gauge (pool.go); maxShare 0 = off
}
func (s *cloudServer) handler() http.Handler {
@@ -112,6 +113,8 @@ func (s *cloudServer) handler() http.Handler {
mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintTokenHandler))
mux.HandleFunc("GET /api/admin/tokens", s.admin(s.listTokensHandler))
mux.HandleFunc("DELETE /api/admin/tokens/{id}", s.admin(s.revokeTokenHandler))
+ // The free-pool gauge (pool.go) — read-only, operator-facing.
+ mux.HandleFunc("GET /api/pool", s.admin(s.poolHandler))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
return mux
}
@@ -430,6 +433,10 @@ func runCloud(args []string) {
reapMax := fs.Int("reap-max-per-sweep", envInt("LCHAT_CLOUD_REAP_MAX_PER_SWEEP", 5), "cap reclaims per reaper sweep (blast-radius bound; 0 = unlimited)")
meterIntervalHours := fs.Int("meter-interval-hours", envInt("LCHAT_CLOUD_METER_INTERVAL_HOURS", 6), "how often the storage meter sweeps (0 disables)")
createBudget := fs.Int("max-creates-per-day", envInt("LCHAT_CLOUD_MAX_CREATES_PER_DAY", 0), "fleet-wide creation circuit breaker: max spaces per trailing 24h (0 = uncapped)")
+ poolMaxShare := fs.Int("pool-max-share", envInt("LCHAT_POOL_MAX_SHARE", 0), "free-pool ceiling as % of total cost (0 = pool gauge off)")
+ poolRevenueShare := fs.Int("pool-revenue-share", envInt("LCHAT_POOL_REVENUE_SHARE", 30), "% of net revenue that funds the free pool")
+ poolBaseEURDay := fs.Float64("pool-base-eur-day", envFloat("LCHAT_POOL_BASE_EUR_DAY", 0.20), "operator floor: pool budget in EUR/day at zero revenue")
+ poolDemoShare := fs.Int("pool-demo-share", envInt("LCHAT_POOL_DEMO_SHARE", 20), "% of the pool reserved for demo spaces")
_ = fs.Parse(args)
if *baseDomain == "" || !validHost("x."+*baseDomain) {
@@ -467,22 +474,33 @@ func runCloud(args []string) {
if billing != nil {
billing.portalConfig = os.Getenv("LCHAT_STRIPE_PORTAL_CONFIG")
}
+ poolCosts, err := parsePoolCosts(os.Getenv("LCHAT_POOL_COSTS"))
+ if err != nil {
+ die("cloud: LCHAT_POOL_COSTS: %v", err)
+ }
s := &cloudServer{
- db: db,
- adminHash: hashToken(admin),
- controlToken: controlToken,
- control: controlClient(*controlSocket),
- baseDomain: *baseDomain,
- quota: *quota,
- reapAfter: time.Duration(*reapAfterDays) * 24 * time.Hour,
- reapDryRun: *reapDryRun,
- reapMax: *reapMax,
+ db: db,
+ adminHash: hashToken(admin),
+ controlToken: controlToken,
+ control: controlClient(*controlSocket),
+ baseDomain: *baseDomain,
+ quota: *quota,
+ reapAfter: time.Duration(*reapAfterDays) * 24 * time.Hour,
+ reapDryRun: *reapDryRun,
+ reapMax: *reapMax,
billing: billing,
storageCaps: storageCaps,
replicationClasses: replicationClasses,
createBudget: *createBudget,
usageURL: strings.TrimSpace(os.Getenv("LCHAT_CLOUD_USAGE_URL")),
+ pool: poolConfig{
+ maxShare: *poolMaxShare,
+ revenueShare: *poolRevenueShare,
+ baseEURDay: *poolBaseEURDay,
+ demoShare: *poolDemoShare,
+ costs: poolCosts,
+ },
}
if s.usageURL != "" {
log.Printf("cloud: usage pipe on — meter tokens push %s", s.usageURL)
@@ -490,6 +508,17 @@ func runCloud(args []string) {
if *createBudget > 0 {
log.Printf("cloud: creation budget on (%d/day fleet-wide)", *createBudget)
}
+ if s.pool.enabled() {
+ log.Printf("cloud: free-pool gauge on (ceiling %d%% of cost, %d%% of revenue, floor €%.2f/day, demo share %d%%)",
+ s.pool.maxShare, s.pool.revenueShare, s.pool.baseEURDay, s.pool.demoShare)
+ if billing != nil {
+ for _, t := range billing.tiers {
+ if t.NetEURYear <= 0 {
+ log.Printf("cloud: pool: tier %q has no net_eur_year in LCHAT_STRIPE_PRICES — its spaces fund the pool as €0", t.Key)
+ }
+ }
+ }
+ }
if len(storageCaps) > 0 {
log.Printf("cloud: storage caps on (%d entries)", len(storageCaps))
}
diff --git a/cloud_db.go b/cloud_db.go
index bed2a39..f668d70 100644
--- a/cloud_db.go
+++ b/cloud_db.go
@@ -76,7 +76,10 @@ CREATE TABLE IF NOT EXISTS creates_log (
if err := db.migrateMeter(); err != nil {
return err
}
- return db.migrateUsage()
+ if err := db.migrateUsage(); err != nil {
+ return err
+ }
+ return db.migratePool()
}
// migrateUsage (#857): the per-space usage ledger — quantities only (token
@@ -663,6 +666,76 @@ func (db *cloudDB) allFinalizedSpaces() ([]meterRow, error) {
return out, rows.Err()
}
+// migratePool adds the free-pool gauge's history table (docs/free-pool.md):
+// one row per meter sweep, enough for the control loop, the admin API, and
+// eventually the status page. Additive like every other migration here.
+func (db *cloudDB) migratePool() error {
+ _, err := db.sql.Exec(`
+CREATE TABLE IF NOT EXISTS pool_meter (
+ ts INTEGER PRIMARY KEY,
+ free_count INTEGER NOT NULL,
+ demo_count INTEGER NOT NULL,
+ paid_count INTEGER NOT NULL,
+ free_cost_day REAL NOT NULL,
+ total_cost_day REAL NOT NULL,
+ net_revenue_day REAL NOT NULL,
+ budget_day REAL NOT NULL,
+ pressure REAL NOT NULL
+);`)
+ return err
+}
+
+// poolRows is the pool gauge's sweep set: what the budget math needs to know
+// about every finalized space. NetEURYear stays zero here — the DB layer
+// doesn't know the price book; the sweep resolves it.
+func (db *cloudDB) poolRows() ([]poolRow, error) {
+ rows, err := db.sql.Query(
+ `SELECT entitlement, tier, storage_bytes FROM spaces WHERE host IS NOT NULL`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []poolRow
+ for rows.Next() {
+ var r poolRow
+ if err := rows.Scan(&r.Entitlement, &r.Tier, &r.StorageBytes); err != nil {
+ return nil, err
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
+// insertPoolMeter records one gauge reading and prunes history past ~400 days
+// (a year of context plus slack; the table stays a few thousand rows).
+func (db *cloudDB) insertPoolMeter(ts int64, r poolReading) error {
+ if _, err := db.sql.Exec(
+ `INSERT OR REPLACE INTO pool_meter
+ (ts, free_count, demo_count, paid_count, free_cost_day, total_cost_day, net_revenue_day, budget_day, pressure)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ ts, r.FreeCount, r.DemoCount, r.PaidCount, r.FreeCostDay, r.TotalCostDay, r.NetRevenueDay, r.BudgetDay, r.Pressure,
+ ); err != nil {
+ return err
+ }
+ _, err := db.sql.Exec(`DELETE FROM pool_meter WHERE ts < ?`, ts-400*24*3600)
+ return err
+}
+
+// latestPoolMeter reads the newest gauge reading (ok=false: never swept).
+func (db *cloudDB) latestPoolMeter() (ts int64, r poolReading, ok bool, err error) {
+ err = db.sql.QueryRow(
+ `SELECT ts, free_count, demo_count, paid_count, free_cost_day, total_cost_day, net_revenue_day, budget_day, pressure
+ FROM pool_meter ORDER BY ts DESC LIMIT 1`,
+ ).Scan(&ts, &r.FreeCount, &r.DemoCount, &r.PaidCount, &r.FreeCostDay, &r.TotalCostDay, &r.NetRevenueDay, &r.BudgetDay, &r.Pressure)
+ if err == sql.ErrNoRows {
+ return 0, r, false, nil
+ }
+ if err != nil {
+ return 0, r, false, err
+ }
+ return ts, r, true, nil
+}
+
// spaceBillingByID reads the billing state a quota push needs.
func (db *cloudDB) spaceBillingByID(id int64) (m meterRow, err error) {
m.ID = id
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index a8590ed..5caee7c 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -405,6 +405,32 @@ every space streams (the pre-ladder status quo). What the classes mean —
sync cadence, loss windows, and the graceful-flush guarantee — lives with
the mechanism in `registry.go` and the ops guide.
+## The free-pool gauge (docs/free-pool.md slice 1)
+
+The free pool runs the free tier as a budget: free + demo spend must never
+exceed a configured share of total cost, funded by paid revenue with an
+operator floor. **Slice 1 is a gauge only** — it computes and records, gates
+nothing, and ships off by default. At the end of each meter sweep the cloud
+service estimates every ledger space's daily cost from a declared cost model
+(`LCHAT_POOL_COSTS`, defaults = the docs/free-pool.md snapshot), derives
+
+```
+budget_day = min( X% × total_cost_day, max(floor, Y% × net_revenue_day) )
+pressure = free_cost_day / budget_day
+```
+
+and records one `pool_meter` row (pruned past ~400 days) plus a log line.
+Knobs: `LCHAT_POOL_MAX_SHARE` (X — **0/unset = gauge off**),
+`LCHAT_POOL_REVENUE_SHARE` (Y, default 30), `LCHAT_POOL_BASE_EUR_DAY`
+(default 0.20), `LCHAT_POOL_DEMO_SHARE` (default 20, reserved for the demo
+tier). Revenue comes from an additive `net_eur_year` field per tier in
+`LCHAT_STRIPE_PRICES` (entries without it fund the pool as €0 and log a
+startup warning). `GET /api/pool` (admin token) serves the latest reading —
+labelled an estimate, because it is one: derived from declared constants,
+not metered. Admission control (`free_pool_full`) and demo coupling are
+later slices, armed only after the gauge has been watched — the reaper's
+dry-run-then-arm pattern.
+
## Billing (Stripe) — tiers, quota exemption, no metering
Optional space upgrades (`billing.go`): a person who created a space can pay a
diff --git a/meter.go b/meter.go
index b56a654..fcb5fc8 100644
--- a/meter.go
+++ b/meter.go
@@ -126,6 +126,11 @@ func (s *cloudServer) meterOnce() (metered int) {
if metered > 0 {
log.Printf("cloud: meter: refreshed %d space(s)", metered)
}
+ // The free-pool gauge rides the same sweep (pool.go): the storage figures
+ // it reads from the ledger were refreshed moments ago.
+ if s.pool.enabled() {
+ s.poolSweep(time.Now())
+ }
return metered
}
diff --git a/pool.go b/pool.go
new file mode 100644
index 0000000..68175b0
--- /dev/null
+++ b/pool.go
@@ -0,0 +1,264 @@
+package main
+
+// The free pool, slice 1 (docs/free-pool.md): a GAUGE, no behavior change.
+// Every meter sweep estimates what the free tier costs per day (from a tiny
+// config-declared cost model), derives the pool's daily budget from the
+// percent-of-total-cost ceiling + net revenue + an operator floor, and records
+// one pressure reading. Nothing here gates a create or touches an instance —
+// admission control and demo coupling are later slices, taken only once this
+// gauge has been watched for a while (the reaper's dry-run-then-arm pattern).
+//
+// The readings are ESTIMATES by construction: per-instance cost is derived
+// from declared constants (awake fractions, slot/storage rates), not metered.
+// That's deliberate — real cost attribution is heavy machinery for cents —
+// but every surface that shows a reading must say so.
+
+import (
+ "fmt"
+ "log"
+ "math"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// poolCosts is the declared cost model (LCHAT_POOL_COSTS): euro rates and the
+// assumed awake fractions. Defaults are the 2026-07 snapshot from
+// docs/free-pool.md; formulas are the contract, these numbers drift and get
+// re-measured at tier-economics re-derivations.
+type poolCosts struct {
+ fixedDay float64 // whole cloud plane per day (box + router/home/push/cloud)
+ slotDay float64 // one materialized instance slot per day
+ reqDay float64 // S3 request tail of one always-awake instance per day
+ storageGBDay float64 // one stored GB per day (replica x retention)
+ awakeFree float64 // assumed awake fraction of a free/paid space
+ awakeDemo float64 // assumed awake fraction of a demo (short-lived, poked)
+}
+
+func defaultPoolCosts() poolCosts {
+ return poolCosts{
+ fixedDay: 1.17,
+ slotDay: 0.027,
+ reqDay: 0.002,
+ storageGBDay: 0.0014,
+ awakeFree: 0.05,
+ awakeDemo: 0.30,
+ }
+}
+
+// parsePoolCosts reads LCHAT_POOL_COSTS ("fixed_day=1.17,slot_day=0.027,…").
+// Absent keys keep their defaults; an unknown key or bad value is a deployment
+// mistake and errors rather than silently mismeasuring.
+func parsePoolCosts(v string) (poolCosts, error) {
+ c := defaultPoolCosts()
+ if strings.TrimSpace(v) == "" {
+ return c, nil
+ }
+ fields := map[string]*float64{
+ "fixed_day": &c.fixedDay,
+ "slot_day": &c.slotDay,
+ "req_day": &c.reqDay,
+ "storage_gb_day": &c.storageGBDay,
+ "awake_free": &c.awakeFree,
+ "awake_demo": &c.awakeDemo,
+ }
+ for _, pair := range strings.Split(v, ",") {
+ k, val, ok := strings.Cut(strings.TrimSpace(pair), "=")
+ if !ok {
+ return c, fmt.Errorf("bad entry %q (want key=value)", pair)
+ }
+ dst, known := fields[strings.TrimSpace(k)]
+ if !known {
+ return c, fmt.Errorf("unknown cost %q", k)
+ }
+ n, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
+ if err != nil || n < 0 {
+ return c, fmt.Errorf("cost %q: bad value %q", k, val)
+ }
+ *dst = n
+ }
+ return c, nil
+}
+
+// poolConfig is the budget's shape: X caps the subsidy as a share of total
+// cost, Y indexes it to revenue, the floor keeps a pool alive at zero revenue,
+// and D reserves a slice for demos (unused until the demo tier exists).
+type poolConfig struct {
+ maxShare int // X: free-spend ceiling as % of total cost; 0 = pool off
+ revenueShare int // Y: % of net revenue that funds the pool
+ baseEURDay float64
+ demoShare int // D: % of the pool reserved for demos
+ costs poolCosts
+}
+
+func (c poolConfig) enabled() bool { return c.maxShare > 0 }
+
+// poolRow is one ledger space as the budget math sees it. NetEURYear is
+// resolved from the price book by the caller (0 for free/demo rows and for
+// tiers whose price-book entry carries no net_eur_year).
+type poolRow struct {
+ Entitlement string
+ Tier string
+ StorageBytes int64
+ NetEURYear float64
+}
+
+// poolReading is one sweep's gauge output — what pool_meter stores.
+type poolReading struct {
+ FreeCount int
+ DemoCount int
+ PaidCount int
+ FreeCostDay float64
+ TotalCostDay float64
+ NetRevenueDay float64
+ BudgetDay float64
+ DemoBudgetDay float64
+ Pressure float64
+}
+
+// computePool is the budget math (docs/free-pool.md § The budget formula),
+// pure for testing:
+//
+// budget_day = min( X% x total_cost_day, max(floor, Y% x net_revenue_day) )
+// pressure = free_cost_day / budget_day
+func computePool(cfg poolConfig, rows []poolRow) poolReading {
+ var r poolReading
+ for _, row := range rows {
+ awake := cfg.costs.awakeFree
+ // "demo" is the entitlement the demo tier will use (docs/demo-spaces.md);
+ // counting it here means the gauge is demo-ready the day that lands.
+ if row.Entitlement == "demo" {
+ awake = cfg.costs.awakeDemo
+ }
+ cost := awake*(cfg.costs.slotDay+cfg.costs.reqDay) +
+ float64(row.StorageBytes)/1e9*cfg.costs.storageGBDay
+ r.TotalCostDay += cost
+ switch row.Entitlement {
+ case entPaid:
+ r.PaidCount++
+ r.NetRevenueDay += row.NetEURYear / 365
+ case "demo":
+ r.DemoCount++
+ r.FreeCostDay += cost
+ default:
+ r.FreeCount++
+ r.FreeCostDay += cost
+ }
+ }
+ r.TotalCostDay += cfg.costs.fixedDay
+ invariantCap := float64(cfg.maxShare) / 100 * r.TotalCostDay
+ revenuePool := float64(cfg.revenueShare) / 100 * r.NetRevenueDay
+ r.BudgetDay = math.Min(invariantCap, math.Max(cfg.baseEURDay, revenuePool))
+ r.DemoBudgetDay = float64(cfg.demoShare) / 100 * r.BudgetDay
+ if r.BudgetDay > 0 {
+ r.Pressure = r.FreeCostDay / r.BudgetDay
+ }
+ return r
+}
+
+// poolState names a pressure for humans. The thresholds are the pre-agreed
+// policy from docs/free-pool.md; in this slice they label the gauge and
+// nothing else — no threshold actuates anything yet.
+func poolState(pressure float64) string {
+ switch {
+ case pressure < 0.8:
+ return "green"
+ case pressure < 1.0:
+ return "amber"
+ case pressure < 1.5:
+ return "red"
+ default:
+ return "deep_red"
+ }
+}
+
+// netForTier resolves a paid space's net revenue from the price book's
+// additive net_eur_year field (0 when billing is off or the entry has none).
+func (s *cloudServer) netForTier(tier string) float64 {
+ if s.billing == nil {
+ return 0
+ }
+ if t, ok := s.billing.tier(tier); ok {
+ return t.NetEURYear
+ }
+ return 0
+}
+
+// poolSweep computes and records one gauge reading from the ledger. Called at
+// the end of each meter sweep (the storage figures it reads were just
+// refreshed); reads only the ledger — never controld, never an instance.
+func (s *cloudServer) poolSweep(now time.Time) {
+ rows, err := s.db.poolRows()
+ if err != nil {
+ log.Printf("cloud: pool: ledger query: %v", err)
+ return
+ }
+ for i := range rows {
+ if rows[i].Entitlement == entPaid {
+ rows[i].NetEURYear = s.netForTier(rows[i].Tier)
+ }
+ }
+ r := computePool(s.pool, rows)
+ if r.BudgetDay <= 0 {
+ log.Printf("cloud: pool: budget is zero (set LCHAT_POOL_BASE_EUR_DAY or add revenue) — gauge recorded, pressure meaningless")
+ }
+ if err := s.db.insertPoolMeter(now.Unix(), r); err != nil {
+ log.Printf("cloud: pool: record: %v", err)
+ return
+ }
+ log.Printf("cloud: pool %s — free spend ~€%.3f/day of €%.3f/day budget (pressure %.2f; %d free, %d demo, %d paid; est.)",
+ poolState(r.Pressure), r.FreeCostDay, r.BudgetDay, r.Pressure, r.FreeCount, r.DemoCount, r.PaidCount)
+}
+
+// poolHandler: GET /api/pool (admin token) — the latest gauge reading, for the
+// operator and, later, the status page. Everything in it is an estimate from
+// the declared cost model, and the payload says so.
+func (s *cloudServer) poolHandler(w http.ResponseWriter, r *http.Request) {
+ if !s.pool.enabled() {
+ writeJSON(w, http.StatusOK, map[string]any{"enabled": false})
+ return
+ }
+ ts, reading, ok, err := s.db.latestPoolMeter()
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "pool_failed", "ledger error")
+ return
+ }
+ if !ok {
+ writeJSON(w, http.StatusOK, map[string]any{"enabled": true, "measured": false})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "enabled": true,
+ "measured": true,
+ "estimate": true, // derived from the declared cost model, not metered
+ "ts": ts,
+ "state": poolState(reading.Pressure),
+ "pressure": reading.Pressure,
+ "free_count": reading.FreeCount,
+ "demo_count": reading.DemoCount,
+ "paid_count": reading.PaidCount,
+ "free_cost_day": reading.FreeCostDay,
+ "total_cost_day": reading.TotalCostDay,
+ "net_revenue_day": reading.NetRevenueDay,
+ "budget_day": reading.BudgetDay,
+ "demo_budget_day": reading.DemoBudgetDay,
+ "config": map[string]any{
+ "max_share": s.pool.maxShare,
+ "revenue_share": s.pool.revenueShare,
+ "base_eur_day": s.pool.baseEURDay,
+ "demo_share": s.pool.demoShare,
+ },
+ })
+}
+
+// envFloat mirrors envInt for fractional knobs.
+func envFloat(key string, def float64) float64 {
+ if v := os.Getenv(key); v != "" {
+ if n, err := strconv.ParseFloat(v, 64); err == nil {
+ return n
+ }
+ }
+ return def
+}
diff --git a/pool_test.go b/pool_test.go
new file mode 100644
index 0000000..e7154f0
--- /dev/null
+++ b/pool_test.go
@@ -0,0 +1,258 @@
+package main
+
+// The free-pool gauge (pool.go): pure budget math, the cost-model config
+// parser, the sweep's ledger round-trip, and the admin endpoint. No test here
+// exercises admission control — slice 1 is a gauge and these tests pin that
+// (nothing refuses, nothing actuates).
+
+import (
+ "encoding/json"
+ "math"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func near(t *testing.T, name string, got, want float64) {
+ t.Helper()
+ if math.Abs(got-want) > 1e-9 {
+ t.Fatalf("%s = %v, want %v", name, got, want)
+ }
+}
+
+// Easy-arithmetic cost model for the math tests.
+func testPoolCfg() poolConfig {
+ return poolConfig{
+ maxShare: 25,
+ revenueShare: 30,
+ baseEURDay: 0.20,
+ demoShare: 20,
+ costs: poolCosts{
+ fixedDay: 1.0,
+ slotDay: 0.03,
+ reqDay: 0.01,
+ storageGBDay: 0.001,
+ awakeFree: 0.5,
+ awakeDemo: 1.0,
+ },
+ }
+}
+
+func TestParsePoolCostsDefaults(t *testing.T) {
+ c, err := parsePoolCosts("")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c != defaultPoolCosts() {
+ t.Fatalf("empty config should yield defaults, got %+v", c)
+ }
+}
+
+func TestParsePoolCostsOverrides(t *testing.T) {
+ c, err := parsePoolCosts("fixed_day=2.5, awake_demo=0.6")
+ if err != nil {
+ t.Fatal(err)
+ }
+ near(t, "fixedDay", c.fixedDay, 2.5)
+ near(t, "awakeDemo", c.awakeDemo, 0.6)
+ // Untouched keys keep their defaults.
+ near(t, "slotDay", c.slotDay, defaultPoolCosts().slotDay)
+}
+
+func TestParsePoolCostsRejectsMistakes(t *testing.T) {
+ for _, bad := range []string{"fixed=1.0", "fixed_day=abc", "slot_day=-1", "fixed_day"} {
+ if _, err := parsePoolCosts(bad); err == nil {
+ t.Fatalf("parsePoolCosts(%q) should error", bad)
+ }
+ }
+}
+
+// Zero revenue: the operator floor carries the pool, clamped by the X ceiling.
+func TestComputePoolZeroRevenue(t *testing.T) {
+ cfg := testPoolCfg()
+ rows := []poolRow{
+ {Entitlement: "free", StorageBytes: 1e9},
+ {Entitlement: "free", StorageBytes: 1e9},
+ }
+ r := computePool(cfg, rows)
+ // Each free space: 0.5*(0.03+0.01) + 1GB*0.001 = 0.021.
+ near(t, "FreeCostDay", r.FreeCostDay, 0.042)
+ near(t, "TotalCostDay", r.TotalCostDay, 1.042)
+ // budget = min(25% x 1.042, max(0.20, 0)) = 0.20 — the floor wins at zero revenue.
+ near(t, "BudgetDay", r.BudgetDay, 0.20)
+ near(t, "Pressure", r.Pressure, 0.042/0.20)
+ near(t, "DemoBudgetDay", r.DemoBudgetDay, 0.04)
+ if r.FreeCount != 2 || r.PaidCount != 0 || r.DemoCount != 0 {
+ t.Fatalf("counts = %d/%d/%d, want 2 free / 0 demo / 0 paid", r.FreeCount, r.DemoCount, r.PaidCount)
+ }
+}
+
+// With revenue, the Y-term takes over — and the X ceiling clamps it.
+func TestComputePoolRevenueClampedByCeiling(t *testing.T) {
+ cfg := testPoolCfg()
+ rows := []poolRow{
+ {Entitlement: "free", StorageBytes: 1e9},
+ {Entitlement: "free", StorageBytes: 1e9},
+ {Entitlement: entPaid, Tier: "business", NetEURYear: 3650}, // €10/day net
+ }
+ r := computePool(cfg, rows)
+ near(t, "NetRevenueDay", r.NetRevenueDay, 10)
+ // Paid space still costs (0.5*0.04 = 0.02): total = 0.042+0.02+1.0 = 1.062.
+ near(t, "TotalCostDay", r.TotalCostDay, 1.062)
+ // revenue pool = 3.0 but the invariant cap = 25% x 1.062 = 0.2655 clamps.
+ near(t, "BudgetDay", r.BudgetDay, 0.2655)
+ if r.PaidCount != 1 {
+ t.Fatalf("PaidCount = %d, want 1", r.PaidCount)
+ }
+}
+
+// Demos cost at the demo awake fraction and count into free spend.
+func TestComputePoolDemo(t *testing.T) {
+ cfg := testPoolCfg()
+ r := computePool(cfg, []poolRow{{Entitlement: "demo"}})
+ // 1.0*(0.03+0.01) = 0.04, no storage.
+ near(t, "FreeCostDay", r.FreeCostDay, 0.04)
+ if r.DemoCount != 1 || r.FreeCount != 0 {
+ t.Fatalf("counts = %d free / %d demo, want 0/1", r.FreeCount, r.DemoCount)
+ }
+}
+
+func TestPoolStateThresholds(t *testing.T) {
+ for _, tc := range []struct {
+ pressure float64
+ want string
+ }{{0, "green"}, {0.79, "green"}, {0.8, "amber"}, {0.99, "amber"}, {1.0, "red"}, {1.49, "red"}, {1.5, "deep_red"}} {
+ if got := poolState(tc.pressure); got != tc.want {
+ t.Fatalf("poolState(%v) = %q, want %q", tc.pressure, got, tc.want)
+ }
+ }
+}
+
+// The sweep reads the ledger, resolves net revenue from the price book, and
+// records a reading that round-trips through pool_meter.
+func TestPoolSweepRecordsReading(t *testing.T) {
+ db := openTestCloudDB(t)
+ free := insertSpaceRow(t, db, "free.example.com", 1000)
+ paid := insertSpaceRow(t, db, "paid.example.com", 1000)
+ if _, err := db.sql.Exec(`UPDATE spaces SET storage_bytes = 2000000000 WHERE id = ?`, free); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.sql.Exec(`UPDATE spaces SET entitlement = 'paid', tier = 'friends' WHERE id = ?`, paid); err != nil {
+ t.Fatal(err)
+ }
+ s := &cloudServer{
+ db: db,
+ pool: testPoolCfg(),
+ billing: &stripeBilling{tiers: []billingTier{
+ {Key: "friends", Name: "Friends", Label: "€40/year", Price: "price_f", NetEURYear: 36.5},
+ }},
+ }
+ s.poolSweep(time.Unix(2_000_000_000, 0))
+
+ ts, r, ok, err := db.latestPoolMeter()
+ if err != nil || !ok {
+ t.Fatalf("latestPoolMeter: ok=%v err=%v", ok, err)
+ }
+ if ts != 2_000_000_000 {
+ t.Fatalf("ts = %d", ts)
+ }
+ if r.FreeCount != 1 || r.PaidCount != 1 || r.DemoCount != 0 {
+ t.Fatalf("counts = %d/%d/%d", r.FreeCount, r.DemoCount, r.PaidCount)
+ }
+ near(t, "NetRevenueDay", r.NetRevenueDay, 36.5/365) // 0.1 — from the price book
+ // free: 0.5*0.04 + 2GB*0.001 = 0.022; paid adds 0.02; fixed 1.0.
+ near(t, "FreeCostDay", r.FreeCostDay, 0.022)
+ near(t, "TotalCostDay", r.TotalCostDay, 1.042)
+ if r.Pressure <= 0 {
+ t.Fatalf("pressure = %v, want > 0", r.Pressure)
+ }
+}
+
+// The gauge rides the meter sweep when enabled — and stays silent when off.
+func TestMeterOnceRunsPoolSweep(t *testing.T) {
+ db := openTestCloudDB(t)
+ insertSpaceRow(t, db, "a.example.com", 1000)
+ sock := fakeControl(t, func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{
+ "members": 1, "setup_complete": true, "has_content": true, "storage_bytes": 1000,
+ })
+ })
+ s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", pool: testPoolCfg()}
+ s.meterOnce()
+ if _, _, ok, _ := db.latestPoolMeter(); !ok {
+ t.Fatal("meterOnce with the pool enabled should record a gauge reading")
+ }
+
+ // Pool off (the shipped default): same sweep, no reading.
+ db2 := openTestCloudDB(t)
+ insertSpaceRow(t, db2, "b.example.com", 1000)
+ s2 := &cloudServer{db: db2, control: controlClient(sock), controlToken: "t"}
+ s2.meterOnce()
+ if _, _, ok, _ := db2.latestPoolMeter(); ok {
+ t.Fatal("meterOnce with the pool off must not record a reading")
+ }
+}
+
+func poolGET(t *testing.T, url, token string) (*http.Response, map[string]any) {
+ t.Helper()
+ req, err := http.NewRequest("GET", url+"/api/pool", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var body map[string]any
+ _ = json.NewDecoder(resp.Body).Decode(&body)
+ return resp, body
+}
+
+func TestPoolHandler(t *testing.T) {
+ db := openTestCloudDB(t)
+ s := &cloudServer{db: db, adminHash: hashToken("admin-secret"), pool: testPoolCfg()}
+ srv := httptest.NewServer(s.handler())
+ defer srv.Close()
+
+ // Admin-gated like the token surface.
+ if resp, _ := poolGET(t, srv.URL, ""); resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("no token: HTTP %d, want 401", resp.StatusCode)
+ }
+ if resp, _ := poolGET(t, srv.URL, "wrong"); resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("bad token: HTTP %d, want 401", resp.StatusCode)
+ }
+
+ // Enabled but never swept.
+ resp, body := poolGET(t, srv.URL, "admin-secret")
+ if resp.StatusCode != http.StatusOK || body["enabled"] != true || body["measured"] != false {
+ t.Fatalf("pre-sweep: HTTP %d body %v", resp.StatusCode, body)
+ }
+
+ // After a sweep: the reading, labelled an estimate.
+ insertSpaceRow(t, db, "c.example.com", 1000)
+ s.poolSweep(time.Unix(2_000_000_000, 0))
+ resp, body = poolGET(t, srv.URL, "admin-secret")
+ if resp.StatusCode != http.StatusOK || body["measured"] != true || body["estimate"] != true {
+ t.Fatalf("post-sweep: HTTP %d body %v", resp.StatusCode, body)
+ }
+ if body["state"] != "green" {
+ t.Fatalf("state = %v, want green (one tiny free space)", body["state"])
+ }
+ if _, ok := body["pressure"].(float64); !ok {
+ t.Fatalf("pressure missing: %v", body)
+ }
+
+ // Pool off: the endpoint says so instead of 404ing (status pages consume this).
+ sOff := &cloudServer{db: db, adminHash: hashToken("admin-secret")}
+ srvOff := httptest.NewServer(sOff.handler())
+ defer srvOff.Close()
+ resp, body = poolGET(t, srvOff.URL, "admin-secret")
+ if resp.StatusCode != http.StatusOK || body["enabled"] != false {
+ t.Fatalf("pool off: HTTP %d body %v", resp.StatusCode, body)
+ }
+}