Files
compute/service/gate_test.go
zeekayandHanzo Dev 2881cba813 compute: a cluster, a node pool and a scale-up provision only after the org is authorized
POST /v1/k8s/clusters provisioned on Hanzo's HOUSE DigitalOcean account with no
balance check of any kind. Any authenticated org could ask for a cluster whose
seed pool was eight H100s, get it, and be billed nothing — the cluster was never
even recorded in the node_pool table the hourly meter reads, so no sweep would
find it later. That is the leak: we pay the upstream, forever, for compute
nobody was charged for. Same shape on /v1/create-node-pool and
/v1/scale-node-pool, which provisioned first and then billed at a CostPerHour
that was never computed at create.

All three now run the gate the droplet launch already ran: price the resource
from the resale catalog, authorize the org for its FULL first interval (rate ×
node count, never a token amount), and only then touch the upstream. A size that
cannot be priced refuses just as loudly as an org that cannot be authorized —
one control does not cover the other, and both are needed, because an unpriced
GPU authorized at $0 passes any balance check ever written.

A scale-up is a provision, so it authorizes the ADDED nodes. A scale-down is
not, and is never gated: refusing to shrink a pool because a balance is low
would keep the meter running on nodes the customer asked to release.

The pool row now carries the resolved rate, its org and its project, so the
hourly sweep bills the real number instead of falling back, and the create path
debits the first hour — which the sweep skips, so that hour is billed once.

The cluster path is split so the money gate is observable: createClusterMetered
takes a clusterCreator, and the tests assert against a fake DigitalOcean that
records what it was asked to build. Unfunded, 401 on a rotated service token,
500, timeout, and an unpriceable size each produce zero create requests and zero
debits; a funded org still gets its four-node H100 cluster and is billed 12712
cents for it. All five refusals were confirmed to FAIL when the gate is mutated
to allow-on-error or to fall back to a zero price, and reverting both mutations
together reproduces the original leak exactly: the cluster provisions for free.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 02:41:19 -07:00

360 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/digitalocean/godo"
)
// ---- fake commerce, by verdict ----
// commerceMood is how the fake commerce answers a balance read. Each value is a
// real production failure: the org is broke, the KMS-synced service token was
// rotated out from under us, commerce is having a bad day, commerce is wedged.
type commerceMood int
const (
funded commerceMood = iota
unfunded
unauthorized // 401 — absent or rotated COMMERCE_SERVICE_TOKEN
broken // 500
hanging // no answer before the client's own timeout
)
// commerceOf stands up a fake commerce in one mood and points the metering
// client at it. It counts balance reads and usage writes, so a test can assert
// not only that a provision was refused but that nothing was billed for it.
func commerceOf(t *testing.T, mood commerceMood, availableCents int64) (debits *int, mu *sync.Mutex) {
t.Helper()
var n int
var m sync.Mutex
mux := http.NewServeMux()
mux.HandleFunc("/v1/billing/balance", func(w http.ResponseWriter, r *http.Request) {
switch mood {
case unauthorized:
w.WriteHeader(http.StatusUnauthorized)
case broken:
w.WriteHeader(http.StatusInternalServerError)
case hanging:
// Outlast the client's 5s timeout without ever answering.
<-r.Context().Done()
case unfunded:
_ = json.NewEncoder(w).Encode(map[string]any{"available": 0, "currency": "usd"})
default:
_ = json.NewEncoder(w).Encode(map[string]any{"available": availableCents, "currency": "usd"})
}
})
// Funded callers go on to the scope-cap check; it fails open by contract, so
// an unrouted 404 here is a legitimate "no cap configured".
mux.HandleFunc("/v1/billing/usage", func(w http.ResponseWriter, r *http.Request) {
m.Lock()
n++
m.Unlock()
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"transactionId":"tx","type":"usage"}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
t.Setenv("COMMERCE_URL", srv.URL)
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-token")
return &n, &m
}
// ---- the price half of the gate ----
// A GPU slug in the catalog resolves to its resale rate. This is the headline:
// the price table that used to answer this question had 32 hardcoded droplet
// slugs and not one GPU, so every GPU resource priced at zero.
func TestHourlyCents_ResolvesGPU(t *testing.T) {
seedCatalog(t, SizeInfo{Slug: "gpu-h100x8-640gb", PriceHourly: 31.7724, Currency: "USD",
GPU: &GPUSpec{Count: 8, Model: "H100"}})
cents, err := HourlyCents("gpu-h100x8-640gb")
if err != nil {
t.Fatalf("a catalog GPU size must price, got %v", err)
}
if cents != 3178 { // ceil(3177.24)
t.Fatalf("HourlyCents = %d, want 3178", cents)
}
}
// An unresolvable price is an ERROR, never a zero. Both shapes count: a slug the
// catalog does not carry, and a slug the catalog prices at zero. Billing either
// one as free is how an H100 runs for nothing.
func TestHourlyCents_RefusesRatherThanZero(t *testing.T) {
seedCatalog(t, SizeInfo{Slug: "zero", PriceHourly: 0, Currency: "USD"})
for _, slug := range []string{"gpu-h100x8-640gb", "zero", ""} {
cents, err := HourlyCents(slug)
if err == nil {
t.Fatalf("HourlyCents(%q) must refuse, got %d cents", slug, cents)
}
if !errors.Is(err, ErrPriceUnavailable) {
t.Fatalf("HourlyCents(%q) error must be ErrPriceUnavailable, got %v", slug, err)
}
if cents != 0 {
t.Fatalf("HourlyCents(%q) must not return a usable price alongside a refusal", slug)
}
}
}
// ---- the balance half of the gate ----
// Every way commerce can fail to say YES is a refusal. This is the mutation
// target: flip AuthorizeCompute to allow-on-error and the four failure moods
// stop refusing, so this test fails.
func TestAuthorizeCompute_FailsClosed(t *testing.T) {
for name, mood := range map[string]commerceMood{
"unfunded": unfunded,
"401 rotated": unauthorized,
"500 upstream": broken,
"timeout": hanging,
} {
t.Run(name, func(t *testing.T) {
commerceOf(t, mood, 0)
if err := AuthorizeCompute(context.Background(), "acme", "", 3178); err == nil {
t.Fatal("commerce did not say yes, so the gate must refuse")
}
})
}
}
// A funded org is NOT refused. A gate that denies everyone is not fail-closed,
// it is broken, and this is the test that tells the two apart.
func TestAuthorizeCompute_AllowsFundedOrg(t *testing.T) {
commerceOf(t, funded, 100000)
if err := AuthorizeCompute(context.Background(), "acme", "", 3178); err != nil {
t.Fatalf("a funded org must be authorized, got %v", err)
}
}
// The gate is for the FULL first interval, not a token amount: a balance that
// covers one node of an eight-GPU pool does not buy the pool.
func TestAuthorizeCompute_GatesTheWholeAmount(t *testing.T) {
commerceOf(t, funded, 3178) // exactly one node-hour
if err := AuthorizeCompute(context.Background(), "acme", "", 3178); err != nil {
t.Fatalf("balance covers one hour, must be allowed: %v", err)
}
if err := AuthorizeCompute(context.Background(), "acme", "", 3178*4); err == nil {
t.Fatal("balance covers one hour but four were requested — must refuse")
}
}
// A zero charge is never authorized. Without this, an unresolved price that
// slipped past HourlyCents would reach the gate as "authorize $0", which every
// balance on earth can afford.
func TestAuthorizeCompute_RefusesZeroCharge(t *testing.T) {
commerceOf(t, funded, 100000)
if err := AuthorizeCompute(context.Background(), "acme", "", 0); err == nil {
t.Fatal("a zero charge must be refused, not waved through")
}
}
// ---- the composition: refused means NOTHING was provisioned ----
// The whole point of a pre-provision gate is that a refusal costs nothing. For
// every way commerce can fail, and for an unpriceable size, the upstream must
// never receive a create request — asserted against the fake DigitalOcean, which
// records the create it was asked for.
func TestCreateClusterMetered_ProvisionsNothingWhenRefused(t *testing.T) {
spec := &CreateClusterSpec{Name: "acme-gpu", Region: "nyc3",
NodePool: CreateClusterNodePool{Size: "gpu-h100x8-640gb", Count: 4}}
cases := map[string]struct {
mood commerceMood
size string
}{
"unfunded": {unfunded, "gpu-h100x8-640gb"},
"401 rotated": {unauthorized, "gpu-h100x8-640gb"},
"500 upstream": {broken, "gpu-h100x8-640gb"},
"timeout": {hanging, "gpu-h100x8-640gb"},
"price unresolved": {funded, "gpu-h200x8-1128gb"}, // funded, but not in the catalog
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
seedCatalog(t, SizeInfo{Slug: "gpu-h100x8-640gb", PriceHourly: 31.7724, Currency: "USD"})
debits, mu := commerceOf(t, c.mood, 0)
do := &doksTestServer{clusters: map[string]*godo.KubernetesCluster{}}
client := newDOKSTestClient(t, do)
s := *spec
s.NodePool.Size = c.size
cluster, err := createClusterMetered(context.Background(), client, "acme", "", &s)
if err == nil {
t.Fatal("the provision must be refused")
}
if cluster != nil {
t.Fatalf("a refused provision must return no cluster, got %+v", cluster)
}
if do.created != nil {
t.Fatalf("REFUSED BUT PROVISIONED: upstream received %+v", do.created)
}
mu.Lock()
defer mu.Unlock()
if *debits != 0 {
t.Fatalf("a refused provision must bill nothing, got %d debits", *debits)
}
})
}
}
// The legit path is not broken by the gate: a funded org gets its GPU cluster,
// the upstream receives exactly the requested pool, and the org is billed the
// REAL rate for all four nodes — not zero.
func TestCreateClusterMetered_FundedOrgLaunchesAndPaysTheRealRate(t *testing.T) {
seedCatalog(t, SizeInfo{Slug: "gpu-h100x8-640gb", PriceHourly: 31.7724, Currency: "USD"})
debits, mu := commerceOf(t, funded, 1000000)
do := &doksTestServer{clusters: map[string]*godo.KubernetesCluster{}}
client := newDOKSTestClient(t, do)
cluster, err := createClusterMetered(context.Background(), client, "acme", "", &CreateClusterSpec{
Name: "acme-gpu", Region: "nyc3",
NodePool: CreateClusterNodePool{Size: "gpu-h100x8-640gb", Count: 4},
})
if err != nil {
t.Fatalf("a funded org must be able to launch: %v", err)
}
if cluster == nil || do.created == nil {
t.Fatal("the funded path must actually provision")
}
if do.created.NodePools[0].Count != 4 || do.created.NodePools[0].Size != "gpu-h100x8-640gb" {
t.Fatalf("upstream got the wrong pool: %+v", do.created.NodePools[0])
}
if !hasTag(do.created.Tags, "hanzo-org:acme") {
t.Fatalf("the ownership tag must reach upstream: %v", do.created.Tags)
}
mu.Lock()
defer mu.Unlock()
if *debits != 1 {
t.Fatalf("the launch hour must be billed exactly once, got %d debits", *debits)
}
}
// The amount billed is the whole seed pool's hour (rate × nodes), and the count
// floor the upstream request applies is applied to the charge too — so the
// quantity authorized is always the quantity provisioned.
func TestCreateClusterMetered_BillsEveryNodeAndFloorsTheCount(t *testing.T) {
seedCatalog(t, SizeInfo{Slug: "gpu-h100x8-640gb", PriceHourly: 31.7724, Currency: "USD"})
for name, tc := range map[string]struct{ ask, want int }{
"four nodes": {4, 4},
"zero floors to 1": {0, 1},
} {
t.Run(name, func(t *testing.T) {
var billed int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/balance") {
_ = json.NewEncoder(w).Encode(map[string]any{"available": 10000000, "currency": "usd"})
return
}
if strings.HasSuffix(r.URL.Path, "/usage") {
var u struct {
Amount int64 `json:"amount"`
Provider string `json:"provider"`
Model string `json:"model"`
}
_ = json.NewDecoder(r.Body).Decode(&u)
billed = u.Amount
if u.Provider != "compute" || u.Model != "gpu-h100x8-640gb" {
t.Errorf("debit must name the compute plane and the size, got provider=%q model=%q", u.Provider, u.Model)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"transactionId":"tx","type":"usage"}`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(srv.Close)
t.Setenv("COMMERCE_URL", srv.URL)
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-token")
do := &doksTestServer{clusters: map[string]*godo.KubernetesCluster{}}
if _, err := createClusterMetered(context.Background(), newDOKSTestClient(t, do), "acme", "",
&CreateClusterSpec{Name: "g", Region: "nyc3",
NodePool: CreateClusterNodePool{Size: "gpu-h100x8-640gb", Count: tc.ask}},
); err != nil {
t.Fatalf("funded launch: %v", err)
}
if want := int64(3178 * tc.want); billed != want {
t.Fatalf("billed %d cents, want %d (3178 × %d nodes)", billed, want, tc.want)
}
if do.created.NodePools[0].Count != tc.want {
t.Fatalf("provisioned %d nodes but authorized %d — the two must agree",
do.created.NodePools[0].Count, tc.want)
}
})
}
}
// ---- the sweep gate ----
// An absent service token means no sweep can debit, and saying so is the whole
// fix: the silent early-return this replaces let every running machine go
// unbilled with no log, no metric, and a healthy-looking service.
func TestBillable_IsFalseAndLoudWithoutAToken(t *testing.T) {
t.Setenv("COMMERCE_SERVICE_TOKEN", "")
if Billable(context.Background(), "compute.hourly") {
t.Fatal("no service token means the sweep cannot debit")
}
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-token")
if !Billable(context.Background(), "compute.hourly") {
t.Fatal("a wired token must let the sweep run")
}
}
// The compute gate reads PREPAID balance, not the tier-aware effective balance
// that folds in promotional plan allotment: a free-tier grant buys inference, not
// an H100. Asserted on the wire — tier-aware would read /v1/billing/tier.
func TestAuthorizeCompute_ReadsPrepaidNotPromotionalCredit(t *testing.T) {
var paths []string
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
paths = append(paths, r.URL.Path)
mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{"available": 100000, "currency": "usd"})
}))
t.Cleanup(srv.Close)
t.Setenv("COMMERCE_URL", srv.URL)
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-token")
if err := AuthorizeCompute(context.Background(), "acme", "", 3178); err != nil {
t.Fatalf("funded: %v", err)
}
mu.Lock()
defer mu.Unlock()
for _, p := range paths {
if strings.Contains(p, "/tier") {
t.Fatalf("compute must gate on prepaid balance, but it read %s", p)
}
}
if len(paths) == 0 || !strings.Contains(paths[0], "/balance") {
t.Fatalf("expected a prepaid balance read first, got %v", paths)
}
}