carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
11package main
12
13import (
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
31const 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
34Records one month's AWS bill for the economics dashboard. Operator only: the
35console answers a non-operator's token with the same bare 404 it gives an
36unknown route (it must not be probeable), so "not found" here almost always
37means your account lacks the fleet-operator bit.
38
39A manual entry always overwrites whatever the console already holds for that
40month — 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.
47type 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.
52func (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.
59func (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.
86func 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.
103func 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.
117func 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
133const economicsBackhaulUsage = `usage: carlos economics backhaul [--period mtd|last|7d] [--console <origin>]
134
135Shows what serving through region edges costs the platform this period:
136the 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),
138priced at the rate card's inter-region $/GB (the extra leg; the home box's
139bytes are already egress) and ABSORBED — it is in nobody's bill (#284).
140Operator only; a non-operator's token gets the same bare 404 an unknown
141route gets.
142`
143
144type 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
157type 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
167func (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
190func 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
212func 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.
232type 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.
250func (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.
275func 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