console: CE bill pull with manual override, scenario/bill write doors
- billPass (economicspass.go): pulls current + previous month via a ceCosts seam, wired into economicsPass after statementsPass. The real seam (cepull.go) builds a Cost Explorer client lazily, once per process; construction or query failures (AccessDenied being the expected shape) log once and latch the seam off — manual mode, matching the spec's "never pulled = manual entry, not an error" posture. - upsertBill: one-attempt CAS read-etag-then-put (appconfig's applyAt shape), manual-wins for the automatic pull, always-overwrites for the manual write doors. - POST /fleet/economics/scenarios, POST /fleet/economics/bill, POST /api/economics/bill: operator-only writes. Gate order is session/cliUser -> IsOperator -> a bare http.NotFound byte-identical with an unknown route, BEFORE CSRF (handleFleet's shape) — role existence must never be probeable. - economics.Bill.PulledAt: omitempty -> omitzero, so a manual bill (which never sets it) no longer marshals a lying 0001-01-01T00:00:00Z pulled_at key (Task 3 review carry-over). go.mod: github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5. 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>
8 files changed,
+746
−7
go.mod+1 −0go.sum+2 −5internal/console/cepull.go+108 −0internal/console/economicsbill_test.go+219 −0internal/console/economicspass.go+362 −1internal/console/server.go+21 −0internal/economics/bill.go+5 −1internal/economics/bill_test.go+28 −0
diff --git a/go.mod b/go.mod| index 7ed1528cc..6773eada6 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -6,6 +6,7 @@ require ( |
| github.com/aws/aws-sdk-go-v2 v1.43.5 |
| github.com/aws/aws-sdk-go-v2/config v1.32.6 |
| github.com/aws/aws-sdk-go-v2/credentials v1.19.6 |
| + github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5 |
| github.com/aws/aws-sdk-go-v2/service/iam v1.58.2 |
| github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1 |
| github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 |
diff --git a/go.sum b/go.sum| index 0a0183997..256893dee 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -38,6 +38,8 @@ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEG |
| github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= |
| github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ= |
| github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc= |
| +github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5 h1:hiJJTfio1+i/xnfLwu6RsDjDzkGSiLux54LTzDK4MyA= |
| +github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5/go.mod h1:gy1JJMsby9XLbrwMhJcMu0IuMJF4DvDe4pD1wqMmwkk= |
| github.com/aws/aws-sdk-go-v2/service/iam v1.58.2 h1:/6iRcqrC6k1rMA6uCZMzFE9inOrBpNmhbrZ90X8qH50= |
| github.com/aws/aws-sdk-go-v2/service/iam v1.58.2/go.mod h1:Wm74PIQWDrV2tGPFYtwLqh8jZ7JU/rnq/leiC6UI4dg= |
| github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= |
| @@ -103,7 +105,6 @@ github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh |
| github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc= |
| github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= |
| github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= |
| -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= |
| github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE= |
| github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE= |
| github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= |
| @@ -120,7 +121,6 @@ github.com/markusmobius/go-dateparser v1.2.4 h1:2e8XJozaERVxGwsRg72coi51L2aiYqE2 |
| github.com/markusmobius/go-dateparser v1.2.4/go.mod h1:CBAUADJuMNhJpyM6IYaWAoFhtKaqnUcznY2cL7gNugY= |
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| -github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= |
| github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= |
| github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= |
| github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= |
| @@ -182,7 +182,6 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6 |
| go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= |
| golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= |
| golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= |
| -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= |
| golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= |
| @@ -213,8 +212,6 @@ google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= |
| google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= |
| google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= |
| google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= |
| -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= |
| -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= |
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
| modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= |
diff --git a/internal/console/cepull.go b/internal/console/cepull.go| new file mode 100644 |
| index 000000000..80caf522b |
| --- /dev/null |
| +++ b/internal/console/cepull.go |
| @@ -0,0 +1,108 @@ |
| +// Cost Explorer: the real-AWS half of the billPass seam (economicspass.go). |
| +// A deployment with no billing IAM grant, or one whose AWS config simply |
| +// doesn't resolve (no box role, no ~/.aws/credentials — plenty of dev/test |
| +// 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. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "log" |
| + "strconv" |
| + "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" |
| +) |
| + |
| +// 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 |
| +// 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. |
| +func (s *Server) resolveCECosts(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| + if s.ceCosts != nil { |
| + return s.ceCosts(ctx, start, end) |
| + } |
| + return s.realCECosts(ctx, start, end) |
| +} |
| + |
| +// 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. |
| +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") |
| + } |
| + s.ceClientOnce.Do(func() { |
| + cfg, err := awsconfig.LoadDefaultConfig(ctx) |
| + if err != nil { |
| + s.disableCE(fmt.Errorf("console: cost explorer: load aws config: %w", err)) |
| + return |
| + } |
| + s.ceClient = costexplorer.NewFromConfig(cfg) |
| + }) |
| + if s.ceDisabled { |
| + return nil, fmt.Errorf("console: cost explorer disabled for this process") |
| + } |
| + |
| + out, err := s.ceClient.GetCostAndUsage(ctx, &costexplorer.GetCostAndUsageInput{ |
| + Granularity: cetypes.GranularityMonthly, |
| + Metrics: []string{"UnblendedCost"}, |
| + GroupBy: []cetypes.GroupDefinition{ |
| + {Type: cetypes.GroupDefinitionTypeDimension, Key: aws.String("SERVICE")}, |
| + }, |
| + TimePeriod: &cetypes.DateInterval{ |
| + Start: aws.String(start.Format("2006-01-02")), |
| + End: aws.String(end.Format("2006-01-02")), |
| + }, |
| + }) |
| + if err != nil { |
| + s.disableCE(fmt.Errorf("console: cost explorer: get cost and usage: %w", err)) |
| + return nil, err |
| + } |
| + |
| + costs := map[string]float64{} |
| + for _, res := range out.ResultsByTime { |
| + for _, g := range res.Groups { |
| + if len(g.Keys) == 0 { |
| + continue |
| + } |
| + mv, ok := g.Metrics["UnblendedCost"] |
| + if !ok || mv.Amount == nil { |
| + continue |
| + } |
| + amt, perr := strconv.ParseFloat(*mv.Amount, 64) |
| + if perr != nil { |
| + log.Printf("console: cost explorer: %s: unparseable amount %q: %v", g.Keys[0], *mv.Amount, perr) |
| + continue |
| + } |
| + costs[g.Keys[0]] += amt |
| + } |
| + } |
| + return costs, nil |
| +} |
| + |
| +// 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. |
| +func (s *Server) disableCE(err error) { |
| + s.ceDisabled = true |
| + s.ceWarnOnce.Do(func() { |
| + log.Printf("%v — bill pulls disabled for this process; manual bill entry only", err) |
| + }) |
| +} |
diff --git a/internal/console/economicsbill_test.go b/internal/console/economicsbill_test.go| new file mode 100644 |
| index 000000000..342d1026e |
| --- /dev/null |
| +++ b/internal/console/economicsbill_test.go |
| @@ -0,0 +1,219 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/economics" |
| +) |
| + |
| +// TestBillPassPullsAndManualWins: billPass pulls current + previous month |
| +// through the ceCosts seam and writes each SourceCostExplorer, then a |
| +// manually-entered bill for the current month (written directly, the way |
| +// the write doors this task adds do it) survives a second billPass run |
| +// byte-identical — manual always wins over an automatic pull. |
| +func TestBillPassPullsAndManualWins(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Date(2026, 8, 11, 3, 0, 0, 0, time.UTC) |
| + |
| + fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| + return map[string]float64{"AmazonEC2": 55.5}, nil |
| + } |
| + |
| + if err := fx.srv.billPass(ctx, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + getBill := func(month string) economics.Bill { |
| + t.Helper() |
| + raw, _, err := fx.rel.Get(ctx, economics.BillKey(month)) |
| + if err != nil { |
| + t.Fatalf("bill %s: %v", month, err) |
| + } |
| + var b economics.Bill |
| + if err := json.Unmarshal(raw, &b); err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| + } |
| + |
| + for _, month := range []string{"2026-08", "2026-07"} { |
| + b := getBill(month) |
| + if b.Source != economics.SourceCostExplorer { |
| + t.Fatalf("%s source = %q, want %q", month, b.Source, economics.SourceCostExplorer) |
| + } |
| + if b.TotalUSD != 55.5 { |
| + t.Fatalf("%s total = %v, want 55.5", month, b.TotalUSD) |
| + } |
| + if b.Services["AmazonEC2"] != 55.5 { |
| + t.Fatalf("%s services = %v, want AmazonEC2=55.5", month, b.Services) |
| + } |
| + } |
| + |
| + // Write a manual bill for the current month straight to the bucket |
| + // (read-etag-then-put, since the object already exists from the pull |
| + // above), then confirm a second billPass run leaves it byte-identical. |
| + _, etag, err := fx.rel.Get(ctx, economics.BillKey("2026-08")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + manual := economics.Bill{Schema: 1, Month: "2026-08", TotalUSD: 999, Source: economics.SourceManual} |
| + manualRaw, err := json.Marshal(manual) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.rel.Put(ctx, economics.BillKey("2026-08"), manualRaw, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if err := fx.srv.billPass(ctx, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, _, err := fx.rel.Get(ctx, economics.BillKey("2026-08")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if string(after) != string(manualRaw) { |
| + t.Fatalf("manual bill overwritten by billPass:\n got %s\n want %s", after, manualRaw) |
| + } |
| +} |
| + |
| +// TestBillPassNoCEIsQuiet: a ceCosts failure (no IAM grant, network down, |
| +// whatever) degrades billPass to a logged no-op — never a fatal error, and |
| +// never a written bill for either month. |
| +func TestBillPassNoCEIsQuiet(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Date(2026, 8, 11, 3, 0, 0, 0, time.UTC) |
| + |
| + fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| + return nil, errors.New("AccessDeniedException: user is not authorized") |
| + } |
| + |
| + if err := fx.srv.billPass(ctx, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, month := range []string{"2026-08", "2026-07"} { |
| + if _, _, err := fx.rel.Get(ctx, economics.BillKey(month)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("bill %s written despite a ceCosts failure: err=%v", month, err) |
| + } |
| + } |
| +} |
| + |
| +// TestScenarioSaveGateAndValidation pins the gate order (session -> |
| +// IsOperator -> byte-identical 404, matching TestFleetOperatorOnly's |
| +// comparison shape) BEFORE csrf, then bad-JSON and happy-path behaviour. |
| +func TestScenarioSaveGateAndValidation(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + |
| + out := newBrowser(t) |
| + _, _, bodyGhost := get(t, out, fx.ts.URL+"/this-route-does-not-exist") |
| + |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + sc := economics.DefaultScenario() |
| + scRaw, err := json.Marshal(sc) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Signed in, owns kass, but is not an operator: the same 404 a missing |
| + // route gets. |
| + form := url.Values{"csrf": {csrf}, "name": {"launch"}, "scenario": {string(scRaw)}} |
| + if status, _, body := postForm(t, c, fx.ts.URL+"/fleet/economics/scenarios", form); status != http.StatusNotFound || body != bodyGhost { |
| + t.Fatalf("non-operator scenario save = %d %q, want the same 404 as a missing route (%q)", status, body, bodyGhost) |
| + } |
| + |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Bad JSON: a 4xx, and nothing gets written. |
| + badForm := url.Values{"csrf": {csrf}, "name": {"launch"}, "scenario": {"{not valid json"}} |
| + if status, _, _ := postForm(t, c, fx.ts.URL+"/fleet/economics/scenarios", badForm); status < 400 || status >= 500 { |
| + t.Fatalf("bad scenario JSON = %d, want 4xx", status) |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), economics.ScenarioKey("launch")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("a bad-JSON scenario save still wrote a scenario object") |
| + } |
| + |
| + // Valid scenario + activate=1: both objects land, Active.Name is right. |
| + okForm := url.Values{"csrf": {csrf}, "name": {"launch"}, "scenario": {string(scRaw)}, "activate": {"1"}} |
| + status, loc, _ := postForm(t, c, fx.ts.URL+"/fleet/economics/scenarios", okForm) |
| + if status != http.StatusSeeOther || loc != "/fleet/economics" { |
| + t.Fatalf("valid scenario save = %d -> %q, want 303 -> /fleet/economics", status, loc) |
| + } |
| + raw, _, err := fx.rel.Get(context.Background(), economics.ScenarioKey("launch")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var saved economics.Scenario |
| + if err := json.Unmarshal(raw, &saved); err != nil { |
| + t.Fatal(err) |
| + } |
| + if saved.Name != "launch" { |
| + t.Fatalf("saved scenario name = %q, want %q (form name must win over the JSON body's)", saved.Name, "launch") |
| + } |
| + activeRaw, _, err := fx.rel.Get(context.Background(), economics.ActiveKey) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var active economics.Active |
| + if err := json.Unmarshal(activeRaw, &active); err != nil { |
| + t.Fatal(err) |
| + } |
| + if active.Name != "launch" { |
| + t.Fatalf("active.Name = %q, want %q", active.Name, "launch") |
| + } |
| +} |
| + |
| +// TestAPIBillDoor: the CLI's bearer door mirrors the console form's gate |
| +// order (cliUser -> IsOperator -> byte-identical 404) and manual-write |
| +// semantics. |
| +func TestAPIBillDoor(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-apibill") |
| + |
| + body := `{"month":"2026-08","total_usd":123.45,"services":{"AmazonEC2":55.0}}` |
| + |
| + ghostStatus, ghostBody := postBody(t, fx.ts.URL+"/this-api-route-does-not-exist", token, body) |
| + status, out := postBody(t, fx.ts.URL+"/api/economics/bill", token, body) |
| + if status != http.StatusNotFound || status != ghostStatus || string(out) != string(ghostBody) { |
| + t.Fatalf("non-operator api bill = %d %q, want the same 404 as a missing route (%d %q)", status, out, ghostStatus, ghostBody) |
| + } |
| + |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + status, out = postBody(t, fx.ts.URL+"/api/economics/bill", token, body) |
| + if status != http.StatusOK { |
| + t.Fatalf("operator api bill = %d %s, want 200", status, out) |
| + } |
| + var b economics.Bill |
| + if err := json.Unmarshal(out, &b); err != nil { |
| + t.Fatalf("bill response: %v (%s)", err, out) |
| + } |
| + if b.Month != "2026-08" || b.TotalUSD != 123.45 || b.Source != economics.SourceManual { |
| + t.Fatalf("bill response = %+v", b) |
| + } |
| + |
| + raw, _, err := fx.rel.Get(context.Background(), economics.BillKey("2026-08")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var stored economics.Bill |
| + if err := json.Unmarshal(raw, &stored); err != nil { |
| + t.Fatal(err) |
| + } |
| + if stored.Source != economics.SourceManual || stored.TotalUSD != 123.45 || stored.Services["AmazonEC2"] != 55.0 { |
| + t.Fatalf("stored bill = %+v", stored) |
| + } |
| +} |
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go| index 5d599d346..ef2b14211 100644 |
| --- a/internal/console/economicspass.go |
| +++ b/internal/console/economicspass.go |
| @@ -2,10 +2,14 @@ package console |
| import ( |
| "context" |
| + "crypto/subtle" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "log" |
| + "net/http" |
| "os" |
| + "strconv" |
| "strings" |
| "time" |
| @@ -60,7 +64,9 @@ func (s *Server) economicsPass(ctx context.Context, now time.Time) error { |
| if err := s.statementsPass(ctx, now); err != nil { |
| log.Printf("console: statements pass: %v", err) |
| } |
| - // Task 12 adds: s.billPass(ctx, now) |
| + if err := s.billPass(ctx, now); err != nil { |
| + log.Printf("console: bill pass: %v", err) |
| + } |
| return nil |
| } |
| @@ -582,6 +588,93 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error { |
| return nil |
| } |
| +// 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 |
| +// down) is degraded state, not a pass failure — 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 bill |
| +// object that simply isn't there yet). Each month's pull/write is |
| +// independent — a failure on one must not skip the other. |
| +func (s *Server) billPass(ctx context.Context, now time.Time) error { |
| + nowUTC := now.UTC() |
| + 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 |
| + }{ |
| + {usage.MonthOf(nowUTC), firstOfThisMonth, nowUTC}, |
| + {usage.MonthOf(firstOfLastMonth), firstOfLastMonth, firstOfThisMonth}, |
| + } |
| + for _, p := range periods { |
| + if err := s.pullBillMonth(ctx, p.month, p.start, p.end, nowUTC); err != nil { |
| + log.Printf("console: bill pass %s: %v", p.month, err) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// 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 { |
| + costs, err := s.resolveCECosts(ctx, start, end) |
| + if err != nil { |
| + return fmt.Errorf("cost explorer: %w", err) |
| + } |
| + var total float64 |
| + for _, v := range costs { |
| + total += v |
| + } |
| + bill := economics.Bill{ |
| + Schema: 1, |
| + Month: month, |
| + TotalUSD: total, |
| + Services: costs, |
| + Source: economics.SourceCostExplorer, |
| + PulledAt: pulledAt, |
| + } |
| + return s.upsertBill(ctx, bill, true) |
| +} |
| + |
| +// 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 |
| +// to the caller, who decides what "try again" means for their context (a |
| +// background pass logs and waits for its next tick; a write door turns it |
| +// into a flash message). When manualWins is true (billPass's own calls, |
| +// via pullBillMonth) an existing SourceManual bill is left untouched — |
| +// manual entry always beats an automatic pull. The manual write doors |
| +// (handleEconomicsBillSave, handleAPIEconomicsBill) call this with |
| +// manualWins=false: manual entry always overwrites whatever is there, |
| +// a previous CE pull or an earlier manual entry alike. |
| +func (s *Server) upsertBill(ctx context.Context, bill economics.Bill, manualWins bool) error { |
| + key := economics.BillKey(bill.Month) |
| + raw, etag, err := s.cfg.Releases.Get(ctx, key) |
| + switch { |
| + case err == nil: |
| + if manualWins { |
| + var existing economics.Bill |
| + if jerr := json.Unmarshal(raw, &existing); jerr == nil && existing.Source == economics.SourceManual { |
| + return nil |
| + } |
| + } |
| + case errors.Is(err, os.ErrNotExist): |
| + etag = "" |
| + default: |
| + return fmt.Errorf("console: bill %s: read: %w", key, err) |
| + } |
| + body, err := json.Marshal(bill) |
| + if err != nil { |
| + return fmt.Errorf("console: bill %s: marshal: %w", key, err) |
| + } |
| + if _, err := s.cfg.Releases.Put(ctx, key, body, etag); err != nil { |
| + return fmt.Errorf("console: bill %s: write: %w", key, err) |
| + } |
| + return nil |
| +} |
| + |
| // sizeStore routes through the test seam or the real S3 sizer. |
| func (s *Server) sizeStore(ctx context.Context, p *objstore.Provisioned) (int64, error) { |
| if s.storeSizer != nil { |
| @@ -627,3 +720,271 @@ func sizeProvisionedStore(ctx context.Context, p *objstore.Provisioned) (int64, |
| } |
| return total, nil |
| } |
| + |
| +// ---- unit economics: scenario/bill write doors ----------------------------- |
| + |
| +// putScenario CAS-writes economics.ScenarioKey(sc.Name) — appconfig's |
| +// applyAt read-etag-then-put shape (internal/appconfig/store.go:162-190), |
| +// one attempt: a lost race surfaces release.ErrCASConflict to the caller |
| +// (handleEconomicsScenarioSave), which turns it into a retryable flash |
| +// error rather than silently overwriting or retrying blind. |
| +func (s *Server) putScenario(ctx context.Context, sc economics.Scenario) error { |
| + key := economics.ScenarioKey(sc.Name) |
| + _, etag, err := s.cfg.Releases.Get(ctx, key) |
| + switch { |
| + case err == nil: |
| + case errors.Is(err, os.ErrNotExist): |
| + etag = "" |
| + default: |
| + return fmt.Errorf("console: scenario %s: read: %w", key, err) |
| + } |
| + body, err := json.Marshal(sc) |
| + if err != nil { |
| + return fmt.Errorf("console: scenario %s: marshal: %w", key, err) |
| + } |
| + if _, err := s.cfg.Releases.Put(ctx, key, body, etag); err != nil { |
| + return fmt.Errorf("console: scenario %s: write: %w", key, err) |
| + } |
| + return nil |
| +} |
| + |
| +// putActiveScenario CAS-writes economics.ActiveKey to name it — same |
| +// one-attempt shape as putScenario, same ErrCASConflict contract. |
| +func (s *Server) putActiveScenario(ctx context.Context, name string) error { |
| + _, etag, err := s.cfg.Releases.Get(ctx, economics.ActiveKey) |
| + switch { |
| + case err == nil: |
| + case errors.Is(err, os.ErrNotExist): |
| + etag = "" |
| + default: |
| + return fmt.Errorf("console: active scenario: read: %w", err) |
| + } |
| + body, err := json.Marshal(economics.Active{Schema: 1, Name: name}) |
| + if err != nil { |
| + return fmt.Errorf("console: active scenario: marshal: %w", err) |
| + } |
| + if _, err := s.cfg.Releases.Put(ctx, economics.ActiveKey, body, etag); err != nil { |
| + return fmt.Errorf("console: active scenario: write: %w", err) |
| + } |
| + return nil |
| +} |
| + |
| +// 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. |
| +func (s *Server) handleEconomicsScenarioSave(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, ok := s.session(r) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + isOp, err := s.store.IsOperator(addr) |
| + if err != nil { |
| + log.Printf("console: economics scenario save: is-operator: %v", err) |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !isOp { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + |
| + name := strings.TrimSpace(r.FormValue("name")) |
| + var sc economics.Scenario |
| + if err := json.Unmarshal([]byte(r.FormValue("scenario")), &sc); err != nil { |
| + s.renderMessage(w, r, http.StatusBadRequest, "Invalid scenario", "Could not parse the scenario JSON: "+err.Error()) |
| + return |
| + } |
| + // The form's name field wins over whatever the JSON body carries — the |
| + // name is the bucket key (ScenarioKey), so it must come from the one |
| + // place a CSRF-protected form field lives, not from a value a client |
| + // could otherwise mismatch against the URL a link points at. |
| + sc.Name = name |
| + if err := sc.Validate(); err != nil { |
| + s.renderMessage(w, r, http.StatusBadRequest, "Invalid scenario", err.Error()) |
| + return |
| + } |
| + |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/fleet/economics", http.StatusSeeOther) |
| + } |
| + |
| + if err := s.putScenario(r.Context(), sc); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + back("err", "Someone else saved a scenario — try again.") |
| + return |
| + } |
| + log.Printf("console: economics scenario save: %v", err) |
| + s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not save the scenario. Please try again.") |
| + return |
| + } |
| + |
| + if r.FormValue("activate") == "1" { |
| + if err := s.putActiveScenario(r.Context(), name); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + back("err", "Scenario saved, but someone else changed the active scenario — try again.") |
| + return |
| + } |
| + log.Printf("console: economics scenario save: activate %s: %v", name, err) |
| + back("err", "Scenario saved, but could not activate it. Please try again.") |
| + return |
| + } |
| + } |
| + |
| + back("ok", "Saved scenario "+name+".") |
| +} |
| + |
| +// parseBillLines parses the manual-bill form's "lines" textarea: one |
| +// "Service=123.45" per line, blank lines skipped. A malformed line is a |
| +// hard error naming the offending line — silently dropping it would |
| +// understate the bill with no visible sign, the same reasoning |
| +// applyAt's ValidVarValue check documents for config values. |
| +func parseBillLines(text string) (map[string]float64, error) { |
| + services := map[string]float64{} |
| + for _, line := range strings.Split(text, "\n") { |
| + line = strings.TrimSpace(line) |
| + if line == "" { |
| + continue |
| + } |
| + name, amtStr, ok := strings.Cut(line, "=") |
| + if !ok { |
| + return nil, fmt.Errorf("line %q: want Service=123.45", line) |
| + } |
| + name = strings.TrimSpace(name) |
| + if name == "" { |
| + return nil, fmt.Errorf("line %q: empty service name", line) |
| + } |
| + amt, err := strconv.ParseFloat(strings.TrimSpace(amtStr), 64) |
| + if err != nil { |
| + return nil, fmt.Errorf("line %q: %w", line, err) |
| + } |
| + services[name] = amt |
| + } |
| + return services, nil |
| +} |
| + |
| +// handleEconomicsBillSave writes a manually-entered bill for one month — |
| +// SourceManual, always overwriting whatever is there (upsertBill's |
| +// manualWins=false). Same gate shape as handleEconomicsScenarioSave. |
| +func (s *Server) handleEconomicsBillSave(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, ok := s.session(r) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + isOp, err := s.store.IsOperator(addr) |
| + if err != nil { |
| + log.Printf("console: economics bill save: is-operator: %v", err) |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !isOp { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/fleet/economics", http.StatusSeeOther) |
| + } |
| + |
| + month := strings.TrimSpace(r.FormValue("month")) |
| + if _, err := time.Parse("2006-01", month); err != nil { |
| + s.renderMessage(w, r, http.StatusBadRequest, "Invalid bill", "Month must be formatted YYYY-MM.") |
| + return |
| + } |
| + total, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("total")), 64) |
| + if err != nil { |
| + s.renderMessage(w, r, http.StatusBadRequest, "Invalid bill", "Total must be a number.") |
| + return |
| + } |
| + services, err := parseBillLines(r.FormValue("lines")) |
| + if err != nil { |
| + s.renderMessage(w, r, http.StatusBadRequest, "Invalid bill", err.Error()) |
| + return |
| + } |
| + |
| + bill := economics.Bill{ |
| + Schema: 1, |
| + Month: month, |
| + TotalUSD: total, |
| + Services: services, |
| + Source: economics.SourceManual, |
| + } |
| + if err := s.upsertBill(r.Context(), bill, false); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + back("err", "Someone else saved a bill for this month — try again.") |
| + return |
| + } |
| + log.Printf("console: economics bill save: %v", err) |
| + s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not save the bill. Please try again.") |
| + return |
| + } |
| + back("ok", "Saved the "+month+" bill.") |
| +} |
| + |
| +// 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. |
| +func (s *Server) handleAPIEconomicsBill(w http.ResponseWriter, r *http.Request) { |
| + addr, ok := s.cliUser(w, r) |
| + if !ok { |
| + return |
| + } |
| + isOp, err := s.store.IsOperator(addr) |
| + if err != nil { |
| + log.Printf("console: api economics bill: is-operator: %v", err) |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !isOp { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + |
| + var req struct { |
| + Month string `json:"month"` |
| + TotalUSD float64 `json:"total_usd"` |
| + Services map[string]float64 `json:"services"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + if _, err := time.Parse("2006-01", req.Month); err != nil { |
| + jsonError(w, http.StatusBadRequest, "month must be formatted YYYY-MM") |
| + return |
| + } |
| + |
| + bill := economics.Bill{ |
| + Schema: 1, |
| + Month: req.Month, |
| + TotalUSD: req.TotalUSD, |
| + Services: req.Services, |
| + Source: economics.SourceManual, |
| + } |
| + if err := s.upsertBill(r.Context(), bill, false); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + jsonError(w, http.StatusConflict, "someone else saved a bill for this month — try again") |
| + return |
| + } |
| + log.Printf("console: api economics bill: %v", err) |
| + jsonError(w, http.StatusInternalServerError, "could not save the bill") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, bill) |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 3ad810ba6..abf5fa5e2 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -20,8 +20,11 @@ import ( |
| "os" |
| "regexp" |
| "strings" |
| + "sync" |
| "time" |
| + "github.com/aws/aws-sdk-go-v2/service/costexplorer" |
| + |
| "github.com/carlosframework/platform/internal/applog" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/objstore" |
| @@ -278,6 +281,18 @@ type Server struct { |
| // (sizeProvisionedStore, economicspass.go); tests inject a fake so a |
| // sweep can be proven to degrade ("unswept") without live AWS. |
| storeSizer func(ctx context.Context, p *objstore.Provisioned) (int64, error) |
| + // ceCosts is billPass's test/fake seam for Cost Explorer (cepull.go, |
| + // economicspass.go): nil means "use the real, lazily-built CE client" |
| + // (s.realCECosts); tests inject a fake so a pull can be proven to |
| + // succeed, or to degrade quietly, without live AWS billing IAM. |
| + ceCosts func(ctx context.Context, start, end time.Time) (map[string]float64, error) |
| + // ceClientOnce/ceClient/ceWarnOnce/ceDisabled cache s.realCECosts' |
| + // Cost Explorer client and its process-lifetime disabled state — see |
| + // cepull.go's doc comment for why a mutex isn't needed here. |
| + ceClientOnce sync.Once |
| + ceClient *costexplorer.Client |
| + ceWarnOnce sync.Once |
| + ceDisabled bool |
| // shipSem bounds how many `carlos ship` uploads (apicli.go, |
| // handleAPIShip) may run AT ONCE, as opposed to s.cli.ship, which |
| // bounds them over TIME. A rate limiter is a counter; it says nothing |
| @@ -364,6 +379,12 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("POST /auth/signout", s.handleSignout) |
| s.mux.HandleFunc("GET /fleet", s.handleFleet) |
| 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) |
| // 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() |
diff --git a/internal/economics/bill.go b/internal/economics/bill.go| index 5c37914f4..506dd7aaa 100644 |
| --- a/internal/economics/bill.go |
| +++ b/internal/economics/bill.go |
| @@ -17,7 +17,11 @@ type Bill struct { |
| TotalUSD float64 `json:"total_usd"` |
| Services map[string]float64 `json:"services"` |
| Source string `json:"source"` // SourceManual | SourceCostExplorer |
| - PulledAt time.Time `json:"pulled_at,omitempty"` |
| + // PulledAt uses omitzero, not omitempty: encoding/json's omitempty never |
| + // treats a zero time.Time as empty (it isn't a nil/0/"" by the encoder's |
| + // own rule), so a manually-entered bill — which never sets this field — |
| + // would otherwise marshal a lying "0001-01-01T00:00:00Z" pulled_at key. |
| + PulledAt time.Time `json:"pulled_at,omitzero"` |
| } |
| // BillsPrefix lists stored bills. |
diff --git a/internal/economics/bill_test.go b/internal/economics/bill_test.go| new file mode 100644 |
| index 000000000..bf505ab6e |
| --- /dev/null |
| +++ b/internal/economics/bill_test.go |
| @@ -0,0 +1,28 @@ |
| +package economics |
| + |
| +import ( |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestManualBillMarshalsWithoutPulledAt: a SourceManual bill never sets |
| +// PulledAt, and encoding/json's omitempty does NOT treat a zero time.Time as |
| +// empty — omitzero does. A manual bill that marshalled a pulled_at key would |
| +// lie: "0001-01-01T00:00:00Z" reads as a real pull that never happened. |
| +func TestManualBillMarshalsWithoutPulledAt(t *testing.T) { |
| + b := Bill{ |
| + Schema: 1, |
| + Month: "2026-08", |
| + TotalUSD: 42.5, |
| + Services: map[string]float64{"AmazonEC2": 42.5}, |
| + Source: SourceManual, |
| + } |
| + raw, err := json.Marshal(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if strings.Contains(string(raw), "pulled_at") { |
| + t.Fatalf("manual bill marshalled a pulled_at key: %s", raw) |
| + } |
| +} |