package console import ( "context" "crypto/subtle" "encoding/json" "errors" "fmt" "log" "math" "net/http" "os" "sort" "strconv" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" awss3 "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/economics" "github.com/carlosframework/platform/internal/metrics" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/usage" ) // economicsPassInterval is how often the pass wakes. Each day's objects are // create-only, so waking hourly costs one conditional GET per family once // the day is written — and catches up promptly after a console restart. const economicsPassInterval = time.Hour // StartEconomicsPass runs economicsPass immediately and then hourly until // ctx is done. Started from cmd/console's serve(), deliberately NOT from // NewServer — the StartAlertWatcher rule: test servers never grow // goroutines (alertwatch.go:44). func (s *Server) StartEconomicsPass(ctx context.Context) { t := time.NewTicker(economicsPassInterval) defer t.Stop() for { if err := s.economicsPass(ctx, time.Now()); err != nil { log.Printf("console: economics pass: %v", err) } select { case <-ctx.Done(): return case <-t.C: } } } // economicsPass runs the daily economics writes for now's UTC day: census, // storage sweep, statements, bill pull. Each stage degrades independently — // a failed stage logs and the next still runs, because a missing census day // must not cost the month's statements (spec: error handling). func (s *Server) economicsPass(ctx context.Context, now time.Time) error { date := usage.DayOf(now) if err := s.censusPass(ctx, date); err != nil { log.Printf("console: census pass %s: %v", date, err) } if err := s.storagePass(ctx, date); err != nil { log.Printf("console: storage pass %s: %v", date, err) } if err := s.statementsPass(ctx, now); err != nil { log.Printf("console: statements pass: %v", err) } if err := s.billPass(ctx, now); err != nil { log.Printf("console: bill pass: %v", err) } return nil } // openPassRegistry opens the console's registry for a daily pass, refusing // early rather than letting registry.Open's sql.Open silently create and // migrate an EMPTY sqlite file at an unconfigured or unmounted path — a // pass that read that as "zero routes" would write a permanent, uncorrectable // all-zero day (create-only) and leave a stray sqlite file behind. Shared by // every pass that reads routes (censusPass, statementsPass); mirrors // instanceHealth's same-shaped guard for the empty-string case // (apiinstances.go:207). func (s *Server) openPassRegistry() (consoleRegistry, error) { if s.cfg.Registry == "" { return nil, fmt.Errorf("no registry configured") } if _, err := os.Stat(s.cfg.Registry); err != nil { return nil, fmt.Errorf("registry %s: %w", s.cfg.Registry, err) } return openRegistry(s.cfg.Registry) } // censusPass snapshots per-account app/route counts, create-only. Counted by // account SQID (release.RouteAccount's output, e.g. "bab" — never the // console store's display Name, e.g. "ops"): release.RouteAccount normalizes // a route with no account (Route.Account == "") to release.OpsAccount, the // deployment operator's built-in account — so pre-accounts routes land in // one place instead of scattering under "". func (s *Server) censusPass(ctx context.Context, date string) error { key := usage.CensusDayKey(date) if _, _, err := s.cfg.Releases.Get(ctx, key); err == nil { return nil // today already written (by us or a sibling console) } reg, err := s.openPassRegistry() if err != nil { return fmt.Errorf("console: census pass: %w", err) } routes, err := reg.List() if err != nil { return err } accounts := map[string]usage.CensusAccount{} apps := map[string]map[string]bool{} for _, rt := range routes { acct := release.RouteAccount(rt) c := accounts[acct] c.Routes++ if apps[acct] == nil { apps[acct] = map[string]bool{} } if rt.App != "" && !apps[acct][rt.App] { apps[acct][rt.App] = true c.Apps++ } accounts[acct] = c } day := usage.CensusDay{V: 1, Date: date, Accounts: accounts} _, err = usage.CreateDayObject(ctx, s.cfg.Releases, key, day) return err } // storagePass sizes every app's deployment-bucket prefix, plus its tenant // object store when one is provisioned, create-only per day. Requires the // bucket to support sized listings (release.SizeLister); a store that // cannot answer marks the day's sweep absent rather than lying with zeros. func (s *Server) storagePass(ctx context.Context, date string) error { key := usage.StorageDayKey(date) if _, _, err := s.cfg.Releases.Get(ctx, key); err == nil { return nil // today already written (by us or a sibling console) } sizer, ok := s.cfg.Releases.(release.SizeLister) if !ok { log.Printf("console: storage pass: bucket store does not support sized listings; skipping") return nil } // ListDirs (both DirStore and S3Store) returns each child as a FULL, // slash-terminated prefix — "apps/bab/", not "bab" — so the next level's // call takes that string straight back as its own prefix, and the bare // name for map keys/AppPrefix is recovered by trimming both ends. acctPrefixes, err := s.cfg.Releases.ListDirs(ctx, "apps/") if err != nil { return err } day := usage.StorageDay{V: 1, Date: date, Apps: map[string]usage.AppStorage{}} for _, acctPrefix := range acctPrefixes { acct := strings.TrimSuffix(strings.TrimPrefix(acctPrefix, "apps/"), "/") appPrefixes, err := s.cfg.Releases.ListDirs(ctx, acctPrefix) if err != nil { return err } for _, appPrefix := range appPrefixes { app := strings.TrimSuffix(strings.TrimPrefix(appPrefix, acctPrefix), "/") sizes, err := sizer.ListSizes(ctx, release.AppPrefix(acct, app)) if err != nil { return err } var st usage.AppStorage for _, ks := range sizes { st.BucketBytes += ks.Size } // EVERY store this app has: its own, and one per config // environment (2026-09-19). They sum into ONE figure under the // app's usage key, so StorageDay's wire shape gains no dimension // and an account's bill is the same arithmetic over more buckets. // Per-store attribution is discussion #140's ask, and deliberately // not made here. envs, eerr := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct, app) if eerr != nil { // Same rule as an unreadable record below: "could not ask" is // not "no store", and this object is create-only. log.Printf("console: storage pass: store environments %s/%s: %v", acct, app, eerr) st.StoreUnswept = true } for _, env := range append([]string{""}, envs...) { rec, _, gerr := objstore.GetIn(ctx, s.cfg.Releases, acct, app, env) if gerr != nil { // Absent is (nil, "", nil) — objstore.GetIn's own idiom — // so a non-nil error here is a REAL failure (transient // bucket read, malformed store.json), not "no store". // Treating it as "no store" would write // StoreBytes:0/StoreUnswept:false into a create-only // object, indistinguishable from an app that never had a // store and never correctable afterward. log.Printf("console: storage pass: store record %s/%s%s: %v", acct, app, storeEnvSuffix(env), gerr) st.StoreUnswept = true continue } if rec == nil || rec.Provisioned == nil { continue } st.StoreProvisioned = true n, serr := s.sizeStore(ctx, rec.Provisioned) if serr != nil { log.Printf("console: storage pass: store %s/%s%s: %v", acct, app, storeEnvSuffix(env), serr) st.StoreUnswept = true continue } st.StoreBytes += n } day.Apps[acct+"/"+app] = st } } _, err = usage.CreateDayObject(ctx, s.cfg.Releases, key, day) return err } // unitOwner attributes a sampled systemd unit (or exec-child cgroup name) to // its route's account/app. Matching mirrors OwnerAppMetrics (metricsview.go // ~698-730): rather than parse a host back out of the unit string, it builds // each route's OWN expected unit names — activator.UnitName/ExecName for a // unit-backed or exec-backed instance route, plus the route's stamped Unit // column (#110's bespoke unit) — and compares those forward against the // sampled name. (Read first per the brief: neither UnitName nor ExecName // systemd-escapes the host — activator.UnitName is a bare string // concatenation and cgroup.ExecName likewise — so there is no unescaping // helper to reuse; building the expected name forward, the same direction // OwnerAppMetrics does it, is what keeps a hyphenated or dotted host correct // with no separate escaping step.) A unit matching no route — the console's // own unit, system units — attributes to release.OpsAccount, app "": platform // overhead. func unitOwner(routes []registry.Route, unit string) (acct, app string, ok bool) { rt, ok := routeForUnit(routes, unit) if !ok { return release.OpsAccount, "", false } return release.RouteAccount(rt), rt.App, true } // routeForUnit is the matching rule itself, split out of unitOwner so that a // second reader of the same sampled units — `carlos status --instances`, // which needs the route's HOST and not only its owner — cannot end up with a // private second notion of which route a cgroup belongs to. The comment above // unitOwner is the rationale for the rule; this is the rule. func routeForUnit(routes []registry.Route, unit string) (registry.Route, bool) { for _, rt := range routes { if rt.Unit != "" && rt.Unit == unit { return rt, true } if rt.Host != "" && (unit == activator.UnitName(rt.Host) || unit == activator.ExecName(rt.Host)) { return rt, true } } return registry.Route{}, false } // usageSweep is sweepUsage's result: the box- and account-shaped meters // accumulated by reading metrics and edge days over [from, to], plus enough // coverage bookkeeping for callers to label a total honestly. It deliberately // does NOT include storage or census meters — see sweepUsage's doc comment — // every caller folds those in afterward via foldStorageAndCensus. type usageSweep struct { PerAccount map[string]map[string]float64 // account -> meter -> value PerBox map[string]economics.BoxLoad // box -> period load (Hours, per-account CPU/mem) BoxDays map[string]int // box -> days with any metrics rollup // CoveredDays counts UTC days in [from, to] with at least one source // object — a metrics day rollup on any box, an edge day on any box, or // the day's storage sweep (checked here for validity only, never // accumulated — see below) — out of PeriodDays. A day with none of // those contributes nothing, uncovered, never a zero standing in for // missing data (package usage's "modelling, not invoice" posture). CoveredDays, PeriodDays int // EgressMetered marks every box that has EVER written a usage/edge day // (discovered via ListDirs on usage.EdgeBoxesPrefix, independent of // [from, to]) — a box that reports metrics but has no edge component at // all is simply absent from this map, so `sweep.EgressMetered[box]` // defaults to false for it, matching the honesty rule: a caller must // show "unmetered", never a zero that reads as "confirmed no egress". EgressMetered map[string]bool } // sweepUsage reads every UTC day in [from, to] (both truncated to midnight, // inclusive) from metrics and edge day objects and accumulates them into // account- and box-shaped meters — the per-day reading/accumulation shared // by statementsPass (whole month to date, create-or-replace statements) and // Task 11's LoadEconomics (arbitrary period, priced live under a scenario). // // routes is the registry snapshot unitOwner attributes sampled units // against — the CALLER resolves it, deliberately not sweepUsage itself, // because the two callers need opposite failure behaviour on a // missing/unmounted registry: statementsPass (a WRITE pass) must refuse // outright rather than write a permanent all-zero day off an empty // registry.Open (openPassRegistry's own doc comment), while LoadEconomics // (a READ path) degrades instead — see loadRoutesForEconomics // (economicsview.go). A nil/empty routes therefore isn't an error here: // every sampled unit simply falls through unitOwner's unmatched-unit case // to release.OpsAccount, same as any other unmatched unit. // // Storage and census are deliberately NOT accumulated here: storage's // account attribution needs a ValidAccount guard with pass-scoped dedup // logging that only makes sense once per whole call, and census never gates // coverage at all — both stay in foldStorageAndCensus, called separately by // every caller on top of this sweep's result. Storage's day objects ARE // still consulted here, for CoveredDays only: a day with a valid storage // sweep and nothing else is still a covered day, exactly as before this // function existed, even though the bytes themselves are read again by // foldStorageAndCensus. // // unitOwner attributes a sampled unit against routes, which — for both // current callers — is this CONSOLE's own box-local registry, while this // sweep iterates units published by every box under metrics/boxes/. On a // multi-box deployment a unit sampled on a box other than this console's own // can fail to match any route in this registry and silently fall to // release.OpsAccount, indistinguishable from the intended platform-overhead // fallback. Both production deployments are single-box today, so this is // currently inert; the structural fix is routes-as-bucket-records (PR #137, // unmerged), which would give every pass a registry view that spans boxes // instead of one box's local file. func (s *Server) sweepUsage(ctx context.Context, from, to time.Time, routes []registry.Route) (*usageSweep, error) { metricsBoxes, err := s.cfg.Releases.ListDirs(ctx, metricsBoxesPrefix) if err != nil { return nil, fmt.Errorf("console: sweep usage: %w", err) } edgeBoxes, err := s.cfg.Releases.ListDirs(ctx, usage.EdgeBoxesPrefix) if err != nil { return nil, fmt.Errorf("console: sweep usage: %w", err) } sweep := &usageSweep{ PerAccount: map[string]map[string]float64{}, PerBox: map[string]economics.BoxLoad{}, BoxDays: map[string]int{}, EgressMetered: map[string]bool{}, } // A box is metered for egress the moment it has EVER written a day — // independent of the swept period, matching the struct's "wrote any // usage/edge day" contract. for _, boxPrefix := range edgeBoxes { box := strings.TrimSuffix(strings.TrimPrefix(boxPrefix, usage.EdgeBoxesPrefix), "/") sweep.EgressMetered[box] = true } touch := func(acct string) map[string]float64 { mm := sweep.PerAccount[acct] if mm == nil { mm = map[string]float64{} sweep.PerAccount[acct] = mm } return mm } boxLoad := func(box string) economics.BoxLoad { bl, ok := sweep.PerBox[box] if !ok { bl = economics.BoxLoad{Box: box, ByAccount: map[string]economics.BoxAccountLoad{}} } return bl } fromDay := from.UTC().Truncate(24 * time.Hour) toDay := to.UTC().Truncate(24 * time.Hour) for d := fromDay; !d.After(toDay); d = d.AddDate(0, 0, 1) { date := usage.DayOf(d) covered := false for _, boxPrefix := range metricsBoxes { box := strings.TrimSuffix(strings.TrimPrefix(boxPrefix, metricsBoxesPrefix), "/") raw, _, gerr := s.cfg.Releases.Get(ctx, metrics.DayKey(box, date)) if gerr != nil { continue // missing = skip, uncovered by this box } var roll metrics.DayRollup if jerr := json.Unmarshal(raw, &roll); jerr != nil { log.Printf("console: sweep usage: parse %s: %v", metrics.DayKey(box, date), jerr) continue } covered = true sweep.BoxDays[box]++ bl := boxLoad(box) bl.Hours += float64(len(roll.Hours)) for _, hour := range roll.Hours { for unit, um := range hour.Units { acct, _, _ := unitOwner(routes, unit) mm := touch(acct) var instN int var cpuCoreHours, memGBHours float64 if cpu, ok := um["cpu"]; ok { cpuCoreHours = cpu.Avg * float64(cpu.N) / 60 mm[usage.MeterCPUCoreHours] += cpuCoreHours instN = cpu.N } if mem, ok := um["mem_bytes"]; ok { memGBHours = mem.Avg * float64(mem.N) / 60 / 1e9 mm[usage.MeterMemGBHours] += memGBHours if instN == 0 { instN = mem.N } } mm[usage.MeterInstanceHours] += float64(instN) / 60 al := bl.ByAccount[acct] al.CPUCoreHours += cpuCoreHours al.MemGBHours += memGBHours bl.ByAccount[acct] = al } } sweep.PerBox[box] = bl } for _, boxPrefix := range edgeBoxes { box := strings.TrimSuffix(strings.TrimPrefix(boxPrefix, usage.EdgeBoxesPrefix), "/") raw, _, gerr := s.cfg.Releases.Get(ctx, usage.EdgeDayKey(box, date)) if gerr != nil { continue // missing = skip, uncovered by this box } var ed usage.EdgeDay if jerr := json.Unmarshal(raw, &ed); jerr != nil { log.Printf("console: sweep usage: parse %s: %v", usage.EdgeDayKey(box, date), jerr) continue } covered = true for _, rows := range ed.Hours { for _, row := range rows { acct := row.Account if acct == "" { acct = release.OpsAccount } mm := touch(acct) mm[usage.MeterEgressGB] += float64(row.BytesOut) / 1e9 mm[usage.MeterRequests1k] += float64(row.Requests) / 1000 // The hop SUBSET (usage.EdgeRow): traffic served through a // region edge and backhauled from the home box, so the // platform paid for its egress twice. Never added to the // totals above (it is inside them); priced nowhere — the // #284 ruling is "absorb, keep the data" — and surfaced as // its own meter so the economics pages and `carlos // economics backhaul` can show what that costs us (#288). mm[usage.MeterBackhaulGB] += float64(row.HopBytesOut) / 1e9 mm[usage.MeterBackhaulRequests1k] += float64(row.HopRequests) / 1000 } } } // Storage is consulted here for coverage ONLY — a valid day object // makes the day covered, exactly as before this sweep existed — but // its bytes are not accumulated: that needs statementsPass's // ValidAccount guard and pass-scoped dedup logging. Missing = skip, // uncovered by storage (metrics/edge may still have covered it). if raw, _, gerr := s.cfg.Releases.Get(ctx, usage.StorageDayKey(date)); gerr == nil { var sd usage.StorageDay if jerr := json.Unmarshal(raw, &sd); jerr == nil { covered = true } } if covered { sweep.CoveredDays++ } sweep.PeriodDays++ } return sweep, nil } // foldStorageAndCensus reads each UTC day's storage and census objects in // [from, to] (both truncated to midnight, inclusive) and folds their meters // into dst (account -> meter -> value) — MeterStorageGBMonth and // MeterAppMonths, the two families sweepUsage deliberately leaves out (see // its doc comment). Shared by statementsPass and buildEconomicsView // (economicsview.go) so the two never disagree about a period's storage/ // app-existence numbers — the bug this fixes: the view path used to call // sweepUsage alone and never see these meters at all. // // Denominator ruling (review, Task 11 fix round): each day's contribution // divides by the number of days in THAT DAY's own calendar month — not one // daysInMonth computed once for the whole range. For statementsPass, whose // range is always "whole month to date", every day shares one month, so // this is identical byte-for-byte to the pre-refactor single daysInMonth // constant (proven by its unmodified tests). For LoadEconomics's "7d" // period, which can straddle a month boundary, a day still accrues // (bytes/1e9)/daysInMonth(that day) and apps/daysInMonth(that day) — meters // stay "accrued GB-months/app-months in the period", pricing correctly // under monthly rates regardless of which days the period spans. // // badStorageKeys dedups a malformed storage-day key's log line at most once // per key for this ONE CALL — matching the pre-split pass-scoped behaviour, // since each of statementsPass and buildEconomicsView calls this exactly // once per invocation. func (s *Server) foldStorageAndCensus(ctx context.Context, from, to time.Time, dst map[string]map[string]float64) error { touch := func(acct string) map[string]float64 { mm := dst[acct] if mm == nil { mm = map[string]float64{} dst[acct] = mm } return mm } badStorageKeys := map[string]bool{} fromDay := from.UTC().Truncate(24 * time.Hour) toDay := to.UTC().Truncate(24 * time.Hour) for d := fromDay; !d.After(toDay); d = d.AddDate(0, 0, 1) { date := usage.DayOf(d) y, mo, _ := d.Date() daysInMonth := time.Date(y, mo+1, 0, 0, 0, 0, 0, time.UTC).Day() if raw, _, gerr := s.cfg.Releases.Get(ctx, usage.StorageDayKey(date)); gerr == nil { var sd usage.StorageDay if jerr := json.Unmarshal(raw, &sd); jerr != nil { log.Printf("console: fold storage/census: parse %s: %v", usage.StorageDayKey(date), jerr) } else { for key, ap := range sd.Apps { acct := key if i := strings.IndexByte(key, '/'); i >= 0 { acct = key[:i] } // A legacy unqualified app prefix (apps//releases/, // no account segment) makes storagePass mint a StorageDay // key whose segment before "/" is an APP name, not an // account sqid. Left unguarded, that mints a bogus // statement (e.g. usage/accounts/kass/2026-08.json) // polluting the sqid-keyed space Task 11 joins on — so an // account segment that doesn't pass release.ValidAccount // attributes to release.OpsAccount instead, same as an // unmatched unit. if !release.ValidAccount(acct) { if !badStorageKeys[key] { badStorageKeys[key] = true 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) } acct = release.OpsAccount } bytesTotal := ap.BucketBytes + ap.StoreBytes touch(acct)[usage.MeterStorageGBMonth] += (float64(bytesTotal) / 1e9) / float64(daysInMonth) } } } // missing = skip; sweepUsage already counted the day's coverage // Census does not gate coverage (sweepUsage's covered-day sources are // metrics/edge/storage only) but still feeds app_months when present. if raw, _, gerr := s.cfg.Releases.Get(ctx, usage.CensusDayKey(date)); gerr == nil { var cd usage.CensusDay if jerr := json.Unmarshal(raw, &cd); jerr != nil { log.Printf("console: fold storage/census: parse %s: %v", usage.CensusDayKey(date), jerr) } else { for acct, ca := range cd.Accounts { touch(acct)[usage.MeterAppMonths] += float64(ca.Apps) / float64(daysInMonth) } } } } 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. // // Each hour's contribution is clamped to at most 1.0 (review, fix round 1): // a mid-hour agent restart can land more than 60 samples in one hour bucket // — mergeExistingChunk dedups exact-timestamp collisions only, not a // restart's fresh cadence landing alongside the old one — and an N above 60 // must read as "fully present that hour", never as more than one hour billed // for one hour of wall time. func rollupPresenceHours(d metrics.DayRollup) float64 { var total float64 for _, hour := range d.Hours { var minutes float64 if cpu, ok := hour.Host["cpu"]; ok { minutes = float64(cpu.N) } else if maxN := maxHostAggN(hour.Host); maxN > 0 { minutes = float64(maxN) } else { minutes = 60 } total += math.Min(1.0, minutes/60) } return total } // maxHostAggN returns the largest N across host's Agg entries, 0 for an // empty (or nil) map. func maxHostAggN(host map[string]metrics.Agg) int { maxN := 0 for _, agg := range host { if agg.N > maxN { maxN = agg.N } } return maxN } // hasDeviceTag reports whether tags (FleetBox.Tags' comma-separated list) // carries the literal segment "device" — the device-vs-server meter split. func hasDeviceTag(tags string) bool { for _, tag := range strings.Split(tags, ",") { if tag == "device" { return true } } return false } // 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 // (fleet,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,label) per day in range; a day with no // rollup is skipped, same missing-means-skip convention sweepUsage itself // uses). // // Fleet box rollups do NOT live at the platform root's metrics.DayKey(label, // date) — a door-backed fleet box's write ACL admits only // fleets//** (internal/edge/storedoor.go's aclRead/aclWrite), and // resolveStatusPrefix (internal/host/agent.go) defaults every door-backed // box's publish prefix to "fleets//" for exactly that reason. This // reads each fleet's rollups through the same // release.NewPrefixStore(s.cfg.Releases, "fleets/"+fleet+"/") rebase the // console's own fleet page already uses for heartbeats // (loadFleetPage/fleets.go), with the plain metrics.DayKey(label, date) // inside it — so the map below is keyed by (fleet, label), not label alone. // // (fleet, label) keying also means a same-labeled box in two DIFFERENT // fleets is not a collision at all — each lives under its own fleet prefix // at a distinct object key, so both are read and billed independently. // (Review, fix round 1: an earlier version of this function keyed by label // alone and resolved a same-label clash to the alphabetically-first fleet — // which, under prefixed reads, would have been silent DATA LOSS for the // second fleet's box-hours, not a display-only tie-break. Removed entirely, // not just fixed, since the underlying ambiguity no longer exists.) // // Known gaps, recorded rather than fixed at modelling grade (review, fix // round 1): // - A fleet box provisioned before this prefixing existed — a grandfathered // bucket-backed box publishing straight to the platform root, or one an // operator hand-configured with CARLOS_STATUS_PREFIX="" — is invisible to // this meter: its rollups never appear under fleets//, so it // contributes zero fleet box-hours, and its sampled units (if any) fall // through unitOwner's unmatched case to release.OpsAccount like any other // unrecognized unit, same as a platform box's stray unit would. This // closes itself once the box (re)publishes under its fleet prefix — no // separate migration is needed. // - fleetMeters failing makes statementsPass itself fail (see // statementsPass's doc comment for why that's deliberate here). // - Retagging a box "device" mid-month re-prices the box's ENTIRE current // month the next time this pass runs, not just days after the retag — // statements are a create-OR-REPLACE snapshot of the whole month to // date, not an append-only ledger, so there is no "as of" boundary to // honor. Modelling-grade, matching every other meter this pass derives. 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 } type boxKey struct{ fleet, label string } boxes := map[boxKey]boxOwner{} for _, f := range fleets { fboxes, 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 fboxes { boxes[boxKey{fleet: f.Name, label: b.Label}] = boxOwner{acct: f.Account, device: hasDeviceTag(b.Tags)} } } 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 bk, owner := range boxes { meter := usage.MeterFleetBoxHoursServer if owner.device { meter = usage.MeterFleetBoxHoursDevice } fleetStore := release.NewPrefixStore(s.cfg.Releases, "fleets/"+bk.fleet+"/") for d := fromDay; !d.After(toDay); d = d.AddDate(0, 0, 1) { date := usage.DayOf(d) key := metrics.DayKey(bk.label, date) raw, _, gerr := fleetStore.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 fleets/%s/%s: %v", bk.fleet, 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, 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 // deleted_at), so this pass stops upserting its statement from the day of // deletion onward — the account's last statement object simply stops moving // forward, rather than the month closing it out explicitly. // // fleetMeters failing (a bad registry read, a bucket hiccup under a fleet // prefix) makes this WHOLE pass return an error rather than upsert // statements missing that account's fleet hours — deliberate (review, fix // round 1): a billing meter silently going to zero for a tick is worse than // this hourly pass simply not moving the statement forward that tick and // trying again next hour with the same last-good numbers still standing. // This is the WRITE side's posture only — Task 5's read side // (buildEconomicsView) must NOT inherit it: a live view degrading to "could // not read fleet hours" for one request is fine, refusing the whole // economics page over it is not, the same asymmetry sweepUsage's own doc // comment draws between its two callers. func (s *Server) statementsPass(ctx context.Context, now time.Time) error { reg, err := s.openPassRegistry() if err != nil { return fmt.Errorf("console: statements pass: %w", err) } routes, err := reg.List() if err != nil { return fmt.Errorf("console: statements pass: %w", err) } nowUTC := now.UTC() y, mo, _ := nowUTC.Date() daysInMonth := time.Date(y, mo+1, 0, 0, 0, 0, 0, time.UTC).Day() month := usage.MonthOf(nowUTC) monthStart := time.Date(y, mo, 1, 0, 0, 0, 0, time.UTC) sweep, err := s.sweepUsage(ctx, monthStart, nowUTC, routes) if err != nil { return fmt.Errorf("console: statements pass: %w", err) } meters := sweep.PerAccount // account -> meter -> value; folded into below 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 — // the accounts page must list it (brief step 3). The union is the // accumulated meter keys (already RouteAccount()-shaped, e.g. // release.OpsAccount == "bab" — see censusPass/storagePass, same key // space) plus every live console account's Sqid. Deliberately NOT the // console account's display Name: Name ("ops") and RouteAccount's sqid // output ("bab") are different strings for the SAME built-in account // (Task 8's census golden pins "bab" as the key), so a name-keyed union // would never actually join a zero-usage account into the sqid-keyed // universe census/edge/storage already write under — see task-10-report // for the full reasoning. accounts, err := s.store.Accounts() if err != nil { return fmt.Errorf("console: statements pass: %w", err) } universe := map[string]bool{} for acct := range meters { universe[acct] = true } for _, a := range accounts { universe[a.Sqid] = true } for acct := range universe { mm := meters[acct] if mm == nil { mm = map[string]float64{} } st := usage.Statement{ V: 1, Account: acct, Month: month, Meters: mm, CoveredDays: coveredDays, DaysInMonth: daysInMonth, UpdatedAt: nowUTC, } if err := usage.UpsertStatement(ctx, s.cfg.Releases, st); err != nil { return fmt.Errorf("console: statements pass: upsert %s: %w", acct, err) } } return nil } // billCEDayGrace is how many days into the FOLLOWING month a closed month's // bill must have been pulled before billPass stops re-pulling it forever — // CE's late-arriving line items settle by then, so a pull on or after this // day is treated as final. Below this day, the closed month is pulled at // most once per UTC day, same as the current (open) month. const billCEDayGrace = 3 // billPass pulls this month and last month's whole-account bill through the // ceCosts seam (cepull.go) and writes each, manual-wins. It ALWAYS returns // nil: a Cost Explorer failure (no billing IAM grant, a disabled seam, AWS // down) is degraded state, not a pass failure — the spec's posture is "a // deployment that never pulled shows manual entry, not an error" // (economicsview.go's loadRecentBills carries the same posture for a bill // object that simply isn't there yet). Each month's pull/write is // independent — a failure on one must not skip the other. // // The open (current) month's TimePeriod.End is tomorrow's UTC date, not // now — CE's End is EXCLUSIVE and must be strictly after Start // (firstOfThisMonth): on day 1 of any month, now truncated to its own day // equals firstOfThisMonth, which would send Start==End and CE would refuse // the whole request with a ValidationException. Bumping End to the day // AFTER now's UTC day (CE accepts a request that reaches into "today", // not-yet-complete) sidesteps day 1 without a special case. The extra // Format-equality check below is belt and braces against any other path // that could still produce a same-day Start/End. func (s *Server) billPass(ctx context.Context, now time.Time) error { nowUTC := now.UTC() today := time.Date(nowUTC.Year(), nowUTC.Month(), nowUTC.Day(), 0, 0, 0, 0, time.UTC) tomorrow := today.AddDate(0, 0, 1) firstOfThisMonth := time.Date(nowUTC.Year(), nowUTC.Month(), 1, 0, 0, 0, 0, time.UTC) firstOfLastMonth := firstOfThisMonth.AddDate(0, -1, 0) periods := []struct { month string start, end time.Time closedMonth bool // the just-finished month: eligible for the day-3 settle-and-stop rule }{ {usage.MonthOf(nowUTC), firstOfThisMonth, tomorrow, false}, {usage.MonthOf(firstOfLastMonth), firstOfLastMonth, firstOfThisMonth, true}, } for _, p := range periods { if p.start.Format("2006-01-02") == p.end.Format("2006-01-02") { log.Printf("console: bill pass %s: start/end both %s; CE requires start existing.Month && existing.PulledAt.Day() >= billCEDayGrace { // Settled: a closed month pulled on or after day 3 of a LATER // month has already seen CE's late-arriving line items land. Its // numbers don't move again — re-pulling it forever would be pure // waste, unlike the open month, which keeps changing all month. // // The month check matters on its own: a pull made from WITHIN the // closing month (e.g. the month's own last daily pull, July 31) // can land on day >= billCEDayGrace purely by calendar coincidence // (Day()==31) without the month having closed at all yet — that // is not settlement, and must not be treated as such. return true } return usage.DayOf(existing.PulledAt) == usage.DayOf(now) } // getBill reads and parses the stored bill for month, or (nil, nil) if none // exists yet — the "no bill pulled yet" state loadRecentBills // (economicsview.go) also treats as normal, not an error. func (s *Server) getBill(ctx context.Context, month string) (*economics.Bill, error) { raw, _, err := s.cfg.Releases.Get(ctx, economics.BillKey(month)) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, err } var b economics.Bill if jerr := json.Unmarshal(raw, &b); jerr != nil { return nil, jerr } return &b, nil } // upsertBill is one CAS read-etag-then-put attempt at economics.BillKey(bill // .Month) — appconfig's applyAt pattern (internal/appconfig/store.go:162-190), // one attempt, no retry loop: a lost race surfaces as release.ErrCASConflict // to the caller, who decides what "try again" means for their context (a // background pass logs and waits for its next tick; a write door turns it // into a flash message). When manualWins is true (billPass's own calls, // via pullBillMonth) an existing SourceManual bill is left untouched — // manual entry always beats an automatic pull. The manual write doors // (handleEconomicsBillSave, handleAPIEconomicsBill) call this with // manualWins=false: manual entry always overwrites whatever is there, // a previous CE pull or an earlier manual entry alike. func (s *Server) upsertBill(ctx context.Context, bill economics.Bill, manualWins bool) error { key := economics.BillKey(bill.Month) raw, etag, err := s.cfg.Releases.Get(ctx, key) switch { case err == nil: if manualWins { var existing economics.Bill if jerr := json.Unmarshal(raw, &existing); jerr == nil && existing.Source == economics.SourceManual { return nil } } case errors.Is(err, os.ErrNotExist): etag = "" default: return fmt.Errorf("console: bill %s: read: %w", key, err) } body, err := json.Marshal(bill) if err != nil { return fmt.Errorf("console: bill %s: marshal: %w", key, err) } if _, err := s.cfg.Releases.Put(ctx, key, body, etag); err != nil { return fmt.Errorf("console: bill %s: write: %w", key, err) } return nil } // sizeStore routes through the test seam or the real S3 sizer. func (s *Server) sizeStore(ctx context.Context, p *objstore.Provisioned) (int64, error) { if s.storeSizer != nil { return s.storeSizer(ctx, p) } return sizeProvisionedStore(ctx, p) } // sizeProvisionedStore sums a tenant object store's live bytes with a // paginated ListObjectsV2, using an S3 client built exactly the way // objstore.NewAWSProvisioner builds its own (objstore.S3ClientFor: region + // optional endpoint override for S3-compatible backends). func sizeProvisionedStore(ctx context.Context, p *objstore.Provisioned) (int64, error) { client, err := objstore.S3ClientFor(ctx, p.Region, p.Endpoint) if err != nil { return 0, err } var total int64 var token *string for { out, err := client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{ Bucket: aws.String(p.Bucket), ContinuationToken: token, }) if err != nil { return 0, err } for _, obj := range out.Contents { total += aws.ToInt64(obj.Size) } if !aws.ToBool(out.IsTruncated) { break } if out.NextContinuationToken == nil { // A misbehaving S3-compatible endpoint that claims IsTruncated // but omits the continuation token would otherwise refetch page // one forever: this pass runs synchronously inside // StartEconomicsPass, so that hang takes census/statements/ // bill-pull down with it, permanently. break } token = out.NextContinuationToken } return total, nil } // ---- unit economics: scenario/bill write doors ----------------------------- // putScenario CAS-writes economics.ScenarioKey(sc.Name) — appconfig's // applyAt read-etag-then-put shape (internal/appconfig/store.go:162-190), // one attempt: a lost race surfaces release.ErrCASConflict to the caller // (handleEconomicsScenarioSave), which turns it into a retryable flash // error rather than silently overwriting or retrying blind. func (s *Server) putScenario(ctx context.Context, sc economics.Scenario) error { key := economics.ScenarioKey(sc.Name) _, etag, err := s.cfg.Releases.Get(ctx, key) switch { case err == nil: case errors.Is(err, os.ErrNotExist): etag = "" default: return fmt.Errorf("console: scenario %s: read: %w", key, err) } body, err := json.Marshal(sc) if err != nil { return fmt.Errorf("console: scenario %s: marshal: %w", key, err) } if _, err := s.cfg.Releases.Put(ctx, key, body, etag); err != nil { return fmt.Errorf("console: scenario %s: write: %w", key, err) } return nil } // putActiveScenario CAS-writes economics.ActiveKey to name it — same // one-attempt shape as putScenario, same ErrCASConflict contract. func (s *Server) putActiveScenario(ctx context.Context, name string) error { _, etag, err := s.cfg.Releases.Get(ctx, economics.ActiveKey) switch { case err == nil: case errors.Is(err, os.ErrNotExist): etag = "" default: return fmt.Errorf("console: active scenario: read: %w", err) } body, err := json.Marshal(economics.Active{Schema: 1, Name: name}) if err != nil { return fmt.Errorf("console: active scenario: marshal: %w", err) } if _, err := s.cfg.Releases.Put(ctx, economics.ActiveKey, body, etag); err != nil { return fmt.Errorf("console: active scenario: write: %w", err) } return nil } // handleEconomicsScenarioSave writes a named pricing scenario (and, // optionally, activates it). Registered without a method prefix // (server.go), so the FIRST check is r.Method — a GET (or anything but // POST) is byte-identical to an unknown route, never a 405 + Allow, which // would leak that this path exists to someone who merely guessed it. Method // check comes before EVERYTHING else, including the session gate: a // wrong-method request must be indistinguishable from an unknown route // regardless of who's asking. Past that, gate is handleFleet's exact shape // — session -> IsOperator -> a bare http.NotFound byte-identical with an // unknown route — BEFORE the CSRF check, per the console security rule: // role existence must never be probeable, and a CSRF rejection would leak // that this route exists to a non-operator who merely guessed the URL. func (s *Server) handleEconomicsScenarioSave(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) return } addr, raw, ok := s.session(r) if !ok { http.NotFound(w, r) return } isOp, err := s.store.IsOperator(addr) if err != nil { log.Printf("console: economics scenario save: is-operator: %v", err) http.NotFound(w, r) return } if !isOp { http.NotFound(w, r) return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } name := strings.TrimSpace(r.FormValue("name")) var sc economics.Scenario if err := json.Unmarshal([]byte(r.FormValue("scenario")), &sc); err != nil { s.renderMessage(w, r, http.StatusBadRequest, "Invalid scenario", "Could not parse the scenario JSON: "+err.Error()) return } // The form's name field wins over whatever the JSON body carries — the // name is the bucket key (ScenarioKey), so it must come from the one // place a CSRF-protected form field lives, not from a value a client // could otherwise mismatch against the URL a link points at. sc.Name = name if err := sc.Validate(); err != nil { s.renderMessage(w, r, http.StatusBadRequest, "Invalid scenario", err.Error()) return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, "/fleet/economics", http.StatusSeeOther) } if err := s.putScenario(r.Context(), sc); err != nil { if errors.Is(err, release.ErrCASConflict) { back("err", "Someone else saved a scenario — try again.") return } log.Printf("console: economics scenario save: %v", err) s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not save the scenario. Please try again.") return } if r.FormValue("activate") == "1" { if err := s.putActiveScenario(r.Context(), name); err != nil { if errors.Is(err, release.ErrCASConflict) { back("err", "Scenario saved, but someone else changed the active scenario — try again.") return } log.Printf("console: economics scenario save: activate %s: %v", name, err) back("err", "Scenario saved, but could not activate it. Please try again.") return } } back("ok", "Saved scenario "+name+".") } // parseBillLines parses the manual-bill form's "lines" textarea: one // "Service=123.45" per line, blank lines skipped. A malformed line is a // hard error naming the offending line — silently dropping it would // understate the bill with no visible sign, the same reasoning // applyAt's ValidVarValue check documents for config values. func parseBillLines(text string) (map[string]float64, error) { services := map[string]float64{} for _, line := range strings.Split(text, "\n") { line = strings.TrimSpace(line) if line == "" { continue } name, amtStr, ok := strings.Cut(line, "=") if !ok { return nil, fmt.Errorf("line %q: want Service=123.45", line) } name = strings.TrimSpace(name) if name == "" { return nil, fmt.Errorf("line %q: empty service name", line) } amt, err := strconv.ParseFloat(strings.TrimSpace(amtStr), 64) if err != nil { return nil, fmt.Errorf("line %q: %w", line, err) } services[name] = amt } return services, nil } // handleEconomicsBillSave writes a manually-entered bill for one month — // SourceManual, always overwriting whatever is there (upsertBill's // manualWins=false). Same gate shape as handleEconomicsScenarioSave, // including the method check before everything else. func (s *Server) handleEconomicsBillSave(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) return } addr, raw, ok := s.session(r) if !ok { http.NotFound(w, r) return } isOp, err := s.store.IsOperator(addr) if err != nil { log.Printf("console: economics bill save: is-operator: %v", err) http.NotFound(w, r) return } if !isOp { http.NotFound(w, r) return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, "/fleet/economics", http.StatusSeeOther) } month := strings.TrimSpace(r.FormValue("month")) if _, err := time.Parse("2006-01", month); err != nil { s.renderMessage(w, r, http.StatusBadRequest, "Invalid bill", "Month must be formatted YYYY-MM.") return } total, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("total")), 64) if err != nil { s.renderMessage(w, r, http.StatusBadRequest, "Invalid bill", "Total must be a number.") return } services, err := parseBillLines(r.FormValue("lines")) if err != nil { s.renderMessage(w, r, http.StatusBadRequest, "Invalid bill", err.Error()) return } bill := economics.Bill{ Schema: 1, Month: month, TotalUSD: total, Services: services, Source: economics.SourceManual, } if err := s.upsertBill(r.Context(), bill, false); err != nil { if errors.Is(err, release.ErrCASConflict) { back("err", "Someone else saved a bill for this month — try again.") return } log.Printf("console: economics bill save: %v", err) s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not save the bill. Please try again.") return } back("ok", "Saved the "+month+" bill.") } // handleEconomicsTierSave sets one account's fleet-pool tier (economics-v2, // Task 4) — Task 5's placement pass reads it back via Store.AccountTiers. // Registered without a method prefix (server.go), same reasoning as // handleEconomicsScenarioSave: the method check comes FIRST, before the // session/operator gate, so a wrong-method request is byte-identical to an // unknown route. Resolves the target account by sqid via AccountBySqid, NOT // AccountAnyBySqid: AccountBySqid is already viewer-independent — it // filters deleted_at, not membership, so it satisfies "an operator can set // the tier on ANY account, including one they don't belong to" just fine — // and AccountAnyBySqid's doc comment (store.go) reserves it for // materialization's one caller alone. Using AccountBySqid also means a // tombstoned account's sqid correctly reports "No such account." instead of // a false success that writes an inert tier onto a dead row (review, fix // round 1). func (s *Server) handleEconomicsTierSave(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) return } addr, raw, ok := s.session(r) if !ok { http.NotFound(w, r) return } isOp, err := s.store.IsOperator(addr) if err != nil { log.Printf("console: economics tier save: is-operator: %v", err) http.NotFound(w, r) return } if !isOp { http.NotFound(w, r) return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, "/fleet/economics/accounts", http.StatusSeeOther) } sqid := strings.TrimSpace(r.FormValue("account")) acct, err := s.store.AccountBySqid(sqid) if err != nil { log.Printf("console: economics tier save: lookup %s: %v", sqid, err) s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not look up the account. Please try again.") return } if acct == nil { back("err", "No such account.") return } tier := r.FormValue("tier") if err := s.store.SetAccountTier(acct.ID, tier); err != nil { // A fixed message, not err.Error(): sibling doors use human wording, // and echoing arbitrary form input into a Set-Cookie flash risks // exceeding the cookie's size limit and silently dropping the flash // (review, fix round 1). The rejected value still reaches the logs. log.Printf("console: economics tier save: rejected tier %q for %s: %v", tier, sqid, err) back("err", "Tier must be one of: try, pool, hosted, fleet, dedicated — or blank to unset.") return } back("ok", "Set "+sqid+"'s tier to "+tierLabel(tier)+".") } // tierLabel renders SetAccountTier's empty-string value as something a flash // message can say, rather than a blank space between two words. func tierLabel(tier string) string { if tier == "" { return "unassigned" } return tier } // handleAPIEconomicsBill is the CLI's manual-bill door (issue: `carlos // economics bill`, Task 15). Registered without a method prefix // (server.go); the method check comes first, same reasoning as // handleEconomicsScenarioSave — a GET here (no Authorization header) must // not reach cliUser's 401, which would itself confirm the route exists. // Past that, gate mirrors the console form's exactly, just through the // bearer/cliUser identity instead of a session: cliUser -> IsOperator -> a // bare http.NotFound byte-identical with an unknown API route, BEFORE the // body is even read — same role-not-probeable reasoning as // handleEconomicsScenarioSave. func (s *Server) handleAPIEconomicsBill(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) return } addr, ok := s.cliUser(w, r) if !ok { return } isOp, err := s.store.IsOperator(addr) if err != nil { log.Printf("console: api economics bill: is-operator: %v", err) http.NotFound(w, r) return } if !isOp { http.NotFound(w, r) return } var req struct { Month string `json:"month"` TotalUSD float64 `json:"total_usd"` Services map[string]float64 `json:"services"` } if !readJSON(w, r, &req) { return } if _, err := time.Parse("2006-01", req.Month); err != nil { jsonError(w, http.StatusBadRequest, "month must be formatted YYYY-MM") return } bill := economics.Bill{ Schema: 1, Month: req.Month, TotalUSD: req.TotalUSD, Services: req.Services, Source: economics.SourceManual, } if err := s.upsertBill(r.Context(), bill, false); err != nil { if errors.Is(err, release.ErrCASConflict) { jsonError(w, http.StatusConflict, "someone else saved a bill for this month — try again") return } log.Printf("console: api economics bill: %v", err) jsonError(w, http.StatusInternalServerError, "could not save the bill") return } writeJSON(w, http.StatusOK, bill) } // backhaulReport is GET /api/economics/backhaul: what serving through region // edges costs the platform this period (#288). The backhaul meters are the // hop SUBSET of egress (usage.EdgeRow): bytes the HOME box sent to a region // edge, which the edge then sent on to the client. The home box's metered // bytes are already priced as egress; the extra leg is the inter-region // transfer, priced here at RateCard.InterRegionGBUSD and stated as // absorbed: a platform cost, in no account's cost or revenue (#284's // ruling). Accounts with no backhaul are omitted. Operator only, the bill // door's bare-404 posture for a token without the bit; the route itself is // discoverable (an unauthenticated GET gets cliUser's 401), which is the // same as every other /api door. type backhaulReport struct { Period string `json:"period"` CoveredDays int `json:"covered_days"` PeriodDays int `json:"period_days"` EgressGB float64 `json:"egress_gb"` BackhaulGB float64 `json:"backhaul_gb"` BackhaulReq1k float64 `json:"backhaul_requests_1k"` ShareOfEgress float64 `json:"share_of_egress"` // 0..1; 0 when no egress InterRegionGBUSD float64 `json:"inter_region_gb_usd"` // the rate the extra leg is priced at AbsorbedUSD float64 `json:"absorbed_usd"` Accounts []backhaulAccount `json:"accounts"` // backhaul desc, account asc on ties } type backhaulAccount struct { Account string `json:"account"` Name string `json:"name,omitempty"` EgressGB float64 `json:"egress_gb"` BackhaulGB float64 `json:"backhaul_gb"` BackhaulReq1k float64 `json:"backhaul_requests_1k"` ShareOfEgress float64 `json:"share_of_egress"` AbsorbedUSD float64 `json:"absorbed_usd"` } func buildBackhaulReport(v *EconomicsView) backhaulReport { rep := backhaulReport{Period: v.Period, CoveredDays: v.CoveredDays, PeriodDays: v.PeriodDays, InterRegionGBUSD: v.Scenario.RateCard.InterRegionGBUSD, Accounts: []backhaulAccount{}} for acct, meters := range v.Meters { a := backhaulAccount{Account: acct, Name: v.Names[acct], EgressGB: meters[usage.MeterEgressGB], BackhaulGB: meters[usage.MeterBackhaulGB], BackhaulReq1k: meters[usage.MeterBackhaulRequests1k]} if a.EgressGB > 0 { a.ShareOfEgress = a.BackhaulGB / a.EgressGB } a.AbsorbedUSD = a.BackhaulGB * rep.InterRegionGBUSD rep.EgressGB += a.EgressGB rep.BackhaulGB += a.BackhaulGB rep.BackhaulReq1k += a.BackhaulReq1k if a.BackhaulGB == 0 && a.BackhaulReq1k == 0 { continue // egress counted, row omitted: no backhaul, nothing to say } rep.Accounts = append(rep.Accounts, a) } if rep.EgressGB > 0 { rep.ShareOfEgress = rep.BackhaulGB / rep.EgressGB } rep.AbsorbedUSD = rep.BackhaulGB * rep.InterRegionGBUSD sort.Slice(rep.Accounts, func(i, j int) bool { if rep.Accounts[i].BackhaulGB != rep.Accounts[j].BackhaulGB { return rep.Accounts[i].BackhaulGB > rep.Accounts[j].BackhaulGB } return rep.Accounts[i].Account < rep.Accounts[j].Account }) return rep } func (s *Server) handleAPIEconomicsBackhaul(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) return } addr, ok := s.cliUser(w, r) if !ok { return } isOp, err := s.store.IsOperator(addr) if err != nil { log.Printf("console: api economics backhaul: is-operator: %v", err) http.NotFound(w, r) return } if !isOp { http.NotFound(w, r) return } v, err := s.LoadEconomics(r.Context(), r.URL.Query().Get("period")) if err != nil { log.Printf("console: api economics backhaul: %v", err) jsonError(w, http.StatusServiceUnavailable, "economics unavailable") return } writeJSON(w, http.StatusOK, buildBackhaulReport(v)) }