carlos / platform Public

cli: carlos economics bill records the month's AWS bill

Task 15 of the unit-economics plan. Signed-in mode only (the bill is a
console object, POST /api/economics/bill through the Task 12 door):
-month YYYY-MM, -total usd, repeatable -line Service=usd, -console.
Client-side validation catches what the console's own 500s on today
(NaN/Inf/negative amounts, malformed months) before a round trip is
spent. A 404 (non-operator or a stale console) gets its own message —
notFoundErr's '-account and -app' text doesn't fit a door with neither
flag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 7fd1add46aa3f90f39569f37fe96069aaa5bdd73 parent 4f2131a
3 files changed, +526 −0
  • economics.go +231 −0
  • economics_test.go +292 −0
  • main.go +3 −0
diff --git a/economics.go b/economics.go
new file mode 100644
index 000000000..54221e8fa
--- /dev/null
+++ b/economics.go
@@ -0,0 +1,231 @@
+// 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"
+ "log"
+ "math"
+ "net/http"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const economicsUsage = `usage: carlos economics bill -month <YYYY-MM> -total <usd> [-line Service=usd ...] [-console <origin>]
+
+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.
+
+flags:
+ -month <YYYY-MM> the billing month, e.g. 2026-08 (required)
+ -total <usd> the invoice total in US dollars (required; must be
+ a finite, non-negative number)
+ -line Service=usd one line item, repeatable — e.g.
+ -line AmazonEC2=55.00 -line AmazonS3=12.30
+ (amount must be finite and non-negative)
+ -console <origin> console to record through (signed-in mode only;
+ default: the only logged-in one)
+
+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 || args[0] != "bill" {
+ fmt.Fprint(os.Stderr, economicsUsage)
+ os.Exit(2)
+ }
+ runEconomicsBill(args[1:])
+}
+
+// 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 := fs.String("console", "", "console to record through (default: the only logged-in one)")
+ 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 <url>.")
+ }
+
+ 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)
+}
diff --git a/economics_test.go b/economics_test.go
new file mode 100644
index 000000000..9d9e9663f
--- /dev/null
+++ b/economics_test.go
@@ -0,0 +1,292 @@
+package main
+
+// Tests for `carlos economics bill` (Task 15): pure parsing/validation
+// first (no network), then the apiSession wire shape against an httptest
+// fake console, then one subprocess happy-path drive of the real binary —
+// restart_test.go's own three-layer shape.
+
+import (
+ "context"
+ "encoding/json"
+ "math"
+ "net/http"
+ "net/http/httptest"
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+// ---- pure parsing/validation ------------------------------------------------
+
+func TestServiceLinesSet(t *testing.T) {
+ lines := serviceLines{}
+ if err := lines.Set("AmazonEC2=55.00"); err != nil {
+ t.Fatalf("Set(AmazonEC2=55.00) = %v, want nil", err)
+ }
+ if got := lines["AmazonEC2"]; got != 55.00 {
+ t.Fatalf("lines[AmazonEC2] = %v, want 55.00", got)
+ }
+
+ for _, bad := range []string{"NoEquals", "X=notanumber", "X=-5", "X=NaN"} {
+ lines := serviceLines{}
+ if err := lines.Set(bad); err == nil {
+ t.Errorf("Set(%q) = nil, want an error", bad)
+ }
+ }
+}
+
+func TestServiceLinesSetMultipleAccumulate(t *testing.T) {
+ lines := serviceLines{}
+ if err := lines.Set("AmazonEC2=55.00"); err != nil {
+ t.Fatal(err)
+ }
+ if err := lines.Set("AmazonS3=12.30"); err != nil {
+ t.Fatal(err)
+ }
+ if len(lines) != 2 || lines["AmazonEC2"] != 55.00 || lines["AmazonS3"] != 12.30 {
+ t.Fatalf("lines = %+v, want both entries", lines)
+ }
+}
+
+func TestValidMonth(t *testing.T) {
+ if err := validMonth("2026-08"); err != nil {
+ t.Errorf("validMonth(2026-08) = %v, want nil", err)
+ }
+ for _, bad := range []string{"2026-8", "2026-13", "garbage", ""} {
+ if err := validMonth(bad); err == nil {
+ t.Errorf("validMonth(%q) = nil, want an error", bad)
+ }
+ }
+}
+
+func TestValidAmount(t *testing.T) {
+ for _, good := range []float64{0, 55, 123.45} {
+ if err := validAmount(good); err != nil {
+ t.Errorf("validAmount(%v) = %v, want nil", good, err)
+ }
+ }
+ for _, bad := range []float64{math.NaN(), math.Inf(1), math.Inf(-1), -5} {
+ if err := validAmount(bad); err == nil {
+ t.Errorf("validAmount(%v) = nil, want an error", bad)
+ }
+ }
+}
+
+// ---- apiSession-level: wire shape against a fake console -------------------
+
+// TestAPISessionEconomicsBillWireShape pins what handleAPIEconomicsBill
+// requires: a bearer POST to /api/economics/bill with month/total_usd/
+// services in the body, decoding the console's own echoed bill back.
+func TestAPISessionEconomicsBillWireShape(t *testing.T) {
+ var gotPath, gotMethod, gotAuth string
+ var gotBody struct {
+ Month string `json:"month"`
+ TotalUSD float64 `json:"total_usd"`
+ Services map[string]float64 `json:"services"`
+ }
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
+ json.NewDecoder(r.Body).Decode(&gotBody)
+ json.NewEncoder(w).Encode(map[string]any{
+ "month": "2026-08", "total_usd": 123.45,
+ "services": map[string]float64{"AmazonEC2": 55.0}, "source": "manual",
+ })
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).economicsBill(context.Background(), "2026-08", 123.45, map[string]float64{"AmazonEC2": 55.0})
+ if err != nil {
+ t.Fatalf("economicsBill: %v", err)
+ }
+ if gotMethod != http.MethodPost {
+ t.Fatalf("method = %s, want POST", gotMethod)
+ }
+ if gotPath != "/api/economics/bill" {
+ t.Fatalf("path = %s", gotPath)
+ }
+ if gotAuth != "Bearer cli-token" {
+ t.Fatalf("auth = %q", gotAuth)
+ }
+ if gotBody.Month != "2026-08" || gotBody.TotalUSD != 123.45 || gotBody.Services["AmazonEC2"] != 55.0 {
+ t.Fatalf("request body = %+v", gotBody)
+ }
+ if res.Month != "2026-08" || res.TotalUSD != 123.45 || res.Services["AmazonEC2"] != 55.0 || res.Source != "manual" {
+ t.Fatalf("result = %+v, want the console's echoed bill", res)
+ }
+}
+
+// TestAPISessionEconomicsBillNotFoundNamesOperatorBit: handleAPIEconomicsBill
+// answers a non-operator's token with the same bare 404 an unknown route
+// gets — notFoundErr's own "-account and -app" text does not fit this
+// door at all, so this must be its own message.
+func TestAPISessionEconomicsBillNotFoundNamesOperatorBit(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).economicsBill(context.Background(), "2026-08", 1, nil)
+ if err == nil {
+ t.Fatal("a 404 must error")
+ }
+ if !strings.Contains(err.Error(), "operator") {
+ t.Fatalf("error %q does not name the operator-bit cause", err)
+ }
+ if strings.Contains(err.Error(), "-account") {
+ t.Fatalf("error %q reuses notFoundErr's -account/-app text, which does not apply to this door", err)
+ }
+}
+
+// TestAPISessionEconomicsBillServerErrorSurfacesVerbatim: a real diagnosis
+// (a bad month, a CAS conflict) reaches the terminal in the console's own
+// words.
+func TestAPISessionEconomicsBillServerErrorSurfacesVerbatim(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ json.NewEncoder(w).Encode(map[string]string{"error": "someone else saved a bill for this month — try again"})
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).economicsBill(context.Background(), "2026-08", 1, nil)
+ if err == nil || !strings.Contains(err.Error(), "try again") {
+ t.Fatalf("economicsBill 409 = %v, want the console's own text", err)
+ }
+}
+
+// ---- subprocess: bad arguments ---------------------------------------------
+
+// TestEconomicsVerbRefusals drives the real binary for the ways the verb
+// stops before it ever dials anything: no sub-verb, an unknown sub-verb, a
+// missing/malformed -month, a missing -total, and direct bucket mode. Every
+// run goes through carlosTestEnv with a scratch cmd.Dir — the repo rule —
+// so a logged-in laptop's real credential and console are never what this
+// test exercises.
+func TestEconomicsVerbRefusals(t *testing.T) {
+ bin := buildCarlos(t)
+ scratch := t.TempDir()
+ run := func(env []string, args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command(bin, args...)
+ cmd.Env = carlosTestEnv(t, env...)
+ cmd.Dir = scratch
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ out, err := run(nil, "economics")
+ if err == nil {
+ t.Fatalf("economics with no sub-verb exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "usage: carlos economics bill") {
+ t.Fatalf("economics with no sub-verb = %q, want the usage string", out)
+ }
+
+ out, err = run(nil, "economics", "scenario")
+ if err == nil {
+ t.Fatalf("economics with an unknown sub-verb exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "usage: carlos economics bill") {
+ t.Fatalf("economics with an unknown sub-verb = %q, want the usage string", out)
+ }
+
+ out, err = run(nil, "economics", "bill", "-total", "1")
+ if err == nil {
+ t.Fatalf("bill with no -month exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "usage: carlos economics bill") {
+ t.Fatalf("bill with no -month = %q, want the usage string", out)
+ }
+
+ out, err = run(nil, "economics", "bill", "-month", "2026-08")
+ if err == nil {
+ t.Fatalf("bill with no -total exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "usage: carlos economics bill") {
+ t.Fatalf("bill with no -total = %q, want the usage string", out)
+ }
+
+ out, err = run(nil, "economics", "bill", "-month", "2026-8", "-total", "1")
+ if err == nil {
+ t.Fatalf("bill with a malformed -month exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "YYYY-MM") {
+ t.Fatalf("bill with a malformed -month = %q, want the month-shape complaint", out)
+ }
+
+ out, err = run(nil, "economics", "bill", "-month", "2026-08", "-total", "-5")
+ if err == nil {
+ t.Fatalf("bill with a negative -total exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "negative") {
+ t.Fatalf("bill with a negative -total = %q, want the negative-amount complaint", out)
+ }
+
+ out, err = run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "economics", "bill", "-month", "2026-08", "-total", "1")
+ if err == nil {
+ t.Fatalf("bill in direct bucket mode exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "carlos economics bill works through a console") ||
+ !strings.Contains(out, "carlos auth login") {
+ t.Fatalf("direct-mode refusal = %q, want economics bill's own message and the login fix", out)
+ }
+}
+
+// ---- subprocess: happy path -------------------------------------------------
+
+// TestEconomicsBillCLIHappyPath drives `carlos economics bill` end to end
+// against a fake console (credentials written straight to the path
+// carlosTestEnv designates, the same shortcut fleets_cli_test.go's
+// TestRunFleetsCreateHitsRightPathWithBody takes to avoid a real login
+// round trip) and checks the printed output names the service line and the
+// total.
+func TestEconomicsBillCLIHappyPath(t *testing.T) {
+ bin := buildCarlos(t)
+ scratch := t.TempDir()
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/economics/bill" || r.Method != http.MethodPost {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "month": "2026-08", "total_usd": 123.45,
+ "services": map[string]float64{"AmazonEC2": 55.0}, "source": "manual",
+ })
+ }))
+ defer ts.Close()
+
+ env := carlosTestEnv(t)
+ writeTestCredential(t, envSliceValue(env, "CARLOS_CREDENTIALS"), ts.URL)
+
+ cmd := exec.Command(bin, "economics", "bill", "-month", "2026-08", "-total", "123.45", "-line", "AmazonEC2=55.00")
+ cmd.Env = env
+ cmd.Dir = scratch
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("economics bill: %v\n%s", err, out)
+ }
+ got := string(out)
+ if !strings.Contains(got, "AmazonEC2") || !strings.Contains(got, "55.00") {
+ t.Fatalf("stdout = %q, want the AmazonEC2 service line", got)
+ }
+ if !strings.Contains(got, "123.45") {
+ t.Fatalf("stdout = %q, want the total", got)
+ }
+}
+
+// envSliceValue returns the value of the last occurrence of key=... in env,
+// or "" if absent — used only to recover the CARLOS_CREDENTIALS path
+// carlosTestEnv picked, so a credential can be written to the exact spot the
+// subprocess will look, without adding any env var of this test's own
+// invention. Distinct from scopedkeys_e2e_test.go's envValue (same package),
+// which pulls VAR=value out of a verb's STDOUT, not a []string env slice.
+func envSliceValue(env []string, key string) string {
+ prefix := key + "="
+ for i := len(env) - 1; i >= 0; i-- {
+ if strings.HasPrefix(env[i], prefix) {
+ return strings.TrimPrefix(env[i], prefix)
+ }
+ }
+ return ""
+}
diff --git a/main.go b/main.go
index b7a85b745..f6ee8ebb7 100644
--- a/main.go
+++ b/main.go
@@ -61,6 +61,8 @@ func main() {
runDeploy(args)
case "restart":
runRestart(args)
+ case "economics":
+ runEconomics(args)
case "store":
runStore(args)
case "logs":
@@ -166,6 +168,7 @@ Your deployment — stand up and administer boxes, routes and accounts
revoke a capability, hibernate it, change its backing
instances with no sub-verb: list THIS box's registry routes
release-keygen mint the root signing key, or a scoped signing key
+ economics record the month's AWS bill for the economics dashboard
status -live: what every box last reported; -dry-run: leak check
On the host — what systemd runs; rarely typed by hand