// economics.go is `carlos economics bill` (Task 15 of the unit-economics // plan): the CLI door onto the console's manual-bill record // (POST /api/economics/bill, internal/console/economicspass.go's // handleAPIEconomicsBill) — the one way an operator gets a real AWS invoice // total into the economics dashboard without touching the bucket or a box. // // Signed-in mode ONLY, like restart and the instance-record verbs: the bill // is a console-authorized write with no bucket equivalent, and there is // nothing a direct-mode operator could do with it that the dashboard, which // reads the same console object, would ever see. package main import ( "context" "encoding/json" "errors" "flag" "fmt" "io" "log" "math" "net/http" "net/url" "os" "sort" "strconv" "strings" "time" ) const economicsUsage = `usage: carlos economics bill --month --total [--line Service=usd ...] [--console ] carlos economics backhaul [--period mtd|last|7d] [--console ] Records one month's AWS bill for the economics dashboard. Operator only: the console answers a non-operator's token with the same bare 404 it gives an unknown route (it must not be probeable), so "not found" here almost always means your account lacks the fleet-operator bit. A manual entry always overwrites whatever the console already holds for that month — a previous Cost Explorer pull or an earlier manual entry alike. ` // serviceLines is a repeatable -line Service=usd flag collector // (flag.Value): every -line adds one entry to the map, the way a single // map-typed flag cannot — flag.NewFlagSet has no MapVar, only Var, which // calls Set once per occurrence. type serviceLines map[string]float64 // String satisfies flag.Value. It is called once, before any -line is // parsed, to print the flag's zero-value default in -h output — an empty // summary is all that needs to survive that call. func (l serviceLines) String() string { return "" } // Set parses one -line Service=usd occurrence. strings.Cut rather than // strings.Split: a service name is free-form text and could itself contain // "=" beyond the first one, and Cut's ok bool is exactly "was there a '=' // at all" — SplitN(2) would return a 1-element slice for "NoEquals" instead // of a clean signal to test against. func (l serviceLines) Set(raw string) error { service, amountRaw, ok := strings.Cut(raw, "=") if !ok { return fmt.Errorf("%q is not Service=usd — missing '='", raw) } service = strings.TrimSpace(service) if service == "" { return fmt.Errorf("%q is not Service=usd — empty service name", raw) } amount, err := strconv.ParseFloat(strings.TrimSpace(amountRaw), 64) if err != nil { return fmt.Errorf("%q is not Service=usd — %q is not a number", raw, amountRaw) } if err := validAmount(amount); err != nil { return fmt.Errorf("%s=%s: %w", service, amountRaw, err) } l[service] = amount return nil } // validAmount is the one finite-and-non-negative check both -total and every // -line amount share. strconv.ParseFloat happily accepts "NaN" and "Inf" // (it is not a JSON decoder), and the console's own validation // (handleAPIEconomicsBill) checks neither today — a bad value sent as-is // would either 500 the door or wedge whatever renders the dashboard's // totals, so this is caught here, before any request is built, rather than // relied on server-side. func validAmount(v float64) error { if math.IsNaN(v) { return errors.New("must be a number, not NaN") } if math.IsInf(v, 0) { return errors.New("must be finite") } if v < 0 { return errors.New("must not be negative") } return nil } // validMonth checks --month against the same YYYY-MM shape the console // parses with (time.Parse("2006-01"), handleAPIEconomicsBill) — checked // client-side too so a typo'd month fails at the terminal instead of // spending a round trip on a request the console is certain to refuse. func validMonth(month string) error { if _, err := time.Parse("2006-01", month); err != nil { return fmt.Errorf("%q is not a YYYY-MM month", month) } return nil } // runEconomics dispatches the `economics` verb's sub-verbs — today just // `bill` — the same judgment call `carlos version target` makes (main.go): // a word that names a different action earns a sub-verb rather than staying // a bare flag, but an unknown or absent one is a usage error, not a silent // no-op — mirroring main()'s own unknown-command handling (usage to stderr, // exit 2), not the flag-validation log.Fatal (exit 1) every verb below this // dispatch uses for its OWN bad arguments. func runEconomics(args []string) { if len(args) == 0 { fmt.Fprint(os.Stderr, economicsUsage) os.Exit(2) } switch args[0] { case "bill": runEconomicsBill(args[1:]) case "backhaul": runEconomicsBackhaul(args[1:]) default: fmt.Fprint(os.Stderr, economicsUsage) os.Exit(2) } } const economicsBackhaulUsage = `usage: carlos economics backhaul [--period mtd|last|7d] [--console ] Shows what serving through region edges costs the platform this period: the part of every account's egress that was backhauled from its home box (usage.EdgeRow's hop subset — inside the egress totals, never on top), priced at the rate card's inter-region $/GB (the extra leg; the home box's bytes are already egress) and ABSORBED — it is in nobody's bill (#284). Operator only; a non-operator's token gets the same bare 404 an unknown route gets. ` 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"` InterRegionGBUSD float64 `json:"inter_region_gb_usd"` AbsorbedUSD float64 `json:"absorbed_usd"` Accounts []backhaulAccount `json:"accounts"` } 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 (s *apiSession) economicsBackhaul(ctx context.Context, period string) (backhaulReport, error) { path := "/api/economics/backhaul" if period != "" { path += "?period=" + url.QueryEscape(period) } status, body, err := s.getJSON(ctx, path) if err != nil { return backhaulReport{}, err } if status != http.StatusOK { if status == http.StatusNotFound { return backhaulReport{}, errors.New("not found — this needs the fleet-operator bit on your account, " + "or this console predates `carlos economics backhaul` (deploy a console built from current main)") } return backhaulReport{}, apiErrorFromBody(status, body) } var out backhaulReport if err := json.Unmarshal(body, &out); err != nil { return backhaulReport{}, fmt.Errorf("economics backhaul: unreadable response: %w", err) } return out, nil } func runEconomicsBackhaul(args []string) { fs := flag.NewFlagSet("economics backhaul", flag.ExitOnError) period := fs.String("period", "", "mtd (default), last, or 7d") consoleFlag := consoleOption(fs, "console to ask (default: the only logged-in one)") fs.Usage = flagUsage(fs, economicsBackhaulUsage) fs.Parse(args) sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } if sess == nil { log.Fatal("carlos economics backhaul works through a console, and this shell is in direct bucket mode\n" + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Unset the store env and run: carlos auth login --console .") } printUsing(stdout, sess) rep, err := sess.economicsBackhaul(context.Background(), *period) if err != nil { log.Fatalf("economics backhaul: %v", err) } printBackhaulReport(stdout, rep) } func printBackhaulReport(w io.Writer, rep backhaulReport) { 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", rep.Period, rep.CoveredDays, rep.PeriodDays, rep.BackhaulGB, rep.EgressGB, rep.ShareOfEgress*100, rep.BackhaulReq1k, rep.AbsorbedUSD, rep.InterRegionGBUSD) if len(rep.Accounts) == 0 { fmt.Fprintln(w, "no account backhauled anything this period") return } tw := newTable(w) fmt.Fprintln(tw, "ACCOUNT\tNAME\tEGRESS GB\tBACKHAUL GB\tSHARE\tREQ (k)\tABSORBED") for _, a := range rep.Accounts { 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) } tw.Flush() } // economicsBillResult mirrors handleAPIEconomicsBill's success shape: the // bill exactly as the console stored it (economics.Bill's wire shape), // redeclared here rather than importing internal/economics for one struct — // this client only ever decodes it, the same reasoning apiStoreReply and // friends (apimode.go) give for their own redeclared response shapes. type economicsBillResult struct { Month string `json:"month"` TotalUSD float64 `json:"total_usd"` Services map[string]float64 `json:"services"` Source string `json:"source"` } // economicsBill posts one month's manual bill entry to the console // (POST /api/economics/bill) and returns exactly what the console stored. // // A 404 here is genuinely ambiguous, the same shape restart's 404 handling // (apimode.go) already reasons through: handleAPIEconomicsBill's gate // answers a non-operator's token and an unknown route with the SAME bare, // bodyless http.NotFound, on purpose — an operator-status probe must not be // distinguishable from a stale console. notFoundErr's own generic text // ("check --account and --app") does not fit here at all (this door has // neither flag), so this is the one call in the package that writes its own // 404 message rather than reusing it. func (s *apiSession) economicsBill(ctx context.Context, month string, total float64, services map[string]float64) (economicsBillResult, error) { in := struct { Month string `json:"month"` TotalUSD float64 `json:"total_usd"` Services map[string]float64 `json:"services,omitempty"` }{month, total, services} status, body, err := s.postJSON(ctx, "/api/economics/bill", in) if err != nil { return economicsBillResult{}, err } if status != http.StatusOK { if status == http.StatusNotFound { return economicsBillResult{}, errors.New("not found — this needs the fleet-operator bit on your account, " + "or this console predates `carlos economics bill` (deploy a console built from current main)") } return economicsBillResult{}, apiErrorFromBody(status, body) } var out economicsBillResult if err := json.Unmarshal(body, &out); err != nil { return economicsBillResult{}, fmt.Errorf("economics bill: unreadable response: %w", err) } return out, nil } // runEconomicsBill is `carlos economics bill`'s flag parsing and printing. func runEconomicsBill(args []string) { fs := flag.NewFlagSet("economics bill", flag.ExitOnError) month := fs.String("month", "", "billing month, YYYY-MM (required)") total := fs.Float64("total", 0, "invoice total in US dollars (required)") lines := serviceLines{} fs.Var(lines, "line", "one line item, repeatable: Service=usd") consoleFlag := consoleOption(fs, "console to record through (default: the only logged-in one)") fs.Usage = flagUsage(fs, economicsUsage) fs.Parse(args) set := map[string]bool{} fs.Visit(func(f *flag.Flag) { set[f.Name] = true }) if *month == "" || !set["total"] { log.Fatal(economicsUsage) } if err := validMonth(*month); err != nil { log.Fatalf("economics bill: %v", err) } if err := validAmount(*total); err != nil { log.Fatalf("economics bill: --total %v", err) } sess, err := resolveMode(*consoleFlag) if err != nil { log.Fatal(err) } if sess == nil { log.Fatal("carlos economics bill works through a console, and this shell is in direct bucket mode\n" + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). The bill is a console-authorized\n" + "write with no bucket equivalent. Unset the store env and run: carlos auth login --console .") } printUsing(stdout, sess) res, err := sess.economicsBill(context.Background(), *month, *total, map[string]float64(lines)) if err != nil { log.Fatalf("economics bill: %v", err) } names := make([]string, 0, len(res.Services)) for name := range res.Services { names = append(names, name) } sort.Strings(names) if len(names) > 0 { w := newTable(stdout) fmt.Fprintln(w, "SERVICE\tUSD") for _, name := range names { fmt.Fprintf(w, "%s\t%.2f\n", name, res.Services[name]) } w.Flush() } fmt.Fprintf(stdout, "%s %s: $%.2f\n", colorFor(stdout).ok("recorded bill for"), res.Month, res.TotalUSD) }