console: fix billPass day-1 CE window, cost-guard daily pulls, method-probe leak, CE retry classification
Review fix round (1 Critical + 3 Important):
- CRITICAL: billPass's current-month TimePeriod collapsed Start==End
on day 1 of any month (CE's End is exclusive) -> ValidationException
-> the old any-error disableCE latch killed the previous month's
pull in the same tick. End is now tomorrow's UTC date, plus a belt-
and-braces skip if Start/End ever format to the same day.
- IMPORTANT: CE bills $0.01/request; an hourly tick pulling both
months unconditionally cost ~$14.40/mo for a cost-tracking
feature. pullBillMonth now checks the stored bill first and skips
the CE call entirely when a costexplorer pull already landed today
(UTC), and permanently stops re-pulling a closed month once it was
pulled on or after day 3 of the following month (CE's late-arriving
line items settle by then).
- IMPORTANT: the three write doors were registered with a method
prefix ('POST /path'), so an unauthenticated GET got Go's default
405 + Allow — leaking route existence, contradicting the doc
comment's own non-probeability claim. Registered bare instead; each
handler checks r.Method first, before the session/cliUser gate, so
a wrong-method request is byte-identical to an unknown route.
- IMPORTANT: disableCE latched permanently on ANY GetCostAndUsage
failure, so a throttle or a 5xx would silently kill pulls until
redeploy. Now only auth-shaped failures latch: AccessDenied, an
unrecognized/invalid credential (isAuthShapedError, matched via
smithy.APIError codes) at query time, or the AWS config/credential
chain never resolving at all (checked once, explicitly, bounded to
5s, at client-construction time — this also fixes a real bug the
naive classifier introduced: an unresolvable credential chain isn't
a smithy.APIError, so without this it would retry, and pay the
SDK's own multi-attempt IMDS timeout, on EVERY hourly tick
forever). Everything else logs and retries next tick.
New tests: TestBillPassHandlesMonthBoundary, TestBillPassSkipsSecond
PullSameDay, TestBillPassStopsPullingSettledClosedMonth, TestBillPass
RetriesAfterTransientCEFailure, TestEconomicsWriteDoorsRejectNonPOST,
TestIsAuthShapedError (cepull_test.go).
go test ./internal/console/ -run 'BillPass|ScenarioSave|APIBill|Economics|IsAuthShaped'
-count=1: all pass, 5.2s (TestEconomicsPassWritesCensusOnce down from
40s to 5s under the bounded credential probe). Full go build ./...,
go vet ./..., go test ./... -count=1 (21 packages) and
GOOS=darwin go build ./... all clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+432
−38
internal/console/cepull.go+80 −15internal/console/cepull_test.go+41 −0internal/console/economicsbill_test.go+183 −0internal/console/economicspass.go+119 −19internal/console/server.go+9 −4
diff --git a/internal/console/cepull.go b/internal/console/cepull.go| index 80caf522b..52e5510d4 100644 |
| --- a/internal/console/cepull.go |
| +++ b/internal/console/cepull.go |
| @@ -4,29 +4,38 @@ |
| // environments), must not have that surface as a recurring error: the spec's |
| // posture is "a deployment that never pulled shows manual entry, not an |
| // error" (economicsview.go's loadRecentBills carries the same posture for a |
| -// missing bill object). realCECosts is where that posture is enforced: a |
| -// construction or query failure is logged exactly ONCE per process |
| -// (s.ceWarnOnce) and then s.ceDisabled latches true, so every later billPass |
| -// tick returns the same cached failure immediately — no repeated AWS calls, |
| -// no repeated log spam, hourly, forever. |
| +// missing bill object). realCECosts is where that posture is enforced — |
| +// but ONLY for construction-class failures: the AWS config never resolving |
| +// at all, no usable credential in the chain (checked once, explicitly, |
| +// bounded to 5s — see realCECosts), or a GetCostAndUsage failure shaped |
| +// like AccessDenied/an invalid credential (isAuthShapedError). Those latch |
| +// s.ceDisabled after one log line (s.ceWarnOnce) and stay off for the rest |
| +// of the process — there is no "billing IAM might appear later" retry |
| +// worth making. Everything else — throttling, a 5xx, a transient network |
| +// blip — is NOT auth-shaped: it logs and the next hourly tick tries again, |
| +// because CE itself throttles under sustained load and a single blip must |
| +// not silently kill bill pulls until redeploy. |
| package console |
| import ( |
| "context" |
| + "errors" |
| "fmt" |
| "log" |
| "strconv" |
| + "strings" |
| "time" |
| "github.com/aws/aws-sdk-go-v2/aws" |
| awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| "github.com/aws/aws-sdk-go-v2/service/costexplorer" |
| cetypes "github.com/aws/aws-sdk-go-v2/service/costexplorer/types" |
| + "github.com/aws/smithy-go" |
| ) |
| // resolveCECosts is billPass's entry point into the seam: the test/fake |
| // s.ceCosts function when a test set one, otherwise the real, lazily-built, |
| -// process-cached Cost Explorer client. Guarded by sync.Once/a plain bool |
| +// process-cached Cost Explorer client. Guarded by sync.Once/plain bools |
| // rather than a mutex because billPass only ever runs from |
| // StartEconomicsPass's single-goroutine hourly ticker (economicspass.go) — |
| // there is no concurrent caller for these fields to race against. |
| @@ -38,11 +47,9 @@ func (s *Server) resolveCECosts(ctx context.Context, start, end time.Time) (map[ |
| } |
| // realCECosts builds the Cost Explorer client once (s.ceClientOnce) and |
| -// reuses it for the life of the process. Any failure — building the AWS |
| -// config, or the GetCostAndUsage call itself (AccessDenied is the expected |
| -// shape for a deployment that never granted billing IAM) — latches |
| -// s.ceDisabled so every subsequent call fails fast without touching AWS |
| -// again, and logs exactly once via s.ceWarnOnce. |
| +// reuses it for the life of the process. See the package doc comment above |
| +// for which failures latch s.ceDisabled (auth-shaped, permanent) versus |
| +// which just fail this one tick (everything else, retried next hour). |
| func (s *Server) realCECosts(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| if s.ceDisabled { |
| return nil, fmt.Errorf("console: cost explorer disabled for this process") |
| @@ -50,9 +57,31 @@ func (s *Server) realCECosts(ctx context.Context, start, end time.Time) (map[str |
| s.ceClientOnce.Do(func() { |
| cfg, err := awsconfig.LoadDefaultConfig(ctx) |
| if err != nil { |
| + // The AWS config itself failed to resolve — opaque and |
| + // permanent for this process: there is no "retry the SDK's |
| + // environment/instance-metadata lookup next tick" path, so |
| + // this always latches. |
| s.disableCE(fmt.Errorf("console: cost explorer: load aws config: %w", err)) |
| return |
| } |
| + // Force credential resolution HERE, once, under a short bound — |
| + // rather than letting it happen lazily inside the first |
| + // GetCostAndUsage call below. LoadDefaultConfig never itself |
| + // contacts the network; a missing credential chain (no IMDS role, |
| + // no ~/.aws/credentials — exactly the "opaque credential-chain |
| + // construction failure" this seam exists to survive) only |
| + // surfaces once something tries to actually sign a request, as a |
| + // generic wrapped error with no smithy.APIError to classify — |
| + // left unhandled here, EVERY hourly tick would pay the SDK's own |
| + // multi-attempt IMDS retry/timeout before failing, forever. Doing |
| + // it once, bounded, at construction time keeps that cost to a |
| + // single ~5s probe for the life of the process instead. |
| + credCtx, cancel := context.WithTimeout(ctx, 5*time.Second) |
| + defer cancel() |
| + if _, err := cfg.Credentials.Retrieve(credCtx); err != nil { |
| + s.disableCE(fmt.Errorf("console: cost explorer: resolve aws credentials: %w", err)) |
| + return |
| + } |
| s.ceClient = costexplorer.NewFromConfig(cfg) |
| }) |
| if s.ceDisabled { |
| @@ -71,7 +100,11 @@ func (s *Server) realCECosts(ctx context.Context, start, end time.Time) (map[str |
| }, |
| }) |
| if err != nil { |
| - s.disableCE(fmt.Errorf("console: cost explorer: get cost and usage: %w", err)) |
| + if isAuthShapedError(err) { |
| + s.disableCE(fmt.Errorf("console: cost explorer: get cost and usage: %w", err)) |
| + } else { |
| + log.Printf("console: cost explorer: get cost and usage: %v (not auth-shaped; will retry next tick)", err) |
| + } |
| return nil, err |
| } |
| @@ -96,10 +129,42 @@ func (s *Server) realCECosts(ctx context.Context, start, end time.Time) (map[str |
| return costs, nil |
| } |
| +// isAuthShapedError reports whether err is the "billing IAM isn't set up |
| +// here" shape — the only GetCostAndUsage failure realCECosts treats as |
| +// permanent for the process. AWS surfaces a missing/wrong grant as a smithy |
| +// API error whose code names the problem; Cost Explorer's own generated |
| +// exception types (types/errors.go) don't include these because they're |
| +// account/credential-wide, not CE-specific — they arrive as |
| +// *smithy.GenericAPIError instead, which is why this matches on |
| +// ErrorCode() rather than a typed CE exception. Matched by substring |
| +// (Contains, not exact-equal) because AWS is inconsistent about the |
| +// "Exception" suffix across services and error-message formats |
| +// (AccessDenied vs AccessDeniedException being the canonical example). |
| +func isAuthShapedError(err error) bool { |
| + var apiErr smithy.APIError |
| + if !errors.As(err, &apiErr) { |
| + return false |
| + } |
| + code := apiErr.ErrorCode() |
| + for _, shape := range []string{ |
| + "AccessDenied", |
| + "UnrecognizedClient", |
| + "InvalidClientTokenId", |
| + "InvalidAccessKeyId", |
| + "AuthFailure", |
| + "ExpiredToken", |
| + } { |
| + if strings.Contains(code, shape) { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| // disableCE logs err exactly once for the process (s.ceWarnOnce) and |
| -// latches s.ceDisabled — called on both the construction-failure and the |
| -// query-failure path, so either shape of "billing IAM isn't set up here" |
| -// ends the same way: one log line, then silent manual mode. |
| +// latches s.ceDisabled — called only for auth-shaped failures (see |
| +// isAuthShapedError), so "billing IAM isn't set up here" ends in one log |
| +// line and silent manual mode, while a throttle or a 5xx keeps retrying. |
| func (s *Server) disableCE(err error) { |
| s.ceDisabled = true |
| s.ceWarnOnce.Do(func() { |
diff --git a/internal/console/cepull_test.go b/internal/console/cepull_test.go| new file mode 100644 |
| index 000000000..6ccb5d180 |
| --- /dev/null |
| +++ b/internal/console/cepull_test.go |
| @@ -0,0 +1,41 @@ |
| +package console |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| + |
| + "github.com/aws/smithy-go" |
| +) |
| + |
| +// TestIsAuthShapedError pins the classifier the review round added: |
| +// realCECosts must latch (permanently disable itself for the process) only |
| +// on the "billing IAM isn't set up here" shape — AccessDenied and an |
| +// unrecognized/invalid credential — and must NOT latch on a throttle, a |
| +// service-side limit, a 5xx, or a plain non-API error, all of which are |
| +// worth retrying on the next hourly tick. |
| +func TestIsAuthShapedError(t *testing.T) { |
| + cases := []struct { |
| + name string |
| + err error |
| + want bool |
| + }{ |
| + {"access denied exception", &smithy.GenericAPIError{Code: "AccessDeniedException"}, true}, |
| + {"access denied bare", &smithy.GenericAPIError{Code: "AccessDenied"}, true}, |
| + {"unrecognized client", &smithy.GenericAPIError{Code: "UnrecognizedClientException"}, true}, |
| + {"invalid client token id", &smithy.GenericAPIError{Code: "InvalidClientTokenId"}, true}, |
| + {"invalid access key id", &smithy.GenericAPIError{Code: "InvalidAccessKeyId"}, true}, |
| + {"expired token", &smithy.GenericAPIError{Code: "ExpiredTokenException"}, true}, |
| + {"auth failure", &smithy.GenericAPIError{Code: "AuthFailure"}, true}, |
| + {"throttling", &smithy.GenericAPIError{Code: "ThrottlingException"}, false}, |
| + {"limit exceeded", &smithy.GenericAPIError{Code: "LimitExceededException"}, false}, |
| + {"internal error", &smithy.GenericAPIError{Code: "InternalError"}, false}, |
| + {"service unavailable", &smithy.GenericAPIError{Code: "ServiceUnavailableException"}, false}, |
| + {"plain error", errors.New("network is down"), false}, |
| + {"nil", nil, false}, |
| + } |
| + for _, c := range cases { |
| + if got := isAuthShapedError(c.err); got != c.want { |
| + t.Errorf("%s: isAuthShapedError(%v) = %v, want %v", c.name, c.err, got, c.want) |
| + } |
| + } |
| +} |
diff --git a/internal/console/economicsbill_test.go b/internal/console/economicsbill_test.go| index 342d1026e..13e3e84be 100644 |
| --- a/internal/console/economicsbill_test.go |
| +++ b/internal/console/economicsbill_test.go |
| @@ -107,6 +107,189 @@ func TestBillPassNoCEIsQuiet(t *testing.T) { |
| } |
| } |
| +// TestBillPassHandlesMonthBoundary is the review-round regression for the |
| +// day-1 bug: CE's TimePeriod.End is EXCLUSIVE and must be strictly after |
| +// Start. On day 1 of any month, the naive "current month" period |
| +// (firstOfThisMonth .. now) collapses Start==End (both format to the same |
| +// calendar date), which CE would refuse outright — and that refusal used to |
| +// latch ceDisabled and kill the PREVIOUS month's pull in the very same |
| +// tick, the most valuable and just-closed one. The fake fails loudly if it |
| +// is ever asked for a same-day period, so this proves billPass never sends |
| +// one; both months must land. |
| +func TestBillPassHandlesMonthBoundary(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Date(2026, 9, 1, 3, 0, 0, 0, time.UTC) |
| + |
| + fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| + if start.Format("2006-01-02") == end.Format("2006-01-02") { |
| + t.Fatalf("ceCosts called with start == end == %s (day 1 collapse)", start.Format("2006-01-02")) |
| + } |
| + return map[string]float64{"AmazonEC2": 10}, nil |
| + } |
| + |
| + if err := fx.srv.billPass(ctx, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, month := range []string{"2026-09", "2026-08"} { |
| + if _, _, err := fx.rel.Get(ctx, economics.BillKey(month)); err != nil { |
| + t.Fatalf("bill %s not written on day 1 of the month: %v", month, err) |
| + } |
| + } |
| +} |
| + |
| +// TestBillPassSkipsSecondPullSameDay: CE bills $0.01/request, so an hourly |
| +// pass tick must pull each month AT MOST ONCE per UTC day — a second |
| +// billPass call the same day must not touch the ceCosts seam again at all. |
| +func TestBillPassSkipsSecondPullSameDay(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Date(2026, 8, 11, 3, 0, 0, 0, time.UTC) |
| + |
| + var calls int |
| + fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| + calls++ |
| + return map[string]float64{"AmazonEC2": 10}, nil |
| + } |
| + |
| + if err := fx.srv.billPass(ctx, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + first := calls |
| + if first != 2 { |
| + t.Fatalf("first billPass made %d ceCosts calls, want 2 (current + previous month)", first) |
| + } |
| + |
| + if err := fx.srv.billPass(ctx, now.Add(4*time.Hour)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if calls != first { |
| + t.Fatalf("second billPass the same UTC day made %d more ceCosts calls, want 0", calls-first) |
| + } |
| +} |
| + |
| +// TestBillPassStopsPullingSettledClosedMonth: a closed (previous) month |
| +// that was already pulled on or after day 3 of the following month has |
| +// seen CE's late-arriving line items settle — billPass must stop pulling |
| +// it forever, rather than paying for it every day for the rest of time. |
| +// The still-open current month, on a later day, still pulls normally. |
| +func TestBillPassStopsPullingSettledClosedMonth(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + |
| + settled := economics.Bill{ |
| + Schema: 1, Month: "2026-07", TotalUSD: 10, |
| + Services: map[string]float64{"AmazonEC2": 10}, Source: economics.SourceCostExplorer, |
| + PulledAt: time.Date(2026, 8, 5, 3, 0, 0, 0, time.UTC), // day 5 >= the day-3 grace |
| + } |
| + putStoreJSON(t, fx.rel, economics.BillKey("2026-07"), settled) |
| + |
| + var calls int |
| + fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| + calls++ |
| + return map[string]float64{"AmazonEC2": 99}, nil |
| + } |
| + |
| + if err := fx.srv.billPass(ctx, time.Date(2026, 8, 20, 3, 0, 0, 0, time.UTC)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if calls != 1 { |
| + t.Fatalf("billPass made %d ceCosts calls, want 1 (current month only; the settled closed month must be skipped)", calls) |
| + } |
| + raw, _, err := fx.rel.Get(ctx, economics.BillKey("2026-07")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var got economics.Bill |
| + if err := json.Unmarshal(raw, &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.TotalUSD != 10 { |
| + t.Fatalf("settled closed-month bill total = %v, want unchanged 10 (must not have been re-pulled)", got.TotalUSD) |
| + } |
| +} |
| + |
| +// TestBillPassRetriesAfterTransientCEFailure: a non-auth-shaped ceCosts |
| +// failure (a throttle, a network blip) must not disable further attempts — |
| +// realCECosts' auth-shaped latch (cepull.go, isAuthShapedError) is the only |
| +// thing that may do that, and the seam bypasses realCECosts entirely, so |
| +// this proves billPass itself adds no independent latch of its own: a tick |
| +// whose calls all fail leaves nothing written, and the very next tick's |
| +// calls still reach the seam and succeed. |
| +func TestBillPassRetriesAfterTransientCEFailure(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Date(2026, 8, 11, 3, 0, 0, 0, time.UTC) |
| + |
| + var calls int |
| + fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| + calls++ |
| + if calls <= 2 { // both months fail on the first tick |
| + return nil, errors.New("ThrottlingException: rate exceeded") |
| + } |
| + return map[string]float64{"AmazonEC2": 42}, nil |
| + } |
| + |
| + if err := fx.srv.billPass(ctx, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := fx.rel.Get(ctx, economics.BillKey("2026-08")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("bill written despite every ceCosts call failing on the first tick") |
| + } |
| + |
| + if err := fx.srv.billPass(ctx, now.Add(time.Hour)); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := fx.rel.Get(ctx, economics.BillKey("2026-08")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var b economics.Bill |
| + if err := json.Unmarshal(raw, &b); err != nil { |
| + t.Fatal(err) |
| + } |
| + if b.TotalUSD != 42 { |
| + t.Fatalf("bill total = %v, want 42 (the retried tick's data)", b.TotalUSD) |
| + } |
| +} |
| + |
| +// TestEconomicsWriteDoorsRejectNonPOST: the three write doors are |
| +// registered WITHOUT a method prefix (server.go) specifically so a |
| +// wrong-method request never gets Go's default 405 + Allow — which would |
| +// itself confirm the route exists to someone who merely guessed the URL. |
| +// Every one of them must answer a GET with the exact same 404 an unknown |
| +// route gets, signed out AND signed in as an operator (the method check |
| +// runs before the session/operator gate). |
| +func TestEconomicsWriteDoorsRejectNonPOST(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + out := newBrowser(t) |
| + ghostStatus, _, ghostBody := get(t, out, fx.ts.URL+"/this-route-does-not-exist") |
| + |
| + consolePaths := []string{"/fleet/economics/scenarios", "/fleet/economics/bill"} |
| + for _, path := range consolePaths { |
| + if status, _, body := get(t, out, fx.ts.URL+path); status != ghostStatus || body != ghostBody { |
| + t.Fatalf("signed-out GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody) |
| + } |
| + } |
| + |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, path := range consolePaths { |
| + if status, _, body := get(t, c, fx.ts.URL+path); status != ghostStatus || body != ghostBody { |
| + t.Fatalf("operator GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody) |
| + } |
| + } |
| + |
| + // The API door: an unauthenticated GET (no Authorization header) must |
| + // be the same ghost 404 too, not cliUser's 401 — that would confirm |
| + // the route exists just as surely as a 405 would. |
| + if status, _, body := get(t, out, fx.ts.URL+"/api/economics/bill"); status != ghostStatus || body != ghostBody { |
| + t.Fatalf("unauthenticated GET /api/economics/bill = %d %q, want the same 404 as a missing route (%d %q)", status, body, ghostStatus, ghostBody) |
| + } |
| +} |
| + |
| // TestScenarioSaveGateAndValidation pins the gate order (session -> |
| // IsOperator -> byte-identical 404, matching TestFleetOperatorOnly's |
| // comparison shape) BEFORE csrf, then bad-JSON and happy-path behaviour. |
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go| index ef2b14211..c3b598306 100644 |
| --- a/internal/console/economicspass.go |
| +++ b/internal/console/economicspass.go |
| @@ -588,6 +588,13 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error { |
| return nil |
| } |
| +// billCEDayGrace is how many days into the FOLLOWING month a closed month's |
| +// bill must have been pulled before billPass stops re-pulling it forever — |
| +// CE's late-arriving line items settle by then, so a pull on or after this |
| +// day is treated as final. Below this day, the closed month is pulled at |
| +// most once per UTC day, same as the current (open) month. |
| +const billCEDayGrace = 3 |
| + |
| // billPass pulls this month and last month's whole-account bill through the |
| // ceCosts seam (cepull.go) and writes each, manual-wins. It ALWAYS returns |
| // nil: a Cost Explorer failure (no billing IAM grant, a disabled seam, AWS |
| @@ -596,20 +603,37 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error { |
| // (economicsview.go's loadRecentBills carries the same posture for a bill |
| // object that simply isn't there yet). Each month's pull/write is |
| // independent — a failure on one must not skip the other. |
| +// |
| +// The open (current) month's TimePeriod.End is tomorrow's UTC date, not |
| +// now — CE's End is EXCLUSIVE and must be strictly after Start |
| +// (firstOfThisMonth): on day 1 of any month, now truncated to its own day |
| +// equals firstOfThisMonth, which would send Start==End and CE would refuse |
| +// the whole request with a ValidationException. Bumping End to the day |
| +// AFTER now's UTC day (CE accepts a request that reaches into "today", |
| +// not-yet-complete) sidesteps day 1 without a special case. The extra |
| +// Format-equality check below is belt and braces against any other path |
| +// that could still produce a same-day Start/End. |
| func (s *Server) billPass(ctx context.Context, now time.Time) error { |
| nowUTC := now.UTC() |
| + today := time.Date(nowUTC.Year(), nowUTC.Month(), nowUTC.Day(), 0, 0, 0, 0, time.UTC) |
| + tomorrow := today.AddDate(0, 0, 1) |
| firstOfThisMonth := time.Date(nowUTC.Year(), nowUTC.Month(), 1, 0, 0, 0, 0, time.UTC) |
| firstOfLastMonth := firstOfThisMonth.AddDate(0, -1, 0) |
| periods := []struct { |
| - month string |
| - start, end time.Time |
| + month string |
| + start, end time.Time |
| + closedMonth bool // the just-finished month: eligible for the day-3 settle-and-stop rule |
| }{ |
| - {usage.MonthOf(nowUTC), firstOfThisMonth, nowUTC}, |
| - {usage.MonthOf(firstOfLastMonth), firstOfLastMonth, firstOfThisMonth}, |
| + {usage.MonthOf(nowUTC), firstOfThisMonth, tomorrow, false}, |
| + {usage.MonthOf(firstOfLastMonth), firstOfLastMonth, firstOfThisMonth, true}, |
| } |
| for _, p := range periods { |
| - if err := s.pullBillMonth(ctx, p.month, p.start, p.end, nowUTC); err != nil { |
| + if p.start.Format("2006-01-02") == p.end.Format("2006-01-02") { |
| + log.Printf("console: bill pass %s: start/end both %s; CE requires start<end, skipping this tick", p.month, p.start.Format("2006-01-02")) |
| + continue |
| + } |
| + if err := s.pullBillMonth(ctx, p.month, p.start, p.end, nowUTC, p.closedMonth); err != nil { |
| log.Printf("console: bill pass %s: %v", p.month, err) |
| } |
| } |
| @@ -617,8 +641,22 @@ func (s *Server) billPass(ctx context.Context, now time.Time) error { |
| } |
| // pullBillMonth pulls one month's whole-account by-service costs through |
| -// the ceCosts seam and writes it, manual-wins (upsertBill's manualWins=true). |
| -func (s *Server) pullBillMonth(ctx context.Context, month string, start, end, pulledAt time.Time) error { |
| +// the ceCosts seam and writes it, manual-wins (upsertBill's manualWins=true) |
| +// — but first checks whether a pull is even worth making: CE bills |
| +// $0.01/request, and the hourly pass tick would otherwise burn roughly |
| +// $14/mo (2 months × 24 ticks/day × 30 days) pulling data that, most hours, |
| +// hasn't changed since the last pull. shouldSkipCEPull enforces "at most |
| +// once per UTC day" for both months, plus the day-3-of-next-month |
| +// settle-and-stop rule for closedMonth. |
| +func (s *Server) pullBillMonth(ctx context.Context, month string, start, end, now time.Time, closedMonth bool) error { |
| + existing, err := s.getBill(ctx, month) |
| + if err != nil { |
| + return fmt.Errorf("read existing bill: %w", err) |
| + } |
| + if shouldSkipCEPull(existing, closedMonth, now) { |
| + return nil |
| + } |
| + |
| costs, err := s.resolveCECosts(ctx, start, end) |
| if err != nil { |
| return fmt.Errorf("cost explorer: %w", err) |
| @@ -633,11 +671,50 @@ func (s *Server) pullBillMonth(ctx context.Context, month string, start, end, pu |
| TotalUSD: total, |
| Services: costs, |
| Source: economics.SourceCostExplorer, |
| - PulledAt: pulledAt, |
| + PulledAt: now, |
| } |
| return s.upsertBill(ctx, bill, true) |
| } |
| +// shouldSkipCEPull decides whether pullBillMonth should skip calling Cost |
| +// Explorer THIS tick, given the bill currently stored for the month (nil if |
| +// none) — see pullBillMonth's doc comment for why this exists at all. Only |
| +// ever consults a SourceCostExplorer bill: a SourceManual bill is a |
| +// different caller's business (upsertBill's manualWins check handles it at |
| +// write time), not a reason to skip a pull here. |
| +func shouldSkipCEPull(existing *economics.Bill, closedMonth bool, now time.Time) bool { |
| + if existing == nil || existing.Source != economics.SourceCostExplorer { |
| + return false |
| + } |
| + if closedMonth && existing.PulledAt.Day() >= billCEDayGrace { |
| + // Settled: a closed month pulled on or after day 3 of the |
| + // following month has already seen CE's late-arriving line items |
| + // land. Its numbers don't move again — re-pulling it forever |
| + // would be pure waste, unlike the open month, which keeps |
| + // changing all month. |
| + return true |
| + } |
| + return usage.DayOf(existing.PulledAt) == usage.DayOf(now) |
| +} |
| + |
| +// getBill reads and parses the stored bill for month, or (nil, nil) if none |
| +// exists yet — the "no bill pulled yet" state loadRecentBills |
| +// (economicsview.go) also treats as normal, not an error. |
| +func (s *Server) getBill(ctx context.Context, month string) (*economics.Bill, error) { |
| + raw, _, err := s.cfg.Releases.Get(ctx, economics.BillKey(month)) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + return nil, err |
| + } |
| + var b economics.Bill |
| + if jerr := json.Unmarshal(raw, &b); jerr != nil { |
| + return nil, jerr |
| + } |
| + return &b, nil |
| +} |
| + |
| // upsertBill is one CAS read-etag-then-put attempt at economics.BillKey(bill |
| // .Month) — appconfig's applyAt pattern (internal/appconfig/store.go:162-190), |
| // one attempt, no retry loop: a lost race surfaces as release.ErrCASConflict |
| @@ -770,12 +847,22 @@ func (s *Server) putActiveScenario(ctx context.Context, name string) error { |
| } |
| // handleEconomicsScenarioSave writes a named pricing scenario (and, |
| -// optionally, activates it). Gate is handleFleet's exact shape — session -> |
| -// IsOperator -> a bare http.NotFound byte-identical with an unknown route — |
| -// BEFORE the CSRF check, per the console security rule: role existence must |
| -// never be probeable, and a CSRF rejection would leak that this route |
| -// exists to a non-operator who merely guessed the URL. |
| +// optionally, activates it). Registered without a method prefix |
| +// (server.go), so the FIRST check is r.Method — a GET (or anything but |
| +// POST) is byte-identical to an unknown route, never a 405 + Allow, which |
| +// would leak that this path exists to someone who merely guessed it. Method |
| +// check comes before EVERYTHING else, including the session gate: a |
| +// wrong-method request must be indistinguishable from an unknown route |
| +// regardless of who's asking. Past that, gate is handleFleet's exact shape |
| +// — session -> IsOperator -> a bare http.NotFound byte-identical with an |
| +// unknown route — BEFORE the CSRF check, per the console security rule: |
| +// role existence must never be probeable, and a CSRF rejection would leak |
| +// that this route exists to a non-operator who merely guessed the URL. |
| func (s *Server) handleEconomicsScenarioSave(w http.ResponseWriter, r *http.Request) { |
| + if r.Method != http.MethodPost { |
| + http.NotFound(w, r) |
| + return |
| + } |
| addr, raw, ok := s.session(r) |
| if !ok { |
| http.NotFound(w, r) |
| @@ -873,8 +960,13 @@ func parseBillLines(text string) (map[string]float64, error) { |
| // handleEconomicsBillSave writes a manually-entered bill for one month — |
| // SourceManual, always overwriting whatever is there (upsertBill's |
| -// manualWins=false). Same gate shape as handleEconomicsScenarioSave. |
| +// manualWins=false). Same gate shape as handleEconomicsScenarioSave, |
| +// including the method check before everything else. |
| func (s *Server) handleEconomicsBillSave(w http.ResponseWriter, r *http.Request) { |
| + if r.Method != http.MethodPost { |
| + http.NotFound(w, r) |
| + return |
| + } |
| addr, raw, ok := s.session(r) |
| if !ok { |
| http.NotFound(w, r) |
| @@ -936,12 +1028,20 @@ func (s *Server) handleEconomicsBillSave(w http.ResponseWriter, r *http.Request) |
| } |
| // handleAPIEconomicsBill is the CLI's manual-bill door (issue: `carlos |
| -// economics bill`, Task 15). Gate mirrors the console form's exactly, just |
| -// through the bearer/cliUser identity instead of a session: cliUser -> |
| -// IsOperator -> a bare http.NotFound byte-identical with an unknown API |
| -// route, BEFORE the body is even read — same role-not-probeable reasoning |
| -// as handleEconomicsScenarioSave. |
| +// economics bill`, Task 15). Registered without a method prefix |
| +// (server.go); the method check comes first, same reasoning as |
| +// handleEconomicsScenarioSave — a GET here (no Authorization header) must |
| +// not reach cliUser's 401, which would itself confirm the route exists. |
| +// Past that, gate mirrors the console form's exactly, just through the |
| +// bearer/cliUser identity instead of a session: cliUser -> IsOperator -> a |
| +// bare http.NotFound byte-identical with an unknown API route, BEFORE the |
| +// body is even read — same role-not-probeable reasoning as |
| +// handleEconomicsScenarioSave. |
| func (s *Server) handleAPIEconomicsBill(w http.ResponseWriter, r *http.Request) { |
| + if r.Method != http.MethodPost { |
| + http.NotFound(w, r) |
| + return |
| + } |
| addr, ok := s.cliUser(w, r) |
| if !ok { |
| return |
diff --git a/internal/console/server.go b/internal/console/server.go| index abf5fa5e2..d102a600e 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -381,10 +381,15 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("GET /fleet/metrics", s.handleFleetMetrics) |
| // Unit economics (Task 12): scenario/bill writes, operator-only, same |
| // gate shape as handleFleet above. GET /fleet/economics itself is |
| - // Task 13. |
| - s.mux.HandleFunc("POST /fleet/economics/scenarios", s.handleEconomicsScenarioSave) |
| - s.mux.HandleFunc("POST /fleet/economics/bill", s.handleEconomicsBillSave) |
| - s.mux.HandleFunc("POST /api/economics/bill", s.handleAPIEconomicsBill) |
| + // Task 13. Registered WITHOUT a method prefix, deliberately: a |
| + // "POST /path" pattern answers a GET with 405 + Allow, which leaks that |
| + // the route exists to someone who merely guessed the URL — the exact |
| + // probe handleFleet's 404-to-everyone gate exists to prevent. Each |
| + // handler below checks r.Method itself, before anything else, so a |
| + // wrong-method request is byte-identical to an unknown route. |
| + s.mux.HandleFunc("/fleet/economics/scenarios", s.handleEconomicsScenarioSave) |
| + s.mux.HandleFunc("/fleet/economics/bill", s.handleEconomicsBillSave) |
| + s.mux.HandleFunc("/api/economics/bill", s.handleAPIEconomicsBill) |
| // CLI identity: the device-code login `carlos auth login` drives, the |
| // approval page, and the tokens an owner can see and kill (authcli.go). |
| s.registerCLIRoutes() |