carlos / platform Public

console: fleet box-hours metered per account with device class

Adds rollupPresenceHours (per-present-hour box presence: Host["cpu"].N
minutes/60, else max N across Host aggs, else a 1.0h fallback) and
fleetMeters, which derives fleet_box_hours_server/device per account
from every fleet box's day rollups (all statuses — a detached box's
past hours still bill), split by the literal "device" tag. Label
collisions across fleets resolve to the alphabetically-first fleet
(ListFleets' own order), logged once per pass per label.

fleetMeters is a second bounded read over metrics day objects scoped
to fleet-box labels only, deliberately not an extension of
sweepUsage's unit-attributed loop — keeps sweepUsage and its tests
untouched. statementsPass folds it in as a union merge before the
account universe is built, so an account with only fleet usage still
gets a statement.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 31d8d5e7fbfeb06abcc6400507c35c1dd59160c0 parent 22c3f8f
2 files changed, +246 −6
  • internal/console/economicspass.go +159 −6
  • internal/console/economicspass_test.go +87 −0
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go
index ed8e45cb2..29935be43 100644
--- a/internal/console/economicspass.go
+++ b/internal/console/economicspass.go
@@ -505,13 +505,152 @@ func (s *Server) foldStorageAndCensus(ctx context.Context, from, to time.Time, d
return nil
}
+// rollupPresenceHours converts one box's day rollup into billable box-hours
+// under the fleet-hours presence rule (task-3 brief): fleet-box rollups may
+// carry only the host-level aggregate (no per-unit CPU sampling — the box
+// runs no CARLOS-managed unit at all, just the remote-host-agent itself), so
+// presence is derived from the Host aggs rather than Units. For each PRESENT
+// hour (a key in d.Hours, regardless of what it carries):
+// - Host["cpu"].N minutes/60 when the host cpu agg is present;
+// - else the max N across whatever OTHER Host aggs the hour does carry;
+// - else — Host itself has no aggs at all for the hour — a flat 60-minute
+// (1.0h) fallback, since a present-but-metric-less hour key still means
+// the box was up and dialed in for that hour.
+func rollupPresenceHours(d metrics.DayRollup) float64 {
+ var total float64
+ for _, hour := range d.Hours {
+ if cpu, ok := hour.Host["cpu"]; ok {
+ total += float64(cpu.N) / 60
+ continue
+ }
+ maxN := 0
+ for _, agg := range hour.Host {
+ if agg.N > maxN {
+ maxN = agg.N
+ }
+ }
+ if maxN > 0 {
+ total += float64(maxN) / 60
+ continue
+ }
+ total += 1.0
+ }
+ return total
+}
+
+// fleetMeters derives per-account fleet box-hours for [from, to]: for every
+// box registered to every fleet (ALL statuses — a detached box's past hours
+// still bill, same posture as a deleted-account's already-written days),
+// account -> {fleet_box_hours_server, fleet_box_hours_device}, split by
+// whether the box's Tags carry the literal "device" tag.
+//
+// Deliberately a SECOND bounded read over metrics day objects, not an
+// extension of sweepUsage's single pass: sweepUsage iterates every box under
+// metrics/boxes/ once and attributes by UNIT (unitOwner), which has no
+// concept of a fleet box at all — a fleet box's day rollup carries no units
+// most of the time (see rollupPresenceHours's doc comment), only a host
+// aggregate, so folding this in there would mean threading a label->owner
+// map through sweepUsage's hot loop and its box-vs-fleet-box branching for a
+// meter family only two of usageSweep's several callers need at all. This
+// keeps sweepUsage and its existing tests completely untouched: the read
+// here is bounded to exactly the boxes that ARE fleet boxes (one Get per
+// fleet-box label per day in range; a day with no rollup is skipped, same
+// missing-means-skip convention sweepUsage itself uses).
+//
+// Label collisions across fleets — the registry enforces per-fleet box-label
+// uniqueness only, not a global one — resolve to the alphabetically-first
+// fleet name (ListFleets already returns fleets ordered by name, so the
+// first owner claimed for a label during that ordered walk wins) with one
+// log line per pass per colliding label.
+func (s *Server) fleetMeters(ctx context.Context, from, to time.Time) (map[string]map[string]float64, error) {
+ reg, err := s.openPassRegistry()
+ if err != nil {
+ return nil, fmt.Errorf("console: fleet meters: %w", err)
+ }
+ fleets, err := reg.ListFleets()
+ if err != nil {
+ return nil, fmt.Errorf("console: fleet meters: %w", err)
+ }
+
+ type boxOwner struct {
+ acct string
+ device bool
+ }
+ labelOwner := map[string]boxOwner{}
+ labelFleet := map[string]string{} // label -> fleet that claimed it, for the collision log line
+ loggedCollision := map[string]bool{}
+
+ for _, f := range fleets {
+ boxes, err := reg.ListFleetBoxes(f.Name)
+ if err != nil {
+ return nil, fmt.Errorf("console: fleet meters: list boxes %s: %w", f.Name, err)
+ }
+ for _, b := range boxes {
+ if _, exists := labelOwner[b.Label]; exists {
+ if !loggedCollision[b.Label] {
+ loggedCollision[b.Label] = true
+ log.Printf("console: fleet meters: box label %q claimed by both fleet %q and fleet %q; %q (alphabetically first) wins",
+ b.Label, labelFleet[b.Label], f.Name, labelFleet[b.Label])
+ }
+ continue
+ }
+ device := false
+ for _, tag := range strings.Split(b.Tags, ",") {
+ if tag == "device" {
+ device = true
+ break
+ }
+ }
+ labelOwner[b.Label] = boxOwner{acct: f.Account, device: device}
+ labelFleet[b.Label] = f.Name
+ }
+ }
+
+ out := map[string]map[string]float64{}
+ touch := func(acct string) map[string]float64 {
+ mm := out[acct]
+ if mm == nil {
+ mm = map[string]float64{}
+ out[acct] = mm
+ }
+ return mm
+ }
+
+ fromDay := from.UTC().Truncate(24 * time.Hour)
+ toDay := to.UTC().Truncate(24 * time.Hour)
+ for label, owner := range labelOwner {
+ meter := usage.MeterFleetBoxHoursServer
+ if owner.device {
+ meter = usage.MeterFleetBoxHoursDevice
+ }
+ for d := fromDay; !d.After(toDay); d = d.AddDate(0, 0, 1) {
+ date := usage.DayOf(d)
+ key := metrics.DayKey(label, date)
+ raw, _, gerr := s.cfg.Releases.Get(ctx, key)
+ if gerr != nil {
+ continue // missing = skip, same convention as sweepUsage
+ }
+ var roll metrics.DayRollup
+ if jerr := json.Unmarshal(raw, &roll); jerr != nil {
+ log.Printf("console: fleet meters: parse %s: %v", key, jerr)
+ continue
+ }
+ touch(owner.acct)[meter] += rollupPresenceHours(roll)
+ }
+ }
+ return out, nil
+}
+
// statementsPass folds the month's metrics, edge and storage days (plus
-// census, for app_months) into one usage.Statement per account, create-or-
-// replace via usage.UpsertStatement — safe to run repeatedly as new days'
-// objects appear. It is a thin caller: open the registry (refusing early —
-// see openPassRegistry — since this is the WRITE pass), sweep the whole
-// month to date (sweepUsage), fold in storage/census meters
-// (foldStorageAndCensus), then upsert.
+// census, for app_months, and fleet box-hours, for fleet_box_hours_server/
+// device) into one usage.Statement per account, create-or-replace via
+// usage.UpsertStatement — safe to run repeatedly as new days' objects
+// appear. It is a thin caller: open the registry (refusing early — see
+// openPassRegistry — since this is the WRITE pass), sweep the whole month to
+// date (sweepUsage), fold in storage/census meters (foldStorageAndCensus),
+// fold in fleet meters (fleetMeters — a union merge, since an account with
+// ONLY fleet usage and no platform routes at all must still get every meter
+// key it's owed), then upsert.
//
// Known shortfall, recorded rather than fixed at modelling grade: an account
// deleted mid-month drops out of s.store.Accounts() (its query filters
@@ -542,6 +681,20 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error {
if err := s.foldStorageAndCensus(ctx, monthStart, nowUTC, meters); err != nil {
return fmt.Errorf("console: statements pass: %w", err)
}
+ fleetMM, err := s.fleetMeters(ctx, monthStart, nowUTC)
+ if err != nil {
+ return fmt.Errorf("console: statements pass: %w", err)
+ }
+ for acct, mm := range fleetMM {
+ dst := meters[acct]
+ if dst == nil {
+ dst = map[string]float64{}
+ meters[acct] = dst
+ }
+ for meter, v := range mm {
+ dst[meter] += v
+ }
+ }
coveredDays := sweep.CoveredDays
// Every account gets a statement this pass, even one with zero usage —
diff --git a/internal/console/economicspass_test.go b/internal/console/economicspass_test.go
index a363ace3e..202c468b2 100644
--- a/internal/console/economicspass_test.go
+++ b/internal/console/economicspass_test.go
@@ -7,6 +7,7 @@ import (
"math"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
@@ -450,3 +451,89 @@ func TestStatementsPassRejectsInvalidStorageAccountSegment(t *testing.T) {
t.Fatalf("ops storage_gb_month = %v, want %v (invalid segment's bytes must land here)", got, want)
}
}
+
+// TestRollupPresenceHours pins rollupPresenceHours's exact rule (task-3
+// brief): per present hour (a key in Hours), Host["cpu"].N minutes/60 when
+// present; else the max N across any other Host agg; else — Host itself
+// carrying no aggs at all — a flat 60-minute (1.0h) fallback. Two hours: one
+// with Host["cpu"].N=60 (-> 1.0h), one with an empty Host map (-> 1.0h
+// fallback); total 2.0.
+func TestRollupPresenceHours(t *testing.T) {
+ d := metrics.DayRollup{V: 1, Box: "bx", Date: "2026-08-10", Hours: map[string]metrics.HourAgg{
+ "09": {Host: map[string]metrics.Agg{"cpu": {N: 60}}},
+ "10": {Host: map[string]metrics.Agg{}},
+ }}
+ if got, want := rollupPresenceHours(d), 2.0; math.Abs(got-want) > 1e-9 {
+ t.Fatalf("rollupPresenceHours = %v, want %v", got, want)
+ }
+}
+
+// TestStatementsPassFleetMeters: a fleet box's day rollups price into the
+// owning account's fleet_box_hours_server/device meters, split by the
+// literal "device" tag — bx-server (untagged) contributes to the server
+// meter, bx-pi (tags "pi,device") to the device meter.
+func TestStatementsPassFleetMeters(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+ now := time.Date(2026, 8, 11, 6, 0, 0, 0, time.UTC)
+
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := reg.CreateFleet(registry.Fleet{Name: "fx", Account: "bae", Bucket: "fx-data"}); err != nil {
+ t.Fatal(err)
+ }
+ tokenHash := strings.Repeat("a", 64)
+ if err := reg.AddFleetBox(registry.FleetBox{Fleet: "fx", Label: "bx-server", TokenHash: tokenHash}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.AddFleetBox(registry.FleetBox{Fleet: "fx", Label: "bx-pi", TokenHash: tokenHash}); err != nil {
+ t.Fatal(err)
+ }
+ // AddFleetBox's INSERT does not carry tags (schema default '') — a box's
+ // tags are set through the dedicated SetFleetBoxTags writer.
+ if err := reg.SetFleetBoxTags("fx", "bx-pi", "pi,device"); err != nil {
+ t.Fatal(err)
+ }
+
+ serverHours := map[string]metrics.HourAgg{}
+ for h := 0; h < 24; h++ {
+ serverHours[hourKey(h)] = metrics.HourAgg{Host: map[string]metrics.Agg{"cpu": {N: 60}}}
+ }
+ putStoreJSON(t, fx.rel, metrics.DayKey("bx-server", "2026-08-10"), metrics.DayRollup{
+ V: 1, Box: "bx-server", Date: "2026-08-10", Hours: serverHours,
+ })
+
+ piHours := map[string]metrics.HourAgg{}
+ for h := 0; h < 12; h++ {
+ piHours[hourKey(h)] = metrics.HourAgg{Host: map[string]metrics.Agg{"cpu": {N: 60}}}
+ }
+ putStoreJSON(t, fx.rel, metrics.DayKey("bx-pi", "2026-08-10"), metrics.DayRollup{
+ V: 1, Box: "bx-pi", Date: "2026-08-10", Hours: piHours,
+ })
+
+ if err := fx.srv.statementsPass(ctx, now); err != nil {
+ t.Fatal(err)
+ }
+
+ raw, _, err := fx.rel.Get(ctx, usage.StatementKey("bae", "2026-08"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var st usage.Statement
+ if err := json.Unmarshal(raw, &st); err != nil {
+ t.Fatal(err)
+ }
+ if got, want := st.Meters[usage.MeterFleetBoxHoursServer], 24.0; math.Abs(got-want) > 1e-9 {
+ t.Fatalf("fleet_box_hours_server = %v, want %v", got, want)
+ }
+ if got, want := st.Meters[usage.MeterFleetBoxHoursDevice], 12.0; math.Abs(got-want) > 1e-9 {
+ t.Fatalf("fleet_box_hours_device = %v, want %v", got, want)
+ }
+}
+
+// hourKey formats an hour 0-23 as the DayRollup.Hours key shape ("00".."23").
+func hourKey(h int) string {
+ return time.Date(2000, 1, 1, h, 0, 0, 0, time.UTC).Format("15")
+}