| 1 | // economics.go is `carlos economics bill` (Task 15 of the unit-economics |
| 2 | // plan): the CLI door onto the console's manual-bill record |
| 3 | // (POST /api/economics/bill, internal/console/economicspass.go's |
| 4 | // handleAPIEconomicsBill) — the one way an operator gets a real AWS invoice |
| 5 | // total into the economics dashboard without touching the bucket or a box. |
| 6 | // |
| 7 | // Signed-in mode ONLY, like restart and the instance-record verbs: the bill |
| 8 | // is a console-authorized write with no bucket equivalent, and there is |
| 9 | // nothing a direct-mode operator could do with it that the dashboard, which |
| 10 | // reads the same console object, would ever see. |
| 11 | package main |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "encoding/json" |
| 16 | "errors" |
| 17 | "flag" |
| 18 | "fmt" |
| 19 | "io" |
| 20 | "log" |
| 21 | "math" |
| 22 | "net/http" |
| 23 | "net/url" |
| 24 | "os" |
| 25 | "sort" |
| 26 | "strconv" |
| 27 | "strings" |
| 28 | "time" |
| 29 | ) |
| 30 | |
| 31 | const economicsUsage = `usage: carlos economics bill --month <YYYY-MM> --total <usd> [--line Service=usd ...] [--console <origin>] |
| 32 | carlos economics backhaul [--period mtd|last|7d] [--console <origin>] |
| 33 | |
| 34 | Records one month's AWS bill for the economics dashboard. Operator only: the |
| 35 | console answers a non-operator's token with the same bare 404 it gives an |
| 36 | unknown route (it must not be probeable), so "not found" here almost always |
| 37 | means your account lacks the fleet-operator bit. |
| 38 | |
| 39 | A manual entry always overwrites whatever the console already holds for that |
| 40 | month — a previous Cost Explorer pull or an earlier manual entry alike. |
| 41 | ` |
| 42 | |
| 43 | // serviceLines is a repeatable -line Service=usd flag collector |
| 44 | // (flag.Value): every -line adds one entry to the map, the way a single |
| 45 | // map-typed flag cannot — flag.NewFlagSet has no MapVar, only Var, which |
| 46 | // calls Set once per occurrence. |
| 47 | type serviceLines map[string]float64 |
| 48 | |
| 49 | // String satisfies flag.Value. It is called once, before any -line is |
| 50 | // parsed, to print the flag's zero-value default in -h output — an empty |
| 51 | // summary is all that needs to survive that call. |
| 52 | func (l serviceLines) String() string { return "" } |
| 53 | |
| 54 | // Set parses one -line Service=usd occurrence. strings.Cut rather than |
| 55 | // strings.Split: a service name is free-form text and could itself contain |
| 56 | // "=" beyond the first one, and Cut's ok bool is exactly "was there a '=' |
| 57 | // at all" — SplitN(2) would return a 1-element slice for "NoEquals" instead |
| 58 | // of a clean signal to test against. |
| 59 | func (l serviceLines) Set(raw string) error { |
| 60 | service, amountRaw, ok := strings.Cut(raw, "=") |
| 61 | if !ok { |
| 62 | return fmt.Errorf("%q is not Service=usd — missing '='", raw) |
| 63 | } |
| 64 | service = strings.TrimSpace(service) |
| 65 | if service == "" { |
| 66 | return fmt.Errorf("%q is not Service=usd — empty service name", raw) |
| 67 | } |
| 68 | amount, err := strconv.ParseFloat(strings.TrimSpace(amountRaw), 64) |
| 69 | if err != nil { |
| 70 | return fmt.Errorf("%q is not Service=usd — %q is not a number", raw, amountRaw) |
| 71 | } |
| 72 | if err := validAmount(amount); err != nil { |
| 73 | return fmt.Errorf("%s=%s: %w", service, amountRaw, err) |
| 74 | } |
| 75 | l[service] = amount |
| 76 | return nil |
| 77 | } |
| 78 | |
| 79 | // validAmount is the one finite-and-non-negative check both -total and every |
| 80 | // -line amount share. strconv.ParseFloat happily accepts "NaN" and "Inf" |
| 81 | // (it is not a JSON decoder), and the console's own validation |
| 82 | // (handleAPIEconomicsBill) checks neither today — a bad value sent as-is |
| 83 | // would either 500 the door or wedge whatever renders the dashboard's |
| 84 | // totals, so this is caught here, before any request is built, rather than |
| 85 | // relied on server-side. |
| 86 | func validAmount(v float64) error { |
| 87 | if math.IsNaN(v) { |
| 88 | return errors.New("must be a number, not NaN") |
| 89 | } |
| 90 | if math.IsInf(v, 0) { |
| 91 | return errors.New("must be finite") |
| 92 | } |
| 93 | if v < 0 { |
| 94 | return errors.New("must not be negative") |
| 95 | } |
| 96 | return nil |
| 97 | } |
| 98 | |
| 99 | // validMonth checks --month against the same YYYY-MM shape the console |
| 100 | // parses with (time.Parse("2006-01"), handleAPIEconomicsBill) — checked |
| 101 | // client-side too so a typo'd month fails at the terminal instead of |
| 102 | // spending a round trip on a request the console is certain to refuse. |
| 103 | func validMonth(month string) error { |
| 104 | if _, err := time.Parse("2006-01", month); err != nil { |
| 105 | return fmt.Errorf("%q is not a YYYY-MM month", month) |
| 106 | } |
| 107 | return nil |
| 108 | } |
| 109 | |
| 110 | // runEconomics dispatches the `economics` verb's sub-verbs — today just |
| 111 | // `bill` — the same judgment call `carlos version target` makes (main.go): |
| 112 | // a word that names a different action earns a sub-verb rather than staying |
| 113 | // a bare flag, but an unknown or absent one is a usage error, not a silent |
| 114 | // no-op — mirroring main()'s own unknown-command handling (usage to stderr, |
| 115 | // exit 2), not the flag-validation log.Fatal (exit 1) every verb below this |
| 116 | // dispatch uses for its OWN bad arguments. |
| 117 | func runEconomics(args []string) { |
| 118 | if len(args) == 0 { |
| 119 | fmt.Fprint(os.Stderr, economicsUsage) |
| 120 | os.Exit(2) |
| 121 | } |
| 122 | switch args[0] { |
| 123 | case "bill": |
| 124 | runEconomicsBill(args[1:]) |
| 125 | case "backhaul": |
| 126 | runEconomicsBackhaul(args[1:]) |
| 127 | default: |
| 128 | fmt.Fprint(os.Stderr, economicsUsage) |
| 129 | os.Exit(2) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | const economicsBackhaulUsage = `usage: carlos economics backhaul [--period mtd|last|7d] [--console <origin>] |
| 134 | |
| 135 | Shows what serving through region edges costs the platform this period: |
| 136 | the part of every account's egress that was backhauled from its home box |
| 137 | (usage.EdgeRow's hop subset — inside the egress totals, never on top), |
| 138 | priced at the rate card's inter-region $/GB (the extra leg; the home box's |
| 139 | bytes are already egress) and ABSORBED — it is in nobody's bill (#284). |
| 140 | Operator only; a non-operator's token gets the same bare 404 an unknown |
| 141 | route gets. |
| 142 | ` |
| 143 | |
| 144 | type backhaulReport struct { |
| 145 | Period string `json:"period"` |
| 146 | CoveredDays int `json:"covered_days"` |
| 147 | PeriodDays int `json:"period_days"` |
| 148 | EgressGB float64 `json:"egress_gb"` |
| 149 | BackhaulGB float64 `json:"backhaul_gb"` |
| 150 | BackhaulReq1k float64 `json:"backhaul_requests_1k"` |
| 151 | ShareOfEgress float64 `json:"share_of_egress"` |
| 152 | InterRegionGBUSD float64 `json:"inter_region_gb_usd"` |
| 153 | AbsorbedUSD float64 `json:"absorbed_usd"` |
| 154 | Accounts []backhaulAccount `json:"accounts"` |
| 155 | } |
| 156 | |
| 157 | type backhaulAccount struct { |
| 158 | Account string `json:"account"` |
| 159 | Name string `json:"name,omitempty"` |
| 160 | EgressGB float64 `json:"egress_gb"` |
| 161 | BackhaulGB float64 `json:"backhaul_gb"` |
| 162 | BackhaulReq1k float64 `json:"backhaul_requests_1k"` |
| 163 | ShareOfEgress float64 `json:"share_of_egress"` |
| 164 | AbsorbedUSD float64 `json:"absorbed_usd"` |
| 165 | } |
| 166 | |
| 167 | func (s *apiSession) economicsBackhaul(ctx context.Context, period string) (backhaulReport, error) { |
| 168 | path := "/api/economics/backhaul" |
| 169 | if period != "" { |
| 170 | path += "?period=" + url.QueryEscape(period) |
| 171 | } |
| 172 | status, body, err := s.getJSON(ctx, path) |
| 173 | if err != nil { |
| 174 | return backhaulReport{}, err |
| 175 | } |
| 176 | if status != http.StatusOK { |
| 177 | if status == http.StatusNotFound { |
| 178 | return backhaulReport{}, errors.New("not found — this needs the fleet-operator bit on your account, " + |
| 179 | "or this console predates `carlos economics backhaul` (deploy a console built from current main)") |
| 180 | } |
| 181 | return backhaulReport{}, apiErrorFromBody(status, body) |
| 182 | } |
| 183 | var out backhaulReport |
| 184 | if err := json.Unmarshal(body, &out); err != nil { |
| 185 | return backhaulReport{}, fmt.Errorf("economics backhaul: unreadable response: %w", err) |
| 186 | } |
| 187 | return out, nil |
| 188 | } |
| 189 | |
| 190 | func runEconomicsBackhaul(args []string) { |
| 191 | fs := flag.NewFlagSet("economics backhaul", flag.ExitOnError) |
| 192 | period := fs.String("period", "", "mtd (default), last, or 7d") |
| 193 | consoleFlag := consoleOption(fs, "console to ask (default: the only logged-in one)") |
| 194 | fs.Usage = flagUsage(fs, economicsBackhaulUsage) |
| 195 | fs.Parse(args) |
| 196 | sess, err := resolveMode(*consoleFlag) |
| 197 | if err != nil { |
| 198 | log.Fatal(err) |
| 199 | } |
| 200 | if sess == nil { |
| 201 | log.Fatal("carlos economics backhaul works through a console, and this shell is in direct bucket mode\n" + |
| 202 | "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Unset the store env and run: carlos auth login --console <url>.") |
| 203 | } |
| 204 | printUsing(stdout, sess) |
| 205 | rep, err := sess.economicsBackhaul(context.Background(), *period) |
| 206 | if err != nil { |
| 207 | log.Fatalf("economics backhaul: %v", err) |
| 208 | } |
| 209 | printBackhaulReport(stdout, rep) |
| 210 | } |
| 211 | |
| 212 | func printBackhaulReport(w io.Writer, rep backhaulReport) { |
| 213 | fmt.Fprintf(w, "backhaul %s (%d/%d days covered): %.2f GB of %.2f GB egress (%.1f%%), %.1fk requests — inter-region leg ≈ $%.2f at $%.3f/GB, absorbed\n", |
| 214 | rep.Period, rep.CoveredDays, rep.PeriodDays, rep.BackhaulGB, rep.EgressGB, rep.ShareOfEgress*100, rep.BackhaulReq1k, rep.AbsorbedUSD, rep.InterRegionGBUSD) |
| 215 | if len(rep.Accounts) == 0 { |
| 216 | fmt.Fprintln(w, "no account backhauled anything this period") |
| 217 | return |
| 218 | } |
| 219 | tw := newTable(w) |
| 220 | fmt.Fprintln(tw, "ACCOUNT\tNAME\tEGRESS GB\tBACKHAUL GB\tSHARE\tREQ (k)\tABSORBED") |
| 221 | for _, a := range rep.Accounts { |
| 222 | fmt.Fprintf(tw, "%s\t%s\t%.2f\t%.2f\t%.1f%%\t%.1f\t$%.2f\n", a.Account, a.Name, a.EgressGB, a.BackhaulGB, a.ShareOfEgress*100, a.BackhaulReq1k, a.AbsorbedUSD) |
| 223 | } |
| 224 | tw.Flush() |
| 225 | } |
| 226 | |
| 227 | // economicsBillResult mirrors handleAPIEconomicsBill's success shape: the |
| 228 | // bill exactly as the console stored it (economics.Bill's wire shape), |
| 229 | // redeclared here rather than importing internal/economics for one struct — |
| 230 | // this client only ever decodes it, the same reasoning apiStoreReply and |
| 231 | // friends (apimode.go) give for their own redeclared response shapes. |
| 232 | type economicsBillResult struct { |
| 233 | Month string `json:"month"` |
| 234 | TotalUSD float64 `json:"total_usd"` |
| 235 | Services map[string]float64 `json:"services"` |
| 236 | Source string `json:"source"` |
| 237 | } |
| 238 | |
| 239 | // economicsBill posts one month's manual bill entry to the console |
| 240 | // (POST /api/economics/bill) and returns exactly what the console stored. |
| 241 | // |
| 242 | // A 404 here is genuinely ambiguous, the same shape restart's 404 handling |
| 243 | // (apimode.go) already reasons through: handleAPIEconomicsBill's gate |
| 244 | // answers a non-operator's token and an unknown route with the SAME bare, |
| 245 | // bodyless http.NotFound, on purpose — an operator-status probe must not be |
| 246 | // distinguishable from a stale console. notFoundErr's own generic text |
| 247 | // ("check --account and --app") does not fit here at all (this door has |
| 248 | // neither flag), so this is the one call in the package that writes its own |
| 249 | // 404 message rather than reusing it. |
| 250 | func (s *apiSession) economicsBill(ctx context.Context, month string, total float64, services map[string]float64) (economicsBillResult, error) { |
| 251 | in := struct { |
| 252 | Month string `json:"month"` |
| 253 | TotalUSD float64 `json:"total_usd"` |
| 254 | Services map[string]float64 `json:"services,omitempty"` |
| 255 | }{month, total, services} |
| 256 | status, body, err := s.postJSON(ctx, "/api/economics/bill", in) |
| 257 | if err != nil { |
| 258 | return economicsBillResult{}, err |
| 259 | } |
| 260 | if status != http.StatusOK { |
| 261 | if status == http.StatusNotFound { |
| 262 | return economicsBillResult{}, errors.New("not found — this needs the fleet-operator bit on your account, " + |
| 263 | "or this console predates `carlos economics bill` (deploy a console built from current main)") |
| 264 | } |
| 265 | return economicsBillResult{}, apiErrorFromBody(status, body) |
| 266 | } |
| 267 | var out economicsBillResult |
| 268 | if err := json.Unmarshal(body, &out); err != nil { |
| 269 | return economicsBillResult{}, fmt.Errorf("economics bill: unreadable response: %w", err) |
| 270 | } |
| 271 | return out, nil |
| 272 | } |
| 273 | |
| 274 | // runEconomicsBill is `carlos economics bill`'s flag parsing and printing. |
| 275 | func runEconomicsBill(args []string) { |
| 276 | fs := flag.NewFlagSet("economics bill", flag.ExitOnError) |
| 277 | month := fs.String("month", "", "billing month, YYYY-MM (required)") |
| 278 | total := fs.Float64("total", 0, "invoice total in US dollars (required)") |
| 279 | lines := serviceLines{} |
| 280 | fs.Var(lines, "line", "one line item, repeatable: Service=usd") |
| 281 | consoleFlag := consoleOption(fs, "console to record through (default: the only logged-in one)") |
| 282 | fs.Usage = flagUsage(fs, economicsUsage) |
| 283 | fs.Parse(args) |
| 284 | |
| 285 | set := map[string]bool{} |
| 286 | fs.Visit(func(f *flag.Flag) { set[f.Name] = true }) |
| 287 | if *month == "" || !set["total"] { |
| 288 | log.Fatal(economicsUsage) |
| 289 | } |
| 290 | if err := validMonth(*month); err != nil { |
| 291 | log.Fatalf("economics bill: %v", err) |
| 292 | } |
| 293 | if err := validAmount(*total); err != nil { |
| 294 | log.Fatalf("economics bill: --total %v", err) |
| 295 | } |
| 296 | |
| 297 | sess, err := resolveMode(*consoleFlag) |
| 298 | if err != nil { |
| 299 | log.Fatal(err) |
| 300 | } |
| 301 | if sess == nil { |
| 302 | log.Fatal("carlos economics bill works through a console, and this shell is in direct bucket mode\n" + |
| 303 | "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). The bill is a console-authorized\n" + |
| 304 | "write with no bucket equivalent. Unset the store env and run: carlos auth login --console <url>.") |
| 305 | } |
| 306 | |
| 307 | printUsing(stdout, sess) |
| 308 | res, err := sess.economicsBill(context.Background(), *month, *total, map[string]float64(lines)) |
| 309 | if err != nil { |
| 310 | log.Fatalf("economics bill: %v", err) |
| 311 | } |
| 312 | |
| 313 | names := make([]string, 0, len(res.Services)) |
| 314 | for name := range res.Services { |
| 315 | names = append(names, name) |
| 316 | } |
| 317 | sort.Strings(names) |
| 318 | if len(names) > 0 { |
| 319 | w := newTable(stdout) |
| 320 | fmt.Fprintln(w, "SERVICE\tUSD") |
| 321 | for _, name := range names { |
| 322 | fmt.Fprintf(w, "%s\t%.2f\n", name, res.Services[name]) |
| 323 | } |
| 324 | w.Flush() |
| 325 | } |
| 326 | fmt.Fprintf(stdout, "%s %s: $%.2f\n", colorFor(stdout).ok("recorded bill for"), res.Month, res.TotalUSD) |
| 327 | } |
| 328 | |