carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "crypto/subtle"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "log"
10 "math"
11 "net/http"
12 "os"
13 "sort"
14 "strconv"
15 "strings"
16 "time"
17
18 "github.com/aws/aws-sdk-go-v2/aws"
19 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
20
21 "github.com/carlosframework/platform/internal/activator"
22 "github.com/carlosframework/platform/internal/economics"
23 "github.com/carlosframework/platform/internal/metrics"
24 "github.com/carlosframework/platform/internal/objstore"
25 "github.com/carlosframework/platform/internal/registry"
26 "github.com/carlosframework/platform/internal/release"
27 "github.com/carlosframework/platform/internal/usage"
28)
29
30// economicsPassInterval is how often the pass wakes. Each day's objects are
31// create-only, so waking hourly costs one conditional GET per family once
32// the day is written — and catches up promptly after a console restart.
33const economicsPassInterval = time.Hour
34
35// StartEconomicsPass runs economicsPass immediately and then hourly until
36// ctx is done. Started from cmd/console's serve(), deliberately NOT from
37// NewServer — the StartAlertWatcher rule: test servers never grow
38// goroutines (alertwatch.go:44).
39func (s *Server) StartEconomicsPass(ctx context.Context) {
40 t := time.NewTicker(economicsPassInterval)
41 defer t.Stop()
42 for {
43 if err := s.economicsPass(ctx, time.Now()); err != nil {
44 log.Printf("console: economics pass: %v", err)
45 }
46 select {
47 case <-ctx.Done():
48 return
49 case <-t.C:
50 }
51 }
52}
53
54// economicsPass runs the daily economics writes for now's UTC day: census,
55// storage sweep, statements, bill pull. Each stage degrades independently —
56// a failed stage logs and the next still runs, because a missing census day
57// must not cost the month's statements (spec: error handling).
58func (s *Server) economicsPass(ctx context.Context, now time.Time) error {
59 date := usage.DayOf(now)
60 if err := s.censusPass(ctx, date); err != nil {
61 log.Printf("console: census pass %s: %v", date, err)
62 }
63 if err := s.storagePass(ctx, date); err != nil {
64 log.Printf("console: storage pass %s: %v", date, err)
65 }
66 if err := s.statementsPass(ctx, now); err != nil {
67 log.Printf("console: statements pass: %v", err)
68 }
69 if err := s.billPass(ctx, now); err != nil {
70 log.Printf("console: bill pass: %v", err)
71 }
72 return nil
73}
74
75// openPassRegistry opens the console's registry for a daily pass, refusing
76// early rather than letting registry.Open's sql.Open silently create and
77// migrate an EMPTY sqlite file at an unconfigured or unmounted path — a
78// pass that read that as "zero routes" would write a permanent, uncorrectable
79// all-zero day (create-only) and leave a stray sqlite file behind. Shared by
80// every pass that reads routes (censusPass, statementsPass); mirrors
81// instanceHealth's same-shaped guard for the empty-string case
82// (apiinstances.go:207).
83func (s *Server) openPassRegistry() (consoleRegistry, error) {
84 if s.cfg.Registry == "" {
85 return nil, fmt.Errorf("no registry configured")
86 }
87 if _, err := os.Stat(s.cfg.Registry); err != nil {
88 return nil, fmt.Errorf("registry %s: %w", s.cfg.Registry, err)
89 }
90 return openRegistry(s.cfg.Registry)
91}
92
93// censusPass snapshots per-account app/route counts, create-only. Counted by
94// account SQID (release.RouteAccount's output, e.g. "bab" — never the
95// console store's display Name, e.g. "ops"): release.RouteAccount normalizes
96// a route with no account (Route.Account == "") to release.OpsAccount, the
97// deployment operator's built-in account — so pre-accounts routes land in
98// one place instead of scattering under "".
99func (s *Server) censusPass(ctx context.Context, date string) error {
100 key := usage.CensusDayKey(date)
101 if _, _, err := s.cfg.Releases.Get(ctx, key); err == nil {
102 return nil // today already written (by us or a sibling console)
103 }
104 reg, err := s.openPassRegistry()
105 if err != nil {
106 return fmt.Errorf("console: census pass: %w", err)
107 }
108 routes, err := reg.List()
109 if err != nil {
110 return err
111 }
112 accounts := map[string]usage.CensusAccount{}
113 apps := map[string]map[string]bool{}
114 for _, rt := range routes {
115 acct := release.RouteAccount(rt)
116 c := accounts[acct]
117 c.Routes++
118 if apps[acct] == nil {
119 apps[acct] = map[string]bool{}
120 }
121 if rt.App != "" && !apps[acct][rt.App] {
122 apps[acct][rt.App] = true
123 c.Apps++
124 }
125 accounts[acct] = c
126 }
127 day := usage.CensusDay{V: 1, Date: date, Accounts: accounts}
128 _, err = usage.CreateDayObject(ctx, s.cfg.Releases, key, day)
129 return err
130}
131
132// storagePass sizes every app's deployment-bucket prefix, plus its tenant
133// object store when one is provisioned, create-only per day. Requires the
134// bucket to support sized listings (release.SizeLister); a store that
135// cannot answer marks the day's sweep absent rather than lying with zeros.
136func (s *Server) storagePass(ctx context.Context, date string) error {
137 key := usage.StorageDayKey(date)
138 if _, _, err := s.cfg.Releases.Get(ctx, key); err == nil {
139 return nil // today already written (by us or a sibling console)
140 }
141 sizer, ok := s.cfg.Releases.(release.SizeLister)
142 if !ok {
143 log.Printf("console: storage pass: bucket store does not support sized listings; skipping")
144 return nil
145 }
146 // ListDirs (both DirStore and S3Store) returns each child as a FULL,
147 // slash-terminated prefix — "apps/bab/", not "bab" — so the next level's
148 // call takes that string straight back as its own prefix, and the bare
149 // name for map keys/AppPrefix is recovered by trimming both ends.
150 acctPrefixes, err := s.cfg.Releases.ListDirs(ctx, "apps/")
151 if err != nil {
152 return err
153 }
154 day := usage.StorageDay{V: 1, Date: date, Apps: map[string]usage.AppStorage{}}
155 for _, acctPrefix := range acctPrefixes {
156 acct := strings.TrimSuffix(strings.TrimPrefix(acctPrefix, "apps/"), "/")
157 appPrefixes, err := s.cfg.Releases.ListDirs(ctx, acctPrefix)
158 if err != nil {
159 return err
160 }
161 for _, appPrefix := range appPrefixes {
162 app := strings.TrimSuffix(strings.TrimPrefix(appPrefix, acctPrefix), "/")
163 sizes, err := sizer.ListSizes(ctx, release.AppPrefix(acct, app))
164 if err != nil {
165 return err
166 }
167 var st usage.AppStorage
168 for _, ks := range sizes {
169 st.BucketBytes += ks.Size
170 }
171 // EVERY store this app has: its own, and one per config
172 // environment (2026-09-19). They sum into ONE figure under the
173 // app's usage key, so StorageDay's wire shape gains no dimension
174 // and an account's bill is the same arithmetic over more buckets.
175 // Per-store attribution is discussion #140's ask, and deliberately
176 // not made here.
177 envs, eerr := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct, app)
178 if eerr != nil {
179 // Same rule as an unreadable record below: "could not ask" is
180 // not "no store", and this object is create-only.
181 log.Printf("console: storage pass: store environments %s/%s: %v", acct, app, eerr)
182 st.StoreUnswept = true
183 }
184 for _, env := range append([]string{""}, envs...) {
185 rec, _, gerr := objstore.GetIn(ctx, s.cfg.Releases, acct, app, env)
186 if gerr != nil {
187 // Absent is (nil, "", nil) — objstore.GetIn's own idiom —
188 // so a non-nil error here is a REAL failure (transient
189 // bucket read, malformed store.json), not "no store".
190 // Treating it as "no store" would write
191 // StoreBytes:0/StoreUnswept:false into a create-only
192 // object, indistinguishable from an app that never had a
193 // store and never correctable afterward.
194 log.Printf("console: storage pass: store record %s/%s%s: %v", acct, app, storeEnvSuffix(env), gerr)
195 st.StoreUnswept = true
196 continue
197 }
198 if rec == nil || rec.Provisioned == nil {
199 continue
200 }
201 st.StoreProvisioned = true
202 n, serr := s.sizeStore(ctx, rec.Provisioned)
203 if serr != nil {
204 log.Printf("console: storage pass: store %s/%s%s: %v", acct, app, storeEnvSuffix(env), serr)
205 st.StoreUnswept = true
206 continue
207 }
208 st.StoreBytes += n
209 }
210 day.Apps[acct+"/"+app] = st
211 }
212 }
213 _, err = usage.CreateDayObject(ctx, s.cfg.Releases, key, day)
214 return err
215}
216
217// unitOwner attributes a sampled systemd unit (or exec-child cgroup name) to
218// its route's account/app. Matching mirrors OwnerAppMetrics (metricsview.go
219// ~698-730): rather than parse a host back out of the unit string, it builds
220// each route's OWN expected unit names — activator.UnitName/ExecName for a
221// unit-backed or exec-backed instance route, plus the route's stamped Unit
222// column (#110's bespoke unit) — and compares those forward against the
223// sampled name. (Read first per the brief: neither UnitName nor ExecName
224// systemd-escapes the host — activator.UnitName is a bare string
225// concatenation and cgroup.ExecName likewise — so there is no unescaping
226// helper to reuse; building the expected name forward, the same direction
227// OwnerAppMetrics does it, is what keeps a hyphenated or dotted host correct
228// with no separate escaping step.) A unit matching no route — the console's
229// own unit, system units — attributes to release.OpsAccount, app "": platform
230// overhead.
231func unitOwner(routes []registry.Route, unit string) (acct, app string, ok bool) {
232 rt, ok := routeForUnit(routes, unit)
233 if !ok {
234 return release.OpsAccount, "", false
235 }
236 return release.RouteAccount(rt), rt.App, true
237}
238
239// routeForUnit is the matching rule itself, split out of unitOwner so that a
240// second reader of the same sampled units — `carlos status --instances`,
241// which needs the route's HOST and not only its owner — cannot end up with a
242// private second notion of which route a cgroup belongs to. The comment above
243// unitOwner is the rationale for the rule; this is the rule.
244func routeForUnit(routes []registry.Route, unit string) (registry.Route, bool) {
245 for _, rt := range routes {
246 if rt.Unit != "" && rt.Unit == unit {
247 return rt, true
248 }
249 if rt.Host != "" && (unit == activator.UnitName(rt.Host) || unit == activator.ExecName(rt.Host)) {
250 return rt, true
251 }
252 }
253 return registry.Route{}, false
254}
255
256// usageSweep is sweepUsage's result: the box- and account-shaped meters
257// accumulated by reading metrics and edge days over [from, to], plus enough
258// coverage bookkeeping for callers to label a total honestly. It deliberately
259// does NOT include storage or census meters — see sweepUsage's doc comment —
260// every caller folds those in afterward via foldStorageAndCensus.
261type usageSweep struct {
262 PerAccount map[string]map[string]float64 // account -> meter -> value
263 PerBox map[string]economics.BoxLoad // box -> period load (Hours, per-account CPU/mem)
264 BoxDays map[string]int // box -> days with any metrics rollup
265 // CoveredDays counts UTC days in [from, to] with at least one source
266 // object — a metrics day rollup on any box, an edge day on any box, or
267 // the day's storage sweep (checked here for validity only, never
268 // accumulated — see below) — out of PeriodDays. A day with none of
269 // those contributes nothing, uncovered, never a zero standing in for
270 // missing data (package usage's "modelling, not invoice" posture).
271 CoveredDays, PeriodDays int
272 // EgressMetered marks every box that has EVER written a usage/edge day
273 // (discovered via ListDirs on usage.EdgeBoxesPrefix, independent of
274 // [from, to]) — a box that reports metrics but has no edge component at
275 // all is simply absent from this map, so `sweep.EgressMetered[box]`
276 // defaults to false for it, matching the honesty rule: a caller must
277 // show "unmetered", never a zero that reads as "confirmed no egress".
278 EgressMetered map[string]bool
279}
280
281// sweepUsage reads every UTC day in [from, to] (both truncated to midnight,
282// inclusive) from metrics and edge day objects and accumulates them into
283// account- and box-shaped meters — the per-day reading/accumulation shared
284// by statementsPass (whole month to date, create-or-replace statements) and
285// Task 11's LoadEconomics (arbitrary period, priced live under a scenario).
286//
287// routes is the registry snapshot unitOwner attributes sampled units
288// against — the CALLER resolves it, deliberately not sweepUsage itself,
289// because the two callers need opposite failure behaviour on a
290// missing/unmounted registry: statementsPass (a WRITE pass) must refuse
291// outright rather than write a permanent all-zero day off an empty
292// registry.Open (openPassRegistry's own doc comment), while LoadEconomics
293// (a READ path) degrades instead — see loadRoutesForEconomics
294// (economicsview.go). A nil/empty routes therefore isn't an error here:
295// every sampled unit simply falls through unitOwner's unmatched-unit case
296// to release.OpsAccount, same as any other unmatched unit.
297//
298// Storage and census are deliberately NOT accumulated here: storage's
299// account attribution needs a ValidAccount guard with pass-scoped dedup
300// logging that only makes sense once per whole call, and census never gates
301// coverage at all — both stay in foldStorageAndCensus, called separately by
302// every caller on top of this sweep's result. Storage's day objects ARE
303// still consulted here, for CoveredDays only: a day with a valid storage
304// sweep and nothing else is still a covered day, exactly as before this
305// function existed, even though the bytes themselves are read again by
306// foldStorageAndCensus.
307//
308// unitOwner attributes a sampled unit against routes, which — for both
309// current callers — is this CONSOLE's own box-local registry, while this
310// sweep iterates units published by every box under metrics/boxes/. On a
311// multi-box deployment a unit sampled on a box other than this console's own
312// can fail to match any route in this registry and silently fall to
313// release.OpsAccount, indistinguishable from the intended platform-overhead
314// fallback. Both production deployments are single-box today, so this is
315// currently inert; the structural fix is routes-as-bucket-records (PR #137,
316// unmerged), which would give every pass a registry view that spans boxes
317// instead of one box's local file.
318func (s *Server) sweepUsage(ctx context.Context, from, to time.Time, routes []registry.Route) (*usageSweep, error) {
319 metricsBoxes, err := s.cfg.Releases.ListDirs(ctx, metricsBoxesPrefix)
320 if err != nil {
321 return nil, fmt.Errorf("console: sweep usage: %w", err)
322 }
323 edgeBoxes, err := s.cfg.Releases.ListDirs(ctx, usage.EdgeBoxesPrefix)
324 if err != nil {
325 return nil, fmt.Errorf("console: sweep usage: %w", err)
326 }
327
328 sweep := &usageSweep{
329 PerAccount: map[string]map[string]float64{},
330 PerBox: map[string]economics.BoxLoad{},
331 BoxDays: map[string]int{},
332 EgressMetered: map[string]bool{},
333 }
334 // A box is metered for egress the moment it has EVER written a day —
335 // independent of the swept period, matching the struct's "wrote any
336 // usage/edge day" contract.
337 for _, boxPrefix := range edgeBoxes {
338 box := strings.TrimSuffix(strings.TrimPrefix(boxPrefix, usage.EdgeBoxesPrefix), "/")
339 sweep.EgressMetered[box] = true
340 }
341
342 touch := func(acct string) map[string]float64 {
343 mm := sweep.PerAccount[acct]
344 if mm == nil {
345 mm = map[string]float64{}
346 sweep.PerAccount[acct] = mm
347 }
348 return mm
349 }
350 boxLoad := func(box string) economics.BoxLoad {
351 bl, ok := sweep.PerBox[box]
352 if !ok {
353 bl = economics.BoxLoad{Box: box, ByAccount: map[string]economics.BoxAccountLoad{}}
354 }
355 return bl
356 }
357
358 fromDay := from.UTC().Truncate(24 * time.Hour)
359 toDay := to.UTC().Truncate(24 * time.Hour)
360 for d := fromDay; !d.After(toDay); d = d.AddDate(0, 0, 1) {
361 date := usage.DayOf(d)
362 covered := false
363
364 for _, boxPrefix := range metricsBoxes {
365 box := strings.TrimSuffix(strings.TrimPrefix(boxPrefix, metricsBoxesPrefix), "/")
366 raw, _, gerr := s.cfg.Releases.Get(ctx, metrics.DayKey(box, date))
367 if gerr != nil {
368 continue // missing = skip, uncovered by this box
369 }
370 var roll metrics.DayRollup
371 if jerr := json.Unmarshal(raw, &roll); jerr != nil {
372 log.Printf("console: sweep usage: parse %s: %v", metrics.DayKey(box, date), jerr)
373 continue
374 }
375 covered = true
376 sweep.BoxDays[box]++
377 bl := boxLoad(box)
378 bl.Hours += float64(len(roll.Hours))
379 for _, hour := range roll.Hours {
380 for unit, um := range hour.Units {
381 acct, _, _ := unitOwner(routes, unit)
382 mm := touch(acct)
383 var instN int
384 var cpuCoreHours, memGBHours float64
385 if cpu, ok := um["cpu"]; ok {
386 cpuCoreHours = cpu.Avg * float64(cpu.N) / 60
387 mm[usage.MeterCPUCoreHours] += cpuCoreHours
388 instN = cpu.N
389 }
390 if mem, ok := um["mem_bytes"]; ok {
391 memGBHours = mem.Avg * float64(mem.N) / 60 / 1e9
392 mm[usage.MeterMemGBHours] += memGBHours
393 if instN == 0 {
394 instN = mem.N
395 }
396 }
397 mm[usage.MeterInstanceHours] += float64(instN) / 60
398 al := bl.ByAccount[acct]
399 al.CPUCoreHours += cpuCoreHours
400 al.MemGBHours += memGBHours
401 bl.ByAccount[acct] = al
402 }
403 }
404 sweep.PerBox[box] = bl
405 }
406
407 for _, boxPrefix := range edgeBoxes {
408 box := strings.TrimSuffix(strings.TrimPrefix(boxPrefix, usage.EdgeBoxesPrefix), "/")
409 raw, _, gerr := s.cfg.Releases.Get(ctx, usage.EdgeDayKey(box, date))
410 if gerr != nil {
411 continue // missing = skip, uncovered by this box
412 }
413 var ed usage.EdgeDay
414 if jerr := json.Unmarshal(raw, &ed); jerr != nil {
415 log.Printf("console: sweep usage: parse %s: %v", usage.EdgeDayKey(box, date), jerr)
416 continue
417 }
418 covered = true
419 for _, rows := range ed.Hours {
420 for _, row := range rows {
421 acct := row.Account
422 if acct == "" {
423 acct = release.OpsAccount
424 }
425 mm := touch(acct)
426 mm[usage.MeterEgressGB] += float64(row.BytesOut) / 1e9
427 mm[usage.MeterRequests1k] += float64(row.Requests) / 1000
428 // The hop SUBSET (usage.EdgeRow): traffic served through a
429 // region edge and backhauled from the home box, so the
430 // platform paid for its egress twice. Never added to the
431 // totals above (it is inside them); priced nowhere — the
432 // #284 ruling is "absorb, keep the data" — and surfaced as
433 // its own meter so the economics pages and `carlos
434 // economics backhaul` can show what that costs us (#288).
435 mm[usage.MeterBackhaulGB] += float64(row.HopBytesOut) / 1e9
436 mm[usage.MeterBackhaulRequests1k] += float64(row.HopRequests) / 1000
437 }
438 }
439 }
440
441 // Storage is consulted here for coverage ONLY — a valid day object
442 // makes the day covered, exactly as before this sweep existed — but
443 // its bytes are not accumulated: that needs statementsPass's
444 // ValidAccount guard and pass-scoped dedup logging. Missing = skip,
445 // uncovered by storage (metrics/edge may still have covered it).
446 if raw, _, gerr := s.cfg.Releases.Get(ctx, usage.StorageDayKey(date)); gerr == nil {
447 var sd usage.StorageDay
448 if jerr := json.Unmarshal(raw, &sd); jerr == nil {
449 covered = true
450 }
451 }
452
453 if covered {
454 sweep.CoveredDays++
455 }
456 sweep.PeriodDays++
457 }
458
459 return sweep, nil
460}
461
462// foldStorageAndCensus reads each UTC day's storage and census objects in
463// [from, to] (both truncated to midnight, inclusive) and folds their meters
464// into dst (account -> meter -> value) — MeterStorageGBMonth and
465// MeterAppMonths, the two families sweepUsage deliberately leaves out (see
466// its doc comment). Shared by statementsPass and buildEconomicsView
467// (economicsview.go) so the two never disagree about a period's storage/
468// app-existence numbers — the bug this fixes: the view path used to call
469// sweepUsage alone and never see these meters at all.
470//
471// Denominator ruling (review, Task 11 fix round): each day's contribution
472// divides by the number of days in THAT DAY's own calendar month — not one
473// daysInMonth computed once for the whole range. For statementsPass, whose
474// range is always "whole month to date", every day shares one month, so
475// this is identical byte-for-byte to the pre-refactor single daysInMonth
476// constant (proven by its unmodified tests). For LoadEconomics's "7d"
477// period, which can straddle a month boundary, a day still accrues
478// (bytes/1e9)/daysInMonth(that day) and apps/daysInMonth(that day) — meters
479// stay "accrued GB-months/app-months in the period", pricing correctly
480// under monthly rates regardless of which days the period spans.
481//
482// badStorageKeys dedups a malformed storage-day key's log line at most once
483// per key for this ONE CALL — matching the pre-split pass-scoped behaviour,
484// since each of statementsPass and buildEconomicsView calls this exactly
485// once per invocation.
486func (s *Server) foldStorageAndCensus(ctx context.Context, from, to time.Time, dst map[string]map[string]float64) error {
487 touch := func(acct string) map[string]float64 {
488 mm := dst[acct]
489 if mm == nil {
490 mm = map[string]float64{}
491 dst[acct] = mm
492 }
493 return mm
494 }
495 badStorageKeys := map[string]bool{}
496
497 fromDay := from.UTC().Truncate(24 * time.Hour)
498 toDay := to.UTC().Truncate(24 * time.Hour)
499 for d := fromDay; !d.After(toDay); d = d.AddDate(0, 0, 1) {
500 date := usage.DayOf(d)
501 y, mo, _ := d.Date()
502 daysInMonth := time.Date(y, mo+1, 0, 0, 0, 0, 0, time.UTC).Day()
503
504 if raw, _, gerr := s.cfg.Releases.Get(ctx, usage.StorageDayKey(date)); gerr == nil {
505 var sd usage.StorageDay
506 if jerr := json.Unmarshal(raw, &sd); jerr != nil {
507 log.Printf("console: fold storage/census: parse %s: %v", usage.StorageDayKey(date), jerr)
508 } else {
509 for key, ap := range sd.Apps {
510 acct := key
511 if i := strings.IndexByte(key, '/'); i >= 0 {
512 acct = key[:i]
513 }
514 // A legacy unqualified app prefix (apps/<app>/releases/,
515 // no account segment) makes storagePass mint a StorageDay
516 // key whose segment before "/" is an APP name, not an
517 // account sqid. Left unguarded, that mints a bogus
518 // statement (e.g. usage/accounts/kass/2026-08.json)
519 // polluting the sqid-keyed space Task 11 joins on — so an
520 // account segment that doesn't pass release.ValidAccount
521 // attributes to release.OpsAccount instead, same as an
522 // unmatched unit.
523 if !release.ValidAccount(acct) {
524 if !badStorageKeys[key] {
525 badStorageKeys[key] = true
526 log.Printf("console: fold storage/census: storage day %s: key %q has an invalid account segment %q; attributing to %s", date, key, acct, release.OpsAccount)
527 }
528 acct = release.OpsAccount
529 }
530 bytesTotal := ap.BucketBytes + ap.StoreBytes
531 touch(acct)[usage.MeterStorageGBMonth] += (float64(bytesTotal) / 1e9) / float64(daysInMonth)
532 }
533 }
534 } // missing = skip; sweepUsage already counted the day's coverage
535
536 // Census does not gate coverage (sweepUsage's covered-day sources are
537 // metrics/edge/storage only) but still feeds app_months when present.
538 if raw, _, gerr := s.cfg.Releases.Get(ctx, usage.CensusDayKey(date)); gerr == nil {
539 var cd usage.CensusDay
540 if jerr := json.Unmarshal(raw, &cd); jerr != nil {
541 log.Printf("console: fold storage/census: parse %s: %v", usage.CensusDayKey(date), jerr)
542 } else {
543 for acct, ca := range cd.Accounts {
544 touch(acct)[usage.MeterAppMonths] += float64(ca.Apps) / float64(daysInMonth)
545 }
546 }
547 }
548 }
549 return nil
550}
551
552// rollupPresenceHours converts one box's day rollup into billable box-hours
553// under the fleet-hours presence rule (task-3 brief): fleet-box rollups may
554// carry only the host-level aggregate (no per-unit CPU sampling — the box
555// runs no CARLOS-managed unit at all, just the remote-host-agent itself), so
556// presence is derived from the Host aggs rather than Units. For each PRESENT
557// hour (a key in d.Hours, regardless of what it carries):
558// - Host["cpu"].N minutes/60 when the host cpu agg is present;
559// - else the max N across whatever OTHER Host aggs the hour does carry;
560// - else — Host itself has no aggs at all for the hour — a flat 60-minute
561// (1.0h) fallback, since a present-but-metric-less hour key still means
562// the box was up and dialed in for that hour.
563//
564// Each hour's contribution is clamped to at most 1.0 (review, fix round 1):
565// a mid-hour agent restart can land more than 60 samples in one hour bucket
566// — mergeExistingChunk dedups exact-timestamp collisions only, not a
567// restart's fresh cadence landing alongside the old one — and an N above 60
568// must read as "fully present that hour", never as more than one hour billed
569// for one hour of wall time.
570func rollupPresenceHours(d metrics.DayRollup) float64 {
571 var total float64
572 for _, hour := range d.Hours {
573 var minutes float64
574 if cpu, ok := hour.Host["cpu"]; ok {
575 minutes = float64(cpu.N)
576 } else if maxN := maxHostAggN(hour.Host); maxN > 0 {
577 minutes = float64(maxN)
578 } else {
579 minutes = 60
580 }
581 total += math.Min(1.0, minutes/60)
582 }
583 return total
584}
585
586// maxHostAggN returns the largest N across host's Agg entries, 0 for an
587// empty (or nil) map.
588func maxHostAggN(host map[string]metrics.Agg) int {
589 maxN := 0
590 for _, agg := range host {
591 if agg.N > maxN {
592 maxN = agg.N
593 }
594 }
595 return maxN
596}
597
598// hasDeviceTag reports whether tags (FleetBox.Tags' comma-separated list)
599// carries the literal segment "device" — the device-vs-server meter split.
600func hasDeviceTag(tags string) bool {
601 for _, tag := range strings.Split(tags, ",") {
602 if tag == "device" {
603 return true
604 }
605 }
606 return false
607}
608
609// fleetMeters derives per-account fleet box-hours for [from, to]: for every
610// box registered to every fleet (ALL statuses — a detached box's past hours
611// still bill, same posture as a deleted-account's already-written days),
612// account -> {fleet_box_hours_server, fleet_box_hours_device}, split by
613// whether the box's Tags carry the literal "device" tag.
614//
615// Deliberately a SECOND bounded read over metrics day objects, not an
616// extension of sweepUsage's single pass: sweepUsage iterates every box under
617// metrics/boxes/ once and attributes by UNIT (unitOwner), which has no
618// concept of a fleet box at all — a fleet box's day rollup carries no units
619// most of the time (see rollupPresenceHours's doc comment), only a host
620// aggregate, so folding this in there would mean threading a
621// (fleet,label)->owner map through sweepUsage's hot loop and its
622// box-vs-fleet-box branching for a meter family only two of usageSweep's
623// several callers need at all. This keeps sweepUsage and its existing tests
624// completely untouched: the read here is bounded to exactly the boxes that
625// ARE fleet boxes (one Get per (fleet,label) per day in range; a day with no
626// rollup is skipped, same missing-means-skip convention sweepUsage itself
627// uses).
628//
629// Fleet box rollups do NOT live at the platform root's metrics.DayKey(label,
630// date) — a door-backed fleet box's write ACL admits only
631// fleets/<own fleet>/** (internal/edge/storedoor.go's aclRead/aclWrite), and
632// resolveStatusPrefix (internal/host/agent.go) defaults every door-backed
633// box's publish prefix to "fleets/<fleet>/" for exactly that reason. This
634// reads each fleet's rollups through the same
635// release.NewPrefixStore(s.cfg.Releases, "fleets/"+fleet+"/") rebase the
636// console's own fleet page already uses for heartbeats
637// (loadFleetPage/fleets.go), with the plain metrics.DayKey(label, date)
638// inside it — so the map below is keyed by (fleet, label), not label alone.
639//
640// (fleet, label) keying also means a same-labeled box in two DIFFERENT
641// fleets is not a collision at all — each lives under its own fleet prefix
642// at a distinct object key, so both are read and billed independently.
643// (Review, fix round 1: an earlier version of this function keyed by label
644// alone and resolved a same-label clash to the alphabetically-first fleet —
645// which, under prefixed reads, would have been silent DATA LOSS for the
646// second fleet's box-hours, not a display-only tie-break. Removed entirely,
647// not just fixed, since the underlying ambiguity no longer exists.)
648//
649// Known gaps, recorded rather than fixed at modelling grade (review, fix
650// round 1):
651// - A fleet box provisioned before this prefixing existed — a grandfathered
652// bucket-backed box publishing straight to the platform root, or one an
653// operator hand-configured with CARLOS_STATUS_PREFIX="" — is invisible to
654// this meter: its rollups never appear under fleets/<fleet>/, so it
655// contributes zero fleet box-hours, and its sampled units (if any) fall
656// through unitOwner's unmatched case to release.OpsAccount like any other
657// unrecognized unit, same as a platform box's stray unit would. This
658// closes itself once the box (re)publishes under its fleet prefix — no
659// separate migration is needed.
660// - fleetMeters failing makes statementsPass itself fail (see
661// statementsPass's doc comment for why that's deliberate here).
662// - Retagging a box "device" mid-month re-prices the box's ENTIRE current
663// month the next time this pass runs, not just days after the retag —
664// statements are a create-OR-REPLACE snapshot of the whole month to
665// date, not an append-only ledger, so there is no "as of" boundary to
666// honor. Modelling-grade, matching every other meter this pass derives.
667func (s *Server) fleetMeters(ctx context.Context, from, to time.Time) (map[string]map[string]float64, error) {
668 reg, err := s.openPassRegistry()
669 if err != nil {
670 return nil, fmt.Errorf("console: fleet meters: %w", err)
671 }
672 fleets, err := reg.ListFleets()
673 if err != nil {
674 return nil, fmt.Errorf("console: fleet meters: %w", err)
675 }
676
677 type boxOwner struct {
678 acct string
679 device bool
680 }
681 type boxKey struct{ fleet, label string }
682 boxes := map[boxKey]boxOwner{}
683
684 for _, f := range fleets {
685 fboxes, err := reg.ListFleetBoxes(f.Name)
686 if err != nil {
687 return nil, fmt.Errorf("console: fleet meters: list boxes %s: %w", f.Name, err)
688 }
689 for _, b := range fboxes {
690 boxes[boxKey{fleet: f.Name, label: b.Label}] = boxOwner{acct: f.Account, device: hasDeviceTag(b.Tags)}
691 }
692 }
693
694 out := map[string]map[string]float64{}
695 touch := func(acct string) map[string]float64 {
696 mm := out[acct]
697 if mm == nil {
698 mm = map[string]float64{}
699 out[acct] = mm
700 }
701 return mm
702 }
703
704 fromDay := from.UTC().Truncate(24 * time.Hour)
705 toDay := to.UTC().Truncate(24 * time.Hour)
706 for bk, owner := range boxes {
707 meter := usage.MeterFleetBoxHoursServer
708 if owner.device {
709 meter = usage.MeterFleetBoxHoursDevice
710 }
711 fleetStore := release.NewPrefixStore(s.cfg.Releases, "fleets/"+bk.fleet+"/")
712 for d := fromDay; !d.After(toDay); d = d.AddDate(0, 0, 1) {
713 date := usage.DayOf(d)
714 key := metrics.DayKey(bk.label, date)
715 raw, _, gerr := fleetStore.Get(ctx, key)
716 if gerr != nil {
717 continue // missing = skip, same convention as sweepUsage
718 }
719 var roll metrics.DayRollup
720 if jerr := json.Unmarshal(raw, &roll); jerr != nil {
721 log.Printf("console: fleet meters: parse fleets/%s/%s: %v", bk.fleet, key, jerr)
722 continue
723 }
724 touch(owner.acct)[meter] += rollupPresenceHours(roll)
725 }
726 }
727 return out, nil
728}
729
730// statementsPass folds the month's metrics, edge and storage days (plus
731// census, for app_months, and fleet box-hours, for fleet_box_hours_server/
732// device) into one usage.Statement per account, create-or-replace via
733// usage.UpsertStatement — safe to run repeatedly as new days' objects
734// appear. It is a thin caller: open the registry (refusing early — see
735// openPassRegistry — since this is the WRITE pass), sweep the whole month to
736// date (sweepUsage), fold in storage/census meters (foldStorageAndCensus),
737// fold in fleet meters (fleetMeters — a union merge, since an account with
738// ONLY fleet usage and no platform routes at all must still get every meter
739// key it's owed), then upsert.
740//
741// Known shortfall, recorded rather than fixed at modelling grade: an account
742// deleted mid-month drops out of s.store.Accounts() (its query filters
743// deleted_at), so this pass stops upserting its statement from the day of
744// deletion onward — the account's last statement object simply stops moving
745// forward, rather than the month closing it out explicitly.
746//
747// fleetMeters failing (a bad registry read, a bucket hiccup under a fleet
748// prefix) makes this WHOLE pass return an error rather than upsert
749// statements missing that account's fleet hours — deliberate (review, fix
750// round 1): a billing meter silently going to zero for a tick is worse than
751// this hourly pass simply not moving the statement forward that tick and
752// trying again next hour with the same last-good numbers still standing.
753// This is the WRITE side's posture only — Task 5's read side
754// (buildEconomicsView) must NOT inherit it: a live view degrading to "could
755// not read fleet hours" for one request is fine, refusing the whole
756// economics page over it is not, the same asymmetry sweepUsage's own doc
757// comment draws between its two callers.
758func (s *Server) statementsPass(ctx context.Context, now time.Time) error {
759 reg, err := s.openPassRegistry()
760 if err != nil {
761 return fmt.Errorf("console: statements pass: %w", err)
762 }
763 routes, err := reg.List()
764 if err != nil {
765 return fmt.Errorf("console: statements pass: %w", err)
766 }
767
768 nowUTC := now.UTC()
769 y, mo, _ := nowUTC.Date()
770 daysInMonth := time.Date(y, mo+1, 0, 0, 0, 0, 0, time.UTC).Day()
771 month := usage.MonthOf(nowUTC)
772 monthStart := time.Date(y, mo, 1, 0, 0, 0, 0, time.UTC)
773
774 sweep, err := s.sweepUsage(ctx, monthStart, nowUTC, routes)
775 if err != nil {
776 return fmt.Errorf("console: statements pass: %w", err)
777 }
778 meters := sweep.PerAccount // account -> meter -> value; folded into below
779 if err := s.foldStorageAndCensus(ctx, monthStart, nowUTC, meters); err != nil {
780 return fmt.Errorf("console: statements pass: %w", err)
781 }
782 fleetMM, err := s.fleetMeters(ctx, monthStart, nowUTC)
783 if err != nil {
784 return fmt.Errorf("console: statements pass: %w", err)
785 }
786 for acct, mm := range fleetMM {
787 dst := meters[acct]
788 if dst == nil {
789 dst = map[string]float64{}
790 meters[acct] = dst
791 }
792 for meter, v := range mm {
793 dst[meter] += v
794 }
795 }
796 coveredDays := sweep.CoveredDays
797
798 // Every account gets a statement this pass, even one with zero usage —
799 // the accounts page must list it (brief step 3). The union is the
800 // accumulated meter keys (already RouteAccount()-shaped, e.g.
801 // release.OpsAccount == "bab" — see censusPass/storagePass, same key
802 // space) plus every live console account's Sqid. Deliberately NOT the
803 // console account's display Name: Name ("ops") and RouteAccount's sqid
804 // output ("bab") are different strings for the SAME built-in account
805 // (Task 8's census golden pins "bab" as the key), so a name-keyed union
806 // would never actually join a zero-usage account into the sqid-keyed
807 // universe census/edge/storage already write under — see task-10-report
808 // for the full reasoning.
809 accounts, err := s.store.Accounts()
810 if err != nil {
811 return fmt.Errorf("console: statements pass: %w", err)
812 }
813 universe := map[string]bool{}
814 for acct := range meters {
815 universe[acct] = true
816 }
817 for _, a := range accounts {
818 universe[a.Sqid] = true
819 }
820
821 for acct := range universe {
822 mm := meters[acct]
823 if mm == nil {
824 mm = map[string]float64{}
825 }
826 st := usage.Statement{
827 V: 1,
828 Account: acct,
829 Month: month,
830 Meters: mm,
831 CoveredDays: coveredDays,
832 DaysInMonth: daysInMonth,
833 UpdatedAt: nowUTC,
834 }
835 if err := usage.UpsertStatement(ctx, s.cfg.Releases, st); err != nil {
836 return fmt.Errorf("console: statements pass: upsert %s: %w", acct, err)
837 }
838 }
839 return nil
840}
841
842// billCEDayGrace is how many days into the FOLLOWING month a closed month's
843// bill must have been pulled before billPass stops re-pulling it forever —
844// CE's late-arriving line items settle by then, so a pull on or after this
845// day is treated as final. Below this day, the closed month is pulled at
846// most once per UTC day, same as the current (open) month.
847const billCEDayGrace = 3
848
849// billPass pulls this month and last month's whole-account bill through the
850// ceCosts seam (cepull.go) and writes each, manual-wins. It ALWAYS returns
851// nil: a Cost Explorer failure (no billing IAM grant, a disabled seam, AWS
852// down) is degraded state, not a pass failure — the spec's posture is "a
853// deployment that never pulled shows manual entry, not an error"
854// (economicsview.go's loadRecentBills carries the same posture for a bill
855// object that simply isn't there yet). Each month's pull/write is
856// independent — a failure on one must not skip the other.
857//
858// The open (current) month's TimePeriod.End is tomorrow's UTC date, not
859// now — CE's End is EXCLUSIVE and must be strictly after Start
860// (firstOfThisMonth): on day 1 of any month, now truncated to its own day
861// equals firstOfThisMonth, which would send Start==End and CE would refuse
862// the whole request with a ValidationException. Bumping End to the day
863// AFTER now's UTC day (CE accepts a request that reaches into "today",
864// not-yet-complete) sidesteps day 1 without a special case. The extra
865// Format-equality check below is belt and braces against any other path
866// that could still produce a same-day Start/End.
867func (s *Server) billPass(ctx context.Context, now time.Time) error {
868 nowUTC := now.UTC()
869 today := time.Date(nowUTC.Year(), nowUTC.Month(), nowUTC.Day(), 0, 0, 0, 0, time.UTC)
870 tomorrow := today.AddDate(0, 0, 1)
871 firstOfThisMonth := time.Date(nowUTC.Year(), nowUTC.Month(), 1, 0, 0, 0, 0, time.UTC)
872 firstOfLastMonth := firstOfThisMonth.AddDate(0, -1, 0)
873
874 periods := []struct {
875 month string
876 start, end time.Time
877 closedMonth bool // the just-finished month: eligible for the day-3 settle-and-stop rule
878 }{
879 {usage.MonthOf(nowUTC), firstOfThisMonth, tomorrow, false},
880 {usage.MonthOf(firstOfLastMonth), firstOfLastMonth, firstOfThisMonth, true},
881 }
882 for _, p := range periods {
883 if p.start.Format("2006-01-02") == p.end.Format("2006-01-02") {
884 log.Printf("console: bill pass %s: start/end both %s; CE requires start<end, skipping this tick", p.month, p.start.Format("2006-01-02"))
885 continue
886 }
887 if err := s.pullBillMonth(ctx, p.month, p.start, p.end, nowUTC, p.closedMonth); err != nil {
888 log.Printf("console: bill pass %s: %v", p.month, err)
889 }
890 }
891 return nil
892}
893
894// pullBillMonth pulls one month's whole-account by-service costs through
895// the ceCosts seam and writes it, manual-wins (upsertBill's manualWins=true)
896// — but first checks whether a pull is even worth making: CE bills
897// $0.01/request, and the hourly pass tick would otherwise burn roughly
898// $14/mo (2 months × 24 ticks/day × 30 days) pulling data that, most hours,
899// hasn't changed since the last pull. shouldSkipCEPull enforces "at most
900// once per UTC day" for both months, plus the day-3-of-next-month
901// settle-and-stop rule for closedMonth.
902func (s *Server) pullBillMonth(ctx context.Context, month string, start, end, now time.Time, closedMonth bool) error {
903 existing, err := s.getBill(ctx, month)
904 if err != nil {
905 return fmt.Errorf("read existing bill: %w", err)
906 }
907 if shouldSkipCEPull(existing, closedMonth, now) {
908 return nil
909 }
910
911 costs, err := s.resolveCECosts(ctx, start, end)
912 if err != nil {
913 return fmt.Errorf("cost explorer: %w", err)
914 }
915 var total float64
916 for _, v := range costs {
917 total += v
918 }
919 bill := economics.Bill{
920 Schema: 1,
921 Month: month,
922 TotalUSD: total,
923 Services: costs,
924 Source: economics.SourceCostExplorer,
925 PulledAt: now,
926 }
927 return s.upsertBill(ctx, bill, true)
928}
929
930// shouldSkipCEPull decides whether pullBillMonth should skip calling Cost
931// Explorer THIS tick, given the bill currently stored for the month (nil if
932// none) — see pullBillMonth's doc comment for why this exists at all. Only
933// ever consults a SourceCostExplorer bill: a SourceManual bill is a
934// different caller's business (upsertBill's manualWins check handles it at
935// write time), not a reason to skip a pull here.
936func shouldSkipCEPull(existing *economics.Bill, closedMonth bool, now time.Time) bool {
937 if existing == nil || existing.Source != economics.SourceCostExplorer {
938 return false
939 }
940 if closedMonth && usage.MonthOf(existing.PulledAt) > existing.Month && existing.PulledAt.Day() >= billCEDayGrace {
941 // Settled: a closed month pulled on or after day 3 of a LATER
942 // month has already seen CE's late-arriving line items land. Its
943 // numbers don't move again — re-pulling it forever would be pure
944 // waste, unlike the open month, which keeps changing all month.
945 //
946 // The month check matters on its own: a pull made from WITHIN the
947 // closing month (e.g. the month's own last daily pull, July 31)
948 // can land on day >= billCEDayGrace purely by calendar coincidence
949 // (Day()==31) without the month having closed at all yet — that
950 // is not settlement, and must not be treated as such.
951 return true
952 }
953 return usage.DayOf(existing.PulledAt) == usage.DayOf(now)
954}
955
956// getBill reads and parses the stored bill for month, or (nil, nil) if none
957// exists yet — the "no bill pulled yet" state loadRecentBills
958// (economicsview.go) also treats as normal, not an error.
959func (s *Server) getBill(ctx context.Context, month string) (*economics.Bill, error) {
960 raw, _, err := s.cfg.Releases.Get(ctx, economics.BillKey(month))
961 if err != nil {
962 if errors.Is(err, os.ErrNotExist) {
963 return nil, nil
964 }
965 return nil, err
966 }
967 var b economics.Bill
968 if jerr := json.Unmarshal(raw, &b); jerr != nil {
969 return nil, jerr
970 }
971 return &b, nil
972}
973
974// upsertBill is one CAS read-etag-then-put attempt at economics.BillKey(bill
975// .Month) — appconfig's applyAt pattern (internal/appconfig/store.go:162-190),
976// one attempt, no retry loop: a lost race surfaces as release.ErrCASConflict
977// to the caller, who decides what "try again" means for their context (a
978// background pass logs and waits for its next tick; a write door turns it
979// into a flash message). When manualWins is true (billPass's own calls,
980// via pullBillMonth) an existing SourceManual bill is left untouched —
981// manual entry always beats an automatic pull. The manual write doors
982// (handleEconomicsBillSave, handleAPIEconomicsBill) call this with
983// manualWins=false: manual entry always overwrites whatever is there,
984// a previous CE pull or an earlier manual entry alike.
985func (s *Server) upsertBill(ctx context.Context, bill economics.Bill, manualWins bool) error {
986 key := economics.BillKey(bill.Month)
987 raw, etag, err := s.cfg.Releases.Get(ctx, key)
988 switch {
989 case err == nil:
990 if manualWins {
991 var existing economics.Bill
992 if jerr := json.Unmarshal(raw, &existing); jerr == nil && existing.Source == economics.SourceManual {
993 return nil
994 }
995 }
996 case errors.Is(err, os.ErrNotExist):
997 etag = ""
998 default:
999 return fmt.Errorf("console: bill %s: read: %w", key, err)
1000 }
1001 body, err := json.Marshal(bill)
1002 if err != nil {
1003 return fmt.Errorf("console: bill %s: marshal: %w", key, err)
1004 }
1005 if _, err := s.cfg.Releases.Put(ctx, key, body, etag); err != nil {
1006 return fmt.Errorf("console: bill %s: write: %w", key, err)
1007 }
1008 return nil
1009}
1010
1011// sizeStore routes through the test seam or the real S3 sizer.
1012func (s *Server) sizeStore(ctx context.Context, p *objstore.Provisioned) (int64, error) {
1013 if s.storeSizer != nil {
1014 return s.storeSizer(ctx, p)
1015 }
1016 return sizeProvisionedStore(ctx, p)
1017}
1018
1019// sizeProvisionedStore sums a tenant object store's live bytes with a
1020// paginated ListObjectsV2, using an S3 client built exactly the way
1021// objstore.NewAWSProvisioner builds its own (objstore.S3ClientFor: region +
1022// optional endpoint override for S3-compatible backends).
1023func sizeProvisionedStore(ctx context.Context, p *objstore.Provisioned) (int64, error) {
1024 client, err := objstore.S3ClientFor(ctx, p.Region, p.Endpoint)
1025 if err != nil {
1026 return 0, err
1027 }
1028 var total int64
1029 var token *string
1030 for {
1031 out, err := client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{
1032 Bucket: aws.String(p.Bucket),
1033 ContinuationToken: token,
1034 })
1035 if err != nil {
1036 return 0, err
1037 }
1038 for _, obj := range out.Contents {
1039 total += aws.ToInt64(obj.Size)
1040 }
1041 if !aws.ToBool(out.IsTruncated) {
1042 break
1043 }
1044 if out.NextContinuationToken == nil {
1045 // A misbehaving S3-compatible endpoint that claims IsTruncated
1046 // but omits the continuation token would otherwise refetch page
1047 // one forever: this pass runs synchronously inside
1048 // StartEconomicsPass, so that hang takes census/statements/
1049 // bill-pull down with it, permanently.
1050 break
1051 }
1052 token = out.NextContinuationToken
1053 }
1054 return total, nil
1055}
1056
1057// ---- unit economics: scenario/bill write doors -----------------------------
1058
1059// putScenario CAS-writes economics.ScenarioKey(sc.Name) — appconfig's
1060// applyAt read-etag-then-put shape (internal/appconfig/store.go:162-190),
1061// one attempt: a lost race surfaces release.ErrCASConflict to the caller
1062// (handleEconomicsScenarioSave), which turns it into a retryable flash
1063// error rather than silently overwriting or retrying blind.
1064func (s *Server) putScenario(ctx context.Context, sc economics.Scenario) error {
1065 key := economics.ScenarioKey(sc.Name)
1066 _, etag, err := s.cfg.Releases.Get(ctx, key)
1067 switch {
1068 case err == nil:
1069 case errors.Is(err, os.ErrNotExist):
1070 etag = ""
1071 default:
1072 return fmt.Errorf("console: scenario %s: read: %w", key, err)
1073 }
1074 body, err := json.Marshal(sc)
1075 if err != nil {
1076 return fmt.Errorf("console: scenario %s: marshal: %w", key, err)
1077 }
1078 if _, err := s.cfg.Releases.Put(ctx, key, body, etag); err != nil {
1079 return fmt.Errorf("console: scenario %s: write: %w", key, err)
1080 }
1081 return nil
1082}
1083
1084// putActiveScenario CAS-writes economics.ActiveKey to name it — same
1085// one-attempt shape as putScenario, same ErrCASConflict contract.
1086func (s *Server) putActiveScenario(ctx context.Context, name string) error {
1087 _, etag, err := s.cfg.Releases.Get(ctx, economics.ActiveKey)
1088 switch {
1089 case err == nil:
1090 case errors.Is(err, os.ErrNotExist):
1091 etag = ""
1092 default:
1093 return fmt.Errorf("console: active scenario: read: %w", err)
1094 }
1095 body, err := json.Marshal(economics.Active{Schema: 1, Name: name})
1096 if err != nil {
1097 return fmt.Errorf("console: active scenario: marshal: %w", err)
1098 }
1099 if _, err := s.cfg.Releases.Put(ctx, economics.ActiveKey, body, etag); err != nil {
1100 return fmt.Errorf("console: active scenario: write: %w", err)
1101 }
1102 return nil
1103}
1104
1105// handleEconomicsScenarioSave writes a named pricing scenario (and,
1106// optionally, activates it). Registered without a method prefix
1107// (server.go), so the FIRST check is r.Method — a GET (or anything but
1108// POST) is byte-identical to an unknown route, never a 405 + Allow, which
1109// would leak that this path exists to someone who merely guessed it. Method
1110// check comes before EVERYTHING else, including the session gate: a
1111// wrong-method request must be indistinguishable from an unknown route
1112// regardless of who's asking. Past that, gate is handleFleet's exact shape
1113// — session -> IsOperator -> a bare http.NotFound byte-identical with an
1114// unknown route — BEFORE the CSRF check, per the console security rule:
1115// role existence must never be probeable, and a CSRF rejection would leak
1116// that this route exists to a non-operator who merely guessed the URL.
1117func (s *Server) handleEconomicsScenarioSave(w http.ResponseWriter, r *http.Request) {
1118 if r.Method != http.MethodPost {
1119 http.NotFound(w, r)
1120 return
1121 }
1122 addr, raw, ok := s.session(r)
1123 if !ok {
1124 http.NotFound(w, r)
1125 return
1126 }
1127 isOp, err := s.store.IsOperator(addr)
1128 if err != nil {
1129 log.Printf("console: economics scenario save: is-operator: %v", err)
1130 http.NotFound(w, r)
1131 return
1132 }
1133 if !isOp {
1134 http.NotFound(w, r)
1135 return
1136 }
1137 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
1138 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
1139 return
1140 }
1141
1142 name := strings.TrimSpace(r.FormValue("name"))
1143 var sc economics.Scenario
1144 if err := json.Unmarshal([]byte(r.FormValue("scenario")), &sc); err != nil {
1145 s.renderMessage(w, r, http.StatusBadRequest, "Invalid scenario", "Could not parse the scenario JSON: "+err.Error())
1146 return
1147 }
1148 // The form's name field wins over whatever the JSON body carries — the
1149 // name is the bucket key (ScenarioKey), so it must come from the one
1150 // place a CSRF-protected form field lives, not from a value a client
1151 // could otherwise mismatch against the URL a link points at.
1152 sc.Name = name
1153 if err := sc.Validate(); err != nil {
1154 s.renderMessage(w, r, http.StatusBadRequest, "Invalid scenario", err.Error())
1155 return
1156 }
1157
1158 back := func(kind, msg string) {
1159 s.setFlash(w, kind, msg)
1160 http.Redirect(w, r, "/fleet/economics", http.StatusSeeOther)
1161 }
1162
1163 if err := s.putScenario(r.Context(), sc); err != nil {
1164 if errors.Is(err, release.ErrCASConflict) {
1165 back("err", "Someone else saved a scenario — try again.")
1166 return
1167 }
1168 log.Printf("console: economics scenario save: %v", err)
1169 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not save the scenario. Please try again.")
1170 return
1171 }
1172
1173 if r.FormValue("activate") == "1" {
1174 if err := s.putActiveScenario(r.Context(), name); err != nil {
1175 if errors.Is(err, release.ErrCASConflict) {
1176 back("err", "Scenario saved, but someone else changed the active scenario — try again.")
1177 return
1178 }
1179 log.Printf("console: economics scenario save: activate %s: %v", name, err)
1180 back("err", "Scenario saved, but could not activate it. Please try again.")
1181 return
1182 }
1183 }
1184
1185 back("ok", "Saved scenario "+name+".")
1186}
1187
1188// parseBillLines parses the manual-bill form's "lines" textarea: one
1189// "Service=123.45" per line, blank lines skipped. A malformed line is a
1190// hard error naming the offending line — silently dropping it would
1191// understate the bill with no visible sign, the same reasoning
1192// applyAt's ValidVarValue check documents for config values.
1193func parseBillLines(text string) (map[string]float64, error) {
1194 services := map[string]float64{}
1195 for _, line := range strings.Split(text, "\n") {
1196 line = strings.TrimSpace(line)
1197 if line == "" {
1198 continue
1199 }
1200 name, amtStr, ok := strings.Cut(line, "=")
1201 if !ok {
1202 return nil, fmt.Errorf("line %q: want Service=123.45", line)
1203 }
1204 name = strings.TrimSpace(name)
1205 if name == "" {
1206 return nil, fmt.Errorf("line %q: empty service name", line)
1207 }
1208 amt, err := strconv.ParseFloat(strings.TrimSpace(amtStr), 64)
1209 if err != nil {
1210 return nil, fmt.Errorf("line %q: %w", line, err)
1211 }
1212 services[name] = amt
1213 }
1214 return services, nil
1215}
1216
1217// handleEconomicsBillSave writes a manually-entered bill for one month —
1218// SourceManual, always overwriting whatever is there (upsertBill's
1219// manualWins=false). Same gate shape as handleEconomicsScenarioSave,
1220// including the method check before everything else.
1221func (s *Server) handleEconomicsBillSave(w http.ResponseWriter, r *http.Request) {
1222 if r.Method != http.MethodPost {
1223 http.NotFound(w, r)
1224 return
1225 }
1226 addr, raw, ok := s.session(r)
1227 if !ok {
1228 http.NotFound(w, r)
1229 return
1230 }
1231 isOp, err := s.store.IsOperator(addr)
1232 if err != nil {
1233 log.Printf("console: economics bill save: is-operator: %v", err)
1234 http.NotFound(w, r)
1235 return
1236 }
1237 if !isOp {
1238 http.NotFound(w, r)
1239 return
1240 }
1241 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
1242 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
1243 return
1244 }
1245
1246 back := func(kind, msg string) {
1247 s.setFlash(w, kind, msg)
1248 http.Redirect(w, r, "/fleet/economics", http.StatusSeeOther)
1249 }
1250
1251 month := strings.TrimSpace(r.FormValue("month"))
1252 if _, err := time.Parse("2006-01", month); err != nil {
1253 s.renderMessage(w, r, http.StatusBadRequest, "Invalid bill", "Month must be formatted YYYY-MM.")
1254 return
1255 }
1256 total, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("total")), 64)
1257 if err != nil {
1258 s.renderMessage(w, r, http.StatusBadRequest, "Invalid bill", "Total must be a number.")
1259 return
1260 }
1261 services, err := parseBillLines(r.FormValue("lines"))
1262 if err != nil {
1263 s.renderMessage(w, r, http.StatusBadRequest, "Invalid bill", err.Error())
1264 return
1265 }
1266
1267 bill := economics.Bill{
1268 Schema: 1,
1269 Month: month,
1270 TotalUSD: total,
1271 Services: services,
1272 Source: economics.SourceManual,
1273 }
1274 if err := s.upsertBill(r.Context(), bill, false); err != nil {
1275 if errors.Is(err, release.ErrCASConflict) {
1276 back("err", "Someone else saved a bill for this month — try again.")
1277 return
1278 }
1279 log.Printf("console: economics bill save: %v", err)
1280 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not save the bill. Please try again.")
1281 return
1282 }
1283 back("ok", "Saved the "+month+" bill.")
1284}
1285
1286// handleEconomicsTierSave sets one account's fleet-pool tier (economics-v2,
1287// Task 4) — Task 5's placement pass reads it back via Store.AccountTiers.
1288// Registered without a method prefix (server.go), same reasoning as
1289// handleEconomicsScenarioSave: the method check comes FIRST, before the
1290// session/operator gate, so a wrong-method request is byte-identical to an
1291// unknown route. Resolves the target account by sqid via AccountBySqid, NOT
1292// AccountAnyBySqid: AccountBySqid is already viewer-independent — it
1293// filters deleted_at, not membership, so it satisfies "an operator can set
1294// the tier on ANY account, including one they don't belong to" just fine —
1295// and AccountAnyBySqid's doc comment (store.go) reserves it for
1296// materialization's one caller alone. Using AccountBySqid also means a
1297// tombstoned account's sqid correctly reports "No such account." instead of
1298// a false success that writes an inert tier onto a dead row (review, fix
1299// round 1).
1300func (s *Server) handleEconomicsTierSave(w http.ResponseWriter, r *http.Request) {
1301 if r.Method != http.MethodPost {
1302 http.NotFound(w, r)
1303 return
1304 }
1305 addr, raw, ok := s.session(r)
1306 if !ok {
1307 http.NotFound(w, r)
1308 return
1309 }
1310 isOp, err := s.store.IsOperator(addr)
1311 if err != nil {
1312 log.Printf("console: economics tier save: is-operator: %v", err)
1313 http.NotFound(w, r)
1314 return
1315 }
1316 if !isOp {
1317 http.NotFound(w, r)
1318 return
1319 }
1320 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
1321 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
1322 return
1323 }
1324
1325 back := func(kind, msg string) {
1326 s.setFlash(w, kind, msg)
1327 http.Redirect(w, r, "/fleet/economics/accounts", http.StatusSeeOther)
1328 }
1329
1330 sqid := strings.TrimSpace(r.FormValue("account"))
1331 acct, err := s.store.AccountBySqid(sqid)
1332 if err != nil {
1333 log.Printf("console: economics tier save: lookup %s: %v", sqid, err)
1334 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not look up the account. Please try again.")
1335 return
1336 }
1337 if acct == nil {
1338 back("err", "No such account.")
1339 return
1340 }
1341
1342 tier := r.FormValue("tier")
1343 if err := s.store.SetAccountTier(acct.ID, tier); err != nil {
1344 // A fixed message, not err.Error(): sibling doors use human wording,
1345 // and echoing arbitrary form input into a Set-Cookie flash risks
1346 // exceeding the cookie's size limit and silently dropping the flash
1347 // (review, fix round 1). The rejected value still reaches the logs.
1348 log.Printf("console: economics tier save: rejected tier %q for %s: %v", tier, sqid, err)
1349 back("err", "Tier must be one of: try, pool, hosted, fleet, dedicated — or blank to unset.")
1350 return
1351 }
1352
1353 back("ok", "Set "+sqid+"'s tier to "+tierLabel(tier)+".")
1354}
1355
1356// tierLabel renders SetAccountTier's empty-string value as something a flash
1357// message can say, rather than a blank space between two words.
1358func tierLabel(tier string) string {
1359 if tier == "" {
1360 return "unassigned"
1361 }
1362 return tier
1363}
1364
1365// handleAPIEconomicsBill is the CLI's manual-bill door (issue: `carlos
1366// economics bill`, Task 15). Registered without a method prefix
1367// (server.go); the method check comes first, same reasoning as
1368// handleEconomicsScenarioSave — a GET here (no Authorization header) must
1369// not reach cliUser's 401, which would itself confirm the route exists.
1370// Past that, gate mirrors the console form's exactly, just through the
1371// bearer/cliUser identity instead of a session: cliUser -> IsOperator -> a
1372// bare http.NotFound byte-identical with an unknown API route, BEFORE the
1373// body is even read — same role-not-probeable reasoning as
1374// handleEconomicsScenarioSave.
1375func (s *Server) handleAPIEconomicsBill(w http.ResponseWriter, r *http.Request) {
1376 if r.Method != http.MethodPost {
1377 http.NotFound(w, r)
1378 return
1379 }
1380 addr, ok := s.cliUser(w, r)
1381 if !ok {
1382 return
1383 }
1384 isOp, err := s.store.IsOperator(addr)
1385 if err != nil {
1386 log.Printf("console: api economics bill: is-operator: %v", err)
1387 http.NotFound(w, r)
1388 return
1389 }
1390 if !isOp {
1391 http.NotFound(w, r)
1392 return
1393 }
1394
1395 var req struct {
1396 Month string `json:"month"`
1397 TotalUSD float64 `json:"total_usd"`
1398 Services map[string]float64 `json:"services"`
1399 }
1400 if !readJSON(w, r, &req) {
1401 return
1402 }
1403 if _, err := time.Parse("2006-01", req.Month); err != nil {
1404 jsonError(w, http.StatusBadRequest, "month must be formatted YYYY-MM")
1405 return
1406 }
1407
1408 bill := economics.Bill{
1409 Schema: 1,
1410 Month: req.Month,
1411 TotalUSD: req.TotalUSD,
1412 Services: req.Services,
1413 Source: economics.SourceManual,
1414 }
1415 if err := s.upsertBill(r.Context(), bill, false); err != nil {
1416 if errors.Is(err, release.ErrCASConflict) {
1417 jsonError(w, http.StatusConflict, "someone else saved a bill for this month — try again")
1418 return
1419 }
1420 log.Printf("console: api economics bill: %v", err)
1421 jsonError(w, http.StatusInternalServerError, "could not save the bill")
1422 return
1423 }
1424 writeJSON(w, http.StatusOK, bill)
1425}
1426
1427// backhaulReport is GET /api/economics/backhaul: what serving through region
1428// edges costs the platform this period (#288). The backhaul meters are the
1429// hop SUBSET of egress (usage.EdgeRow): bytes the HOME box sent to a region
1430// edge, which the edge then sent on to the client. The home box's metered
1431// bytes are already priced as egress; the extra leg is the inter-region
1432// transfer, priced here at RateCard.InterRegionGBUSD and stated as
1433// absorbed: a platform cost, in no account's cost or revenue (#284's
1434// ruling). Accounts with no backhaul are omitted. Operator only, the bill
1435// door's bare-404 posture for a token without the bit; the route itself is
1436// discoverable (an unauthenticated GET gets cliUser's 401), which is the
1437// same as every other /api door.
1438type backhaulReport struct {
1439 Period string `json:"period"`
1440 CoveredDays int `json:"covered_days"`
1441 PeriodDays int `json:"period_days"`
1442 EgressGB float64 `json:"egress_gb"`
1443 BackhaulGB float64 `json:"backhaul_gb"`
1444 BackhaulReq1k float64 `json:"backhaul_requests_1k"`
1445 ShareOfEgress float64 `json:"share_of_egress"` // 0..1; 0 when no egress
1446 InterRegionGBUSD float64 `json:"inter_region_gb_usd"` // the rate the extra leg is priced at
1447 AbsorbedUSD float64 `json:"absorbed_usd"`
1448 Accounts []backhaulAccount `json:"accounts"` // backhaul desc, account asc on ties
1449}
1450
1451type backhaulAccount struct {
1452 Account string `json:"account"`
1453 Name string `json:"name,omitempty"`
1454 EgressGB float64 `json:"egress_gb"`
1455 BackhaulGB float64 `json:"backhaul_gb"`
1456 BackhaulReq1k float64 `json:"backhaul_requests_1k"`
1457 ShareOfEgress float64 `json:"share_of_egress"`
1458 AbsorbedUSD float64 `json:"absorbed_usd"`
1459}
1460
1461func buildBackhaulReport(v *EconomicsView) backhaulReport {
1462 rep := backhaulReport{Period: v.Period, CoveredDays: v.CoveredDays, PeriodDays: v.PeriodDays,
1463 InterRegionGBUSD: v.Scenario.RateCard.InterRegionGBUSD, Accounts: []backhaulAccount{}}
1464 for acct, meters := range v.Meters {
1465 a := backhaulAccount{Account: acct, Name: v.Names[acct],
1466 EgressGB: meters[usage.MeterEgressGB], BackhaulGB: meters[usage.MeterBackhaulGB],
1467 BackhaulReq1k: meters[usage.MeterBackhaulRequests1k]}
1468 if a.EgressGB > 0 {
1469 a.ShareOfEgress = a.BackhaulGB / a.EgressGB
1470 }
1471 a.AbsorbedUSD = a.BackhaulGB * rep.InterRegionGBUSD
1472 rep.EgressGB += a.EgressGB
1473 rep.BackhaulGB += a.BackhaulGB
1474 rep.BackhaulReq1k += a.BackhaulReq1k
1475 if a.BackhaulGB == 0 && a.BackhaulReq1k == 0 {
1476 continue // egress counted, row omitted: no backhaul, nothing to say
1477 }
1478 rep.Accounts = append(rep.Accounts, a)
1479 }
1480 if rep.EgressGB > 0 {
1481 rep.ShareOfEgress = rep.BackhaulGB / rep.EgressGB
1482 }
1483 rep.AbsorbedUSD = rep.BackhaulGB * rep.InterRegionGBUSD
1484 sort.Slice(rep.Accounts, func(i, j int) bool {
1485 if rep.Accounts[i].BackhaulGB != rep.Accounts[j].BackhaulGB {
1486 return rep.Accounts[i].BackhaulGB > rep.Accounts[j].BackhaulGB
1487 }
1488 return rep.Accounts[i].Account < rep.Accounts[j].Account
1489 })
1490 return rep
1491}
1492
1493func (s *Server) handleAPIEconomicsBackhaul(w http.ResponseWriter, r *http.Request) {
1494 if r.Method != http.MethodGet {
1495 http.NotFound(w, r)
1496 return
1497 }
1498 addr, ok := s.cliUser(w, r)
1499 if !ok {
1500 return
1501 }
1502 isOp, err := s.store.IsOperator(addr)
1503 if err != nil {
1504 log.Printf("console: api economics backhaul: is-operator: %v", err)
1505 http.NotFound(w, r)
1506 return
1507 }
1508 if !isOp {
1509 http.NotFound(w, r)
1510 return
1511 }
1512 v, err := s.LoadEconomics(r.Context(), r.URL.Query().Get("period"))
1513 if err != nil {
1514 log.Printf("console: api economics backhaul: %v", err)
1515 jsonError(w, http.StatusServiceUnavailable, "economics unavailable")
1516 return
1517 }
1518 writeJSON(w, http.StatusOK, buildBackhaulReport(v))
1519}
1520