mirror of
https://github.com/hanzoai/compute.git
synced 2026-10-30 14:50:41 +00:00
dollarStringToCents was one of three hand-written copies of this
conversion across vm, commerce and ai. They read the same DigitalOcean
fields and disagreed: "1,234.56" was 123456 in commerce and 0 here;
"-5.00" was -500 in ai and 0 here. Three answers means at least two are
wrong and nothing in the code says which, so all three now call
money.ParseCents — the module that already holds exact decimals and the
FromMinor/FromCents constructors this is the inverse of.
Three behaviours change, each of them a way the old converter lost money
without saying so:
- a thousands separator no longer zeroes the month. DO writes one past
$1000, ParseFloat refused it, and the fallback was 0 — so the
largest bills read as no spend at all.
- `f <= 0 → 0` is gone. A negative row is a credit or a refund;
clamping it to zero overstates spend by exactly the amount returned
to us, on every account that ever got one.
- an unreadable value is an error, not a 0. Zero is a legal amount, so
a caller could not tell "the vendor billed nothing" from "we could
not read what the vendor billed", and reported the second as the
first. Every caller here already returned (int64, error) and simply
was not being given one.
The GCP cell reader keeps its null case as an honest zero — a SUM over
no rows really is no spend — but its float64 branch now goes through the
same converter via the value's shortest exact decimal form, instead of a
second *100 that rounded differently from the string path. Which answer
you got depended on how BigQuery happened to encode the cell.
Co-authored-by: Hanzo Dev <[email protected]>
164 lines
6.0 KiB
Go
164 lines
6.0 KiB
Go
// Copyright 2025 Hanzo Industries Inc. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/money"
|
|
"golang.org/x/oauth2"
|
|
"golang.org/x/oauth2/google"
|
|
)
|
|
|
|
// bigQueryReadScope is the OAuth scope needed to run the billing-export query.
|
|
const bigQueryReadScope = "https://www.googleapis.com/auth/bigquery.readonly"
|
|
|
|
// gcpCostReader reads a BYOC GCP account's spend from its BigQuery BILLING EXPORT
|
|
// table — the canonical way GCP exposes spend (Cloud Billing has no "current spend"
|
|
// API). GCP therefore needs two things the machine-management path does not: a
|
|
// service-account JSON key with BigQuery read access (stored in Provider.
|
|
// ClientSecret) and the export table reference "<project>.<dataset>.<table>" (in
|
|
// Provider.CostReadScope). Missing either yields ErrCostUnavailable — the honest
|
|
// "not wired" skip, never a fabricated figure. The query is issued via the BigQuery
|
|
// REST jobs.query endpoint with an authorized client, so no heavy BigQuery client
|
|
// dependency is pulled in.
|
|
type gcpCostReader struct {
|
|
saJSON string
|
|
project string
|
|
table string // fully-qualified "project.dataset.table"
|
|
}
|
|
|
|
// splitGCPExport parses Provider.CostReadScope "project.dataset.table" into the
|
|
// billing project (which runs the query job) and the fully-qualified table.
|
|
func splitGCPExport(s string) (project, fqTable string) {
|
|
s = strings.TrimSpace(s)
|
|
parts := strings.Split(s, ".")
|
|
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
|
|
return "", ""
|
|
}
|
|
return parts[0], s
|
|
}
|
|
|
|
func newGCPCostReader(clientSecret, costScope string) (CloudCostReader, error) {
|
|
saJSON := strings.TrimSpace(clientSecret)
|
|
project, table := splitGCPExport(costScope)
|
|
if saJSON == "" || project == "" || table == "" {
|
|
return nil, ErrCostUnavailable
|
|
}
|
|
return &gcpCostReader{saJSON: saJSON, project: project, table: table}, nil
|
|
}
|
|
|
|
// bqQueryResponse is the subset of the BigQuery jobs.query response we read: the
|
|
// completion flag and the single SUM(cost) cell (BigQuery returns cell values as
|
|
// strings under "v").
|
|
type bqQueryResponse struct {
|
|
JobComplete bool `json:"jobComplete"`
|
|
Rows []struct {
|
|
F []struct {
|
|
V any `json:"v"`
|
|
} `json:"f"`
|
|
} `json:"rows"`
|
|
}
|
|
|
|
// MonthToDateCents sums the export table's cost for the current invoice month. The
|
|
// export's invoice.month ("YYYYMM") is the canonical month grouping.
|
|
func (r *gcpCostReader) MonthToDateCents(ctx context.Context, now time.Time) (int64, error) {
|
|
creds, err := google.CredentialsFromJSON(ctx, []byte(r.saJSON), bigQueryReadScope)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("gcp cost: parse service-account credentials: %w", err)
|
|
}
|
|
client := oauth2.NewClient(ctx, creds.TokenSource)
|
|
client.Timeout = 30 * time.Second
|
|
|
|
// month is derived from time (always "YYYYMM"); the table is operator-configured.
|
|
// Neither is caller-controlled, so the interpolation carries no injection surface.
|
|
month := now.UTC().Format("200601")
|
|
query := fmt.Sprintf("SELECT SUM(cost) AS c FROM `%s` WHERE invoice.month = '%s'", r.table, month)
|
|
|
|
reqBody, err := json.Marshal(map[string]any{
|
|
"query": query,
|
|
"useLegacySql": false,
|
|
"timeoutMs": 30000,
|
|
})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("gcp cost: encode query: %w", err)
|
|
}
|
|
|
|
url := fmt.Sprintf("https://bigquery.googleapis.com/bigquery/v2/projects/%s/queries", r.project)
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("gcp cost: build request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := client.Do(httpReq)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("gcp cost: bigquery query: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode/100 != 2 {
|
|
return 0, fmt.Errorf("gcp cost: bigquery status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
var qr bqQueryResponse
|
|
if err := json.Unmarshal(body, &qr); err != nil {
|
|
return 0, fmt.Errorf("gcp cost: decode response: %w", err)
|
|
}
|
|
if !qr.JobComplete {
|
|
return 0, fmt.Errorf("gcp cost: bigquery job did not complete within timeout (will retry next run)")
|
|
}
|
|
if len(qr.Rows) == 0 || len(qr.Rows[0].F) == 0 {
|
|
return 0, nil // no rows this month → no spend
|
|
}
|
|
return gcpCellToCents(qr.Rows[0].F[0].V)
|
|
}
|
|
|
|
// gcpCellToCents converts a BigQuery result cell into cents. BigQuery sends numeric
|
|
// columns as decimal STRINGS, and null when the SUM matched no rows.
|
|
//
|
|
// A null is a real answer — no rows means no spend — so it is 0 with no error. A
|
|
// value that is present but unreadable is not: it returns an error rather than the
|
|
// same 0, because a caller that cannot tell "GCP billed nothing" from "we could not
|
|
// read what GCP billed" reports the second as the first and the spend disappears.
|
|
func gcpCellToCents(v any) (int64, error) {
|
|
switch x := v.(type) {
|
|
case nil:
|
|
return 0, nil
|
|
case string:
|
|
cents, err := money.ParseCents(x)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("gcp cost: cell %q: %w", x, err)
|
|
}
|
|
return cents, nil
|
|
case float64:
|
|
// JSON numbers decode here. The float is already an approximation, so it
|
|
// goes through its shortest exact decimal form and the SAME converter —
|
|
// never a second *100, which would round differently from the string path
|
|
// and make the answer depend on how BigQuery happened to encode the cell.
|
|
return money.ParseCents(strconv.FormatFloat(x, 'f', -1, 64))
|
|
default:
|
|
return 0, fmt.Errorf("gcp cost: unexpected cell type %T", v)
|
|
}
|
|
}
|