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>
3 files changed,
+526
−0
economics.go+231 −0economics_test.go+292 −0main.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 |