| 1 | package console |
| 2 | |
| 3 | import ( |
| 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. |
| 33 | const 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). |
| 39 | func (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). |
| 58 | func (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). |
| 83 | func (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 "". |
| 99 | func (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. |
| 136 | func (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. |
| 231 | func 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. |
| 244 | func 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. |
| 261 | type 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. |
| 318 | func (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. |
| 486 | func (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. |
| 570 | func 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. |
| 588 | func 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. |
| 600 | func 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. |
| 667 | func (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. |
| 758 | func (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. |
| 847 | const 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. |
| 867 | func (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. |
| 902 | func (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. |
| 936 | func 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. |
| 959 | func (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. |
| 985 | func (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. |
| 1012 | func (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). |
| 1023 | func 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. |
| 1064 | func (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. |
| 1086 | func (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. |
| 1117 | func (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. |
| 1193 | func 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. |
| 1221 | func (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). |
| 1300 | func (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. |
| 1358 | func 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. |
| 1375 | func (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. |
| 1438 | type 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 | |
| 1451 | type 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 | |
| 1461 | func 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 | |
| 1493 | func (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 | |