mirror of
https://github.com/hanzoai/compute.git
synced 2026-10-30 14:50:41 +00:00
881 lines
32 KiB
Go
881 lines
32 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 (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/compute/service/commercetest"
|
|
)
|
|
|
|
func TestOrgFromTag(t *testing.T) {
|
|
cases := []struct {
|
|
tags string
|
|
want string
|
|
}{
|
|
{"hanzo-org:acme,", "acme"},
|
|
{"foo,hanzo-org:maxpower,bar", "maxpower"},
|
|
{" hanzo-org:acme ", "acme"},
|
|
{"foo,bar", ""}, // no org tag -> unattributable
|
|
{"", ""}, // empty
|
|
{"hanzo-orgx:acme", ""}, // must be the exact key, not a prefix collision
|
|
}
|
|
for _, c := range cases {
|
|
if got := orgFromTag(c.tags); got != c.want {
|
|
t.Fatalf("orgFromTag(%q) = %q, want %q", c.tags, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProjectFromTag(t *testing.T) {
|
|
cases := []struct {
|
|
tags string
|
|
want string
|
|
}{
|
|
{"hanzo-org:acme,hanzo-project:web,", "web"},
|
|
{"foo,hanzo-project:api,bar", "api"},
|
|
{" hanzo-project:web ", "web"},
|
|
{"hanzo-org:acme,", ""}, // org only -> default project
|
|
{"", ""}, // empty -> default project
|
|
{"hanzo-projectx:web", ""}, // exact key, not a prefix collision
|
|
}
|
|
for _, c := range cases {
|
|
if got := projectFromTag(c.tags); got != c.want {
|
|
t.Fatalf("projectFromTag(%q) = %q, want %q", c.tags, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMeterActor(t *testing.T) {
|
|
// Empty project is the default project: the actor stays the bare org, so
|
|
// threading project through the existing debits is a no-op for callers that do
|
|
// not set X-Project-Id (backward-compatible). A named project yields org/project.
|
|
if got := MeterActor("acme", ""); got != "acme" {
|
|
t.Fatalf("MeterActor(acme, \"\") = %q, want acme (default project == today's behavior)", got)
|
|
}
|
|
if got := MeterActor("acme", "web"); got != "acme/web" {
|
|
t.Fatalf("MeterActor(acme, web) = %q, want acme/web", got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeProject(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"", ""},
|
|
{" ", ""},
|
|
{"web", "web"},
|
|
{" web ", "web"},
|
|
{"bad,project", ""}, // separators can't survive tag/meter read-back -> default
|
|
{"bad:project", ""},
|
|
{"has space", ""},
|
|
}
|
|
for _, c := range cases {
|
|
if got := NormalizeProject(c.in); got != c.want {
|
|
t.Fatalf("NormalizeProject(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A running machine carrying a project tag attributes its debit to the project,
|
|
// while the debit destination (the org named in the body and X-Org-Id) stays the
|
|
// org — one org balance covers all its projects.
|
|
func TestMeterMachines_AttributesProjectViaActor(t *testing.T) {
|
|
recs, mu := fakeCommerce(t)
|
|
seedCatalog(t, priced("s", 5))
|
|
|
|
now := time.Date(2026, 7, 2, 15, 30, 0, 0, time.UTC)
|
|
machines := []*Machine{{Id: "p1", Size: "s", Tag: "hanzo-org:acme,hanzo-project:web,"}}
|
|
|
|
metered, _ := meterMachines(context.Background(), machines, now)
|
|
if metered != 1 {
|
|
t.Fatalf("metered=%d, want 1", metered)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
r := (*recs)[0]
|
|
if r.org != "acme" { // debit destination stays the org
|
|
t.Fatalf("X-Org-Id = %q, want acme", r.org)
|
|
}
|
|
if r.usage.Org != "acme" {
|
|
t.Fatalf("debit org = %q, want acme (org is the billing key)", r.usage.Org)
|
|
}
|
|
if r.usage.Project != "web" {
|
|
t.Fatalf("project = %q, want web (per-project attribution)", r.usage.Project)
|
|
}
|
|
}
|
|
|
|
func TestHourStamp_IdempotencyBucket(t *testing.T) {
|
|
base := time.Date(2026, 7, 2, 15, 0, 0, 0, time.UTC)
|
|
// Same hour -> same stamp (debit dedups).
|
|
if a, b := HourStamp(base), HourStamp(base.Add(59*time.Minute)); a != b {
|
|
t.Fatalf("same-hour stamps differ: %q vs %q", a, b)
|
|
}
|
|
// Next hour -> different stamp (a new hour is a new billable unit).
|
|
if a, b := HourStamp(base), HourStamp(base.Add(time.Hour)); a == b {
|
|
t.Fatalf("cross-hour stamps equal: %q", a)
|
|
}
|
|
if got := HourStamp(base); got != "2026070215" {
|
|
t.Fatalf("hourStamp = %q, want 2026070215", got)
|
|
}
|
|
}
|
|
|
|
// recorded is one usage debit captured by the fake commerce.
|
|
type recorded struct {
|
|
org string
|
|
auth string
|
|
usage commercetest.Usage
|
|
}
|
|
|
|
// fakeCommerce records every usage POST; it never gates (a debit does not check
|
|
// balance).
|
|
func fakeCommerce(t *testing.T) (got *[]recorded, mu *sync.Mutex) {
|
|
t.Helper()
|
|
freshLedger(t)
|
|
var recs []recorded
|
|
var m sync.Mutex
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/v1/billing/usage", func(w http.ResponseWriter, r *http.Request) {
|
|
u := commercetest.Read(r)
|
|
m.Lock()
|
|
recs = append(recs, recorded{org: r.Header.Get("X-Org-Id"), auth: r.Header.Get("Authorization"), usage: u})
|
|
m.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"id":"`+u.ID+`","org":"`+u.Org+`","account":"`+u.Org+`","amount":{"decimal":"`+u.Amount.Decimal+`","currency":"usd"}}`)
|
|
})
|
|
commercetest.Serve(t, mux)
|
|
return &recs, &m
|
|
}
|
|
|
|
// freshLedger gives one test a ledger of its own, so no hour another test billed
|
|
// reads as billed here.
|
|
func freshLedger(t *testing.T) {
|
|
t.Helper()
|
|
saved := billed()
|
|
RegisterLedger(newMemoryLedger())
|
|
t.Cleanup(func() { RegisterLedger(saved) })
|
|
}
|
|
|
|
// seedCatalog replaces the catalog for one test, so a size prices at exactly
|
|
// what the test says.
|
|
func seedCatalog(t *testing.T, sale ...offer) {
|
|
t.Helper()
|
|
saved := offers
|
|
offers = sale
|
|
t.Cleanup(func() { offers = saved })
|
|
}
|
|
|
|
// priced is an offer whose Hanzo price is exactly cents per running hour: its
|
|
// list price is the cost that, with the public address and the fee, is that many
|
|
// cents, with no disk.
|
|
func priced(slug string, cents int64) offer {
|
|
return offer{slug: slug, listMicros: cents*microsPerCent*feeDen/feeNum - ipv4MicrosPerHour}
|
|
}
|
|
|
|
// A running machine debits its OWNING org one hour of its price, attributed
|
|
// product "compute" / model <size>, with an hour-bucketed idempotency RequestID.
|
|
func TestMeterMachines_DebitsRunningMachinePerOrg(t *testing.T) {
|
|
recs, mu := fakeCommerce(t)
|
|
seedCatalog(t, priced("m7i.large", 5))
|
|
|
|
now := time.Date(2026, 7, 2, 15, 30, 0, 0, time.UTC)
|
|
machines := []*Machine{{Id: "111", Size: "m7i.large", Tag: "hanzo-org:acme,"}}
|
|
|
|
metered, skipped := meterMachines(context.Background(), machines, now)
|
|
if metered != 1 || skipped != 0 {
|
|
t.Fatalf("metered=%d skipped=%d, want 1/0", metered, skipped)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(*recs) != 1 {
|
|
t.Fatalf("recorded %d debits, want 1", len(*recs))
|
|
}
|
|
r := (*recs)[0]
|
|
if r.org != "acme" {
|
|
t.Fatalf("X-Org-Id = %q, want acme", r.org)
|
|
}
|
|
u := r.usage
|
|
if u.Org != "acme" {
|
|
t.Fatalf("debit org = %q, want acme", u.Org)
|
|
}
|
|
if r.auth != "Bearer "+commercetest.Token {
|
|
t.Fatalf("debit presented %q, want visor's IAM token", r.auth)
|
|
}
|
|
if u.Service != "compute" {
|
|
t.Fatalf("service = %q, want compute", u.Service)
|
|
}
|
|
if u.Model != "m7i.large" {
|
|
t.Fatalf("model = %q, want m7i.large", u.Model)
|
|
}
|
|
if u.Amount.Decimal != "0.05" || u.Amount.Currency != "usd" || u.Cents() != 5 { // $0.05 -> 5 cents
|
|
t.Fatalf("amount = %+v, want 0.05 usd", u.Amount)
|
|
}
|
|
if u.ID != "compute-111-2026070215" {
|
|
t.Fatalf("id = %q, want compute-111-2026070215 (hour-bucketed idempotency)", u.ID)
|
|
}
|
|
}
|
|
|
|
// A second sweep in the SAME hour bills nothing — the ledger already holds the
|
|
// hour — and the NEXT hour is a new billable unit under a new id.
|
|
func TestMeterMachines_IdempotentWithinHour(t *testing.T) {
|
|
recs, mu := fakeCommerce(t)
|
|
seedCatalog(t, priced("t3.medium", 1))
|
|
|
|
m := []*Machine{{Id: "222", Size: "t3.medium", Tag: "hanzo-org:acme"}}
|
|
h15 := time.Date(2026, 7, 2, 15, 5, 0, 0, time.UTC)
|
|
meterMachines(context.Background(), m, h15)
|
|
meterMachines(context.Background(), m, h15.Add(50*time.Minute)) // same hour
|
|
meterMachines(context.Background(), m, h15.Add(time.Hour)) // next hour
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(*recs) != 2 {
|
|
t.Fatalf("recorded %d, want 2 — a second sweep in one hour charged it again", len(*recs))
|
|
}
|
|
if id1, id2 := (*recs)[0].usage.ID, (*recs)[1].usage.ID; id1 != "compute-222-2026070215" || id2 != "compute-222-2026070216" {
|
|
t.Fatalf("ids = %q, %q", id1, id2)
|
|
}
|
|
}
|
|
|
|
// Hours a sweep missed are billed by the next one, each once. A machine billed
|
|
// through 16:00 whose next sweep is at 20:10 — the ticker restarted, the
|
|
// provider was down, a tick failed — owes 17, 18, 19 and 20, and a second sweep
|
|
// in the same hour, or one after a restart that kept the ledger, owes nothing.
|
|
func TestMeterMachines_BillsMissedHoursOnce(t *testing.T) {
|
|
recs, mu := fakeCommerce(t)
|
|
seedCatalog(t, priced("s", 5))
|
|
|
|
launched := time.Date(2026, 7, 2, 15, 5, 0, 0, time.UTC)
|
|
m := []*Machine{{Id: "gap", Size: "s", Tag: "hanzo-org:acme", CreatedTime: launched.Format(time.RFC3339)}}
|
|
if id := LaunchCharge("gap", launched); id != "compute-gap-2026070215" {
|
|
t.Fatalf("launch charge = %q", id)
|
|
}
|
|
MarkBilled("gap", launched) // the launch's debit landed
|
|
meterMachines(context.Background(), m, launched.Add(time.Hour)) // 16:05
|
|
at := time.Date(2026, 7, 2, 20, 10, 0, 0, time.UTC)
|
|
if metered, _ := meterMachines(context.Background(), m, at); metered != 4 {
|
|
t.Fatalf("the sweep after a four-hour gap billed %d hours, want 4", metered)
|
|
}
|
|
if metered, _ := meterMachines(context.Background(), m, at.Add(30*time.Minute)); metered != 0 {
|
|
t.Fatalf("a second sweep in the same hour billed %d hours", metered)
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
var ids []string
|
|
for _, r := range *recs {
|
|
ids = append(ids, r.usage.ID)
|
|
}
|
|
want := []string{"compute-gap-2026070216", "compute-gap-2026070217", "compute-gap-2026070218", "compute-gap-2026070219", "compute-gap-2026070220"}
|
|
if !slices.Equal(ids, want) {
|
|
t.Fatalf("debits = %v, want %v — each hour once, the launch hour not again", ids, want)
|
|
}
|
|
}
|
|
|
|
// A machine owes nothing for the hours it was stopped: CreatedTime is its last
|
|
// start, and billing resumes there, not after the last hour it was billed.
|
|
func TestMeterMachines_OwesNothingForStoppedHours(t *testing.T) {
|
|
recs, mu := fakeCommerce(t)
|
|
seedCatalog(t, priced("s", 5))
|
|
|
|
MarkBilled("nap", time.Date(2026, 7, 2, 10, 5, 0, 0, time.UTC)) // the launch's debit landed
|
|
restarted := time.Date(2026, 7, 2, 14, 20, 0, 0, time.UTC)
|
|
if id := startCharge("nap", restarted); id != "compute-nap-2026070214" {
|
|
t.Fatalf("start charge = %q", id)
|
|
}
|
|
MarkBilled("nap", restarted) // and the start's
|
|
m := []*Machine{{Id: "nap", Size: "s", Tag: "hanzo-org:acme", CreatedTime: restarted.Format(time.RFC3339)}}
|
|
meterMachines(context.Background(), m, time.Date(2026, 7, 2, 15, 1, 0, 0, time.UTC))
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(*recs) != 1 || (*recs)[0].usage.ID != "compute-nap-2026070215" {
|
|
t.Fatalf("debits = %+v, want only hour 15", *recs)
|
|
}
|
|
}
|
|
|
|
// A start in an hour the launch already billed charges nothing, and a start in a
|
|
// later hour charges that hour once — once its debit has landed. A start whose
|
|
// debit did not land leaves its hour owed.
|
|
func TestAStartChargesOnlyAnUnbilledHour(t *testing.T) {
|
|
fakeCommerce(t)
|
|
at := time.Date(2026, 7, 2, 15, 5, 0, 0, time.UTC)
|
|
if id := LaunchCharge("twice", at); id != "compute-twice-2026070215" {
|
|
t.Fatalf("launch charge = %q", id)
|
|
}
|
|
if id := startCharge("twice", at); id == "" {
|
|
t.Fatal("an hour whose launch debit has not landed reads as billed")
|
|
}
|
|
MarkBilled("twice", at)
|
|
if id := startCharge("twice", at.Add(40*time.Minute)); id != "" {
|
|
t.Fatalf("a start in the launch hour charges %q", id)
|
|
}
|
|
later := at.Add(2 * time.Hour)
|
|
if id := startCharge("twice", later); id != "compute-twice-2026070217" {
|
|
t.Fatalf("a later start charges %q", id)
|
|
}
|
|
if id := startCharge("twice", later.Add(time.Minute)); id != "compute-twice-2026070217" {
|
|
t.Fatalf("a start whose earlier debit did not land names %q, want the same hour's id again", id)
|
|
}
|
|
MarkBilled("twice", later)
|
|
if id := startCharge("twice", later.Add(time.Minute)); id != "" {
|
|
t.Fatalf("a second start in a billed hour charges %q", id)
|
|
}
|
|
}
|
|
|
|
// The sweep must NOT re-bill the LAUNCH hour: the launch path already debited the
|
|
// machine one hour at create time. A machine created within the current sweep hour
|
|
// is skipped for that hour; the NEXT hour it is metered normally.
|
|
func TestMeterMachines_SkipsLaunchHour(t *testing.T) {
|
|
recs, mu := fakeCommerce(t)
|
|
seedCatalog(t, priced("s", 5))
|
|
|
|
// Launched at 15:05, its launch debit landed; the sweep fires later in the
|
|
// SAME clock hour (15:40).
|
|
launched := time.Date(2026, 7, 2, 15, 5, 0, 0, time.UTC)
|
|
m := []*Machine{{Id: "777", Size: "s", Tag: "hanzo-org:acme", CreatedTime: launched.Format(time.RFC3339)}}
|
|
MarkBilled("777", launched)
|
|
|
|
// Same hour as launch -> skipped (launch already billed hour 15).
|
|
metered, _ := meterMachines(context.Background(), m, launched.Add(35*time.Minute))
|
|
if metered != 0 {
|
|
t.Fatalf("launch-hour sweep metered=%d, want 0 (launch already billed this hour)", metered)
|
|
}
|
|
// Next hour -> billed once (the first RECURRING hour).
|
|
metered, _ = meterMachines(context.Background(), m, launched.Add(time.Hour))
|
|
if metered != 1 {
|
|
t.Fatalf("next-hour sweep metered=%d, want 1", metered)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(*recs) != 1 {
|
|
t.Fatalf("recorded %d debits, want 1 (launch hour skipped, next hour billed)", len(*recs))
|
|
}
|
|
if id := (*recs)[0].usage.ID; id != "compute-777-2026070216" {
|
|
t.Fatalf("id = %q, want compute-777-2026070216 (next hour)", id)
|
|
}
|
|
}
|
|
|
|
func TestCreatedInHour(t *testing.T) {
|
|
stamp := "2026070215"
|
|
if !CreatedInHour("2026-07-02T15:05:00Z", stamp) {
|
|
t.Fatal("same-hour create time must be in-hour")
|
|
}
|
|
if CreatedInHour("2026-07-02T14:59:00Z", stamp) {
|
|
t.Fatal("previous-hour create time must NOT be in-hour")
|
|
}
|
|
if CreatedInHour("2026-07-02T16:00:00Z", stamp) {
|
|
t.Fatal("next-hour create time must NOT be in-hour")
|
|
}
|
|
// Empty / unparseable -> never in-hour (metered normally, never wrongly skipped).
|
|
if CreatedInHour("", stamp) {
|
|
t.Fatal("empty create time must not skip metering")
|
|
}
|
|
if CreatedInHour("not-a-time", stamp) {
|
|
t.Fatal("unparseable create time must not skip metering")
|
|
}
|
|
}
|
|
|
|
// An untagged machine (no hanzo-org tag) is skipped, never billed to a wrong
|
|
// tenant; a machine whose price cannot be RESOLVED is skipped and counted as
|
|
// skipped, whether the slug is missing from the catalog or the catalog priced it
|
|
// at zero.
|
|
//
|
|
// The zero case is the one worth stating: the upstream publishes no $0 size, so a
|
|
// zero is an unresolved price wearing a free-tier costume. This used to `continue`
|
|
// without incrementing skipped — "a $0 size is free" — which meant a machine
|
|
// running on an unpriced slug was invisible in both the ledger AND the sweep
|
|
// counters. Now it is a loud skip, and the count says so.
|
|
func TestMeterMachines_SkipsUnattributableAndUnpriceable(t *testing.T) {
|
|
recs, mu := fakeCommerce(t)
|
|
seedCatalog(t,
|
|
priced("paid", 5),
|
|
priced("zero", 0),
|
|
)
|
|
|
|
now := time.Date(2026, 7, 2, 15, 0, 0, 0, time.UTC)
|
|
machines := []*Machine{
|
|
{Id: "1", Size: "paid", Tag: "foo,bar"}, // no org tag -> skip
|
|
{Id: "2", Size: "unknown", Tag: "hanzo-org:acme"}, // not in catalog -> skip
|
|
{Id: "3", Size: "zero", Tag: "hanzo-org:acme"}, // priced at zero -> skip, never a $0 debit
|
|
{Id: "4", Size: "paid", Tag: "hanzo-org:acme"}, // the only billable one
|
|
}
|
|
metered, skipped := meterMachines(context.Background(), machines, now)
|
|
if metered != 1 {
|
|
t.Fatalf("metered = %d, want 1 (only the paid+tagged machine)", metered)
|
|
}
|
|
if skipped != 3 { // untagged + not-in-catalog + priced-at-zero
|
|
t.Fatalf("skipped = %d, want 3", skipped)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(*recs) != 1 {
|
|
t.Fatalf("recorded %d debits, want 1", len(*recs))
|
|
}
|
|
if (*recs)[0].org != "acme" {
|
|
t.Fatalf("billed org %q, want acme", (*recs)[0].org)
|
|
}
|
|
}
|
|
|
|
// Two orgs' running machines are billed to their OWN ledgers — no cross-tenant
|
|
// fold in a mixed sweep.
|
|
func TestMeterMachines_TenantIsolation(t *testing.T) {
|
|
recs, mu := fakeCommerce(t)
|
|
seedCatalog(t, priced("s", 5))
|
|
|
|
now := time.Date(2026, 7, 2, 15, 0, 0, 0, time.UTC)
|
|
machines := []*Machine{
|
|
{Id: "a1", Size: "s", Tag: "hanzo-org:acme"},
|
|
{Id: "m1", Size: "s", Tag: "hanzo-org:maxpower"},
|
|
}
|
|
metered, _ := meterMachines(context.Background(), machines, now)
|
|
if metered != 2 {
|
|
t.Fatalf("metered = %d, want 2", metered)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
byOrg := map[string]int{}
|
|
for _, r := range *recs {
|
|
byOrg[r.org]++
|
|
}
|
|
if byOrg["acme"] != 1 || byOrg["maxpower"] != 1 {
|
|
t.Fatalf("per-org debits = %v, want one each for acme and maxpower", byOrg)
|
|
}
|
|
}
|
|
|
|
// No IAM identity makes the whole sweep a no-op: MeteringConfigured() is false,
|
|
// so MeterRunningMachines returns before enumerating or debiting anything — an
|
|
// unconfigured deployment is never billed or spammed with failed (401) debits.
|
|
func TestMetering_UnconfiguredIsNoop(t *testing.T) {
|
|
t.Setenv("iamEndpoint", "https://iam.example")
|
|
t.Setenv("clientId", "hanzo-visor")
|
|
t.Setenv("clientSecret", "")
|
|
if MeteringConfigured() {
|
|
t.Fatal("MeteringConfigured() = true with no client secret, want false")
|
|
}
|
|
t.Setenv("clientSecret", "shh")
|
|
if !MeteringConfigured() {
|
|
t.Fatal("MeteringConfigured() = false with a full identity, want true")
|
|
}
|
|
}
|
|
|
|
// A launch whose debit never landed recorded nothing, so the sweep bills its
|
|
// hour: the ledger holds only hours commerce charged.
|
|
func TestMeterMachines_BillsALaunchHourWhoseDebitFailed(t *testing.T) {
|
|
recs, mu := fakeCommerce(t)
|
|
seedCatalog(t, priced("s", 5))
|
|
launched := time.Date(2026, 7, 2, 15, 5, 0, 0, time.UTC)
|
|
LaunchCharge("lost", launched) // named, never debited
|
|
m := []*Machine{{Id: "lost", Size: "s", Tag: "hanzo-org:acme", CreatedTime: launched.Format(time.RFC3339)}}
|
|
meterMachines(context.Background(), m, launched.Add(30*time.Minute))
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(*recs) != 1 || (*recs)[0].usage.ID != "compute-lost-2026070215" {
|
|
t.Fatalf("debits = %+v, want the launch hour", *recs)
|
|
}
|
|
}
|
|
|
|
// fundedAt is a commerce whose orgs hold the balances given, which fall as
|
|
// debits land; an org absent from the map is unreadable.
|
|
func fundedAt(t *testing.T, balances map[string]int64) (debits func() []string) {
|
|
t.Helper()
|
|
freshLedger(t)
|
|
var mu sync.Mutex
|
|
var ids []string
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/v1/billing/balance", func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
org := r.Header.Get("X-Org-Id")
|
|
have, ok := balances[org]
|
|
if !ok {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
_, _ = io.WriteString(w, `{"available":`+strconv.FormatInt(have, 10)+`,"account":"`+org+`"}`)
|
|
})
|
|
mux.HandleFunc("/v1/billing/usage", func(w http.ResponseWriter, r *http.Request) {
|
|
u := commercetest.Read(r)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
ids = append(ids, u.ID)
|
|
if have, ok := balances[u.Org]; ok {
|
|
balances[u.Org] = have - u.Cents()
|
|
}
|
|
_, _ = io.WriteString(w, `{"id":"`+u.ID+`"}`)
|
|
})
|
|
commercetest.Serve(t, mux)
|
|
return func() []string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return append([]string(nil), ids...)
|
|
}
|
|
}
|
|
|
|
// stops records which machines the sweep stopped, instead of EC2.
|
|
func stops(t *testing.T) func() []string {
|
|
t.Helper()
|
|
var mu sync.Mutex
|
|
var got []string
|
|
saved := suspend
|
|
suspend = func(_ context.Context, org, id string) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
got = append(got, org+"/"+id)
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { suspend = saved })
|
|
return func() []string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return append([]string(nil), got...)
|
|
}
|
|
}
|
|
|
|
// The hour now starting is paid for as it starts. An org that cannot pay for it
|
|
// has the machine stopped and that hour is not charged; the hours the machine
|
|
// already ran are charged whatever the balance, and so is a stopped machine's
|
|
// disk. Within one org, the balance pays machines in turn until it cannot.
|
|
func TestAMachineItsOrgCannotPayForIsStopped(t *testing.T) {
|
|
debits := fundedAt(t, map[string]int64{"acme": 12, "beta": 0})
|
|
stopped := stops(t)
|
|
seedCatalog(t, offer{slug: "s", listMicros: 37_500 - ipv4MicrosPerHour, diskGB: 50})
|
|
|
|
at := func(h int) time.Time { return time.Date(2026, 7, 2, h, 10, 0, 0, time.UTC) }
|
|
started := at(12).Format(time.RFC3339)
|
|
for _, id := range []string{"a1", "a2", "b1"} {
|
|
MarkBilled(id, at(12))
|
|
}
|
|
MarkBilled("b2", at(12))
|
|
machines := []*Machine{
|
|
// Each owes 13, which ran, and 14, which is starting; 6c an hour.
|
|
{Id: "a1", Size: "s", Tag: "hanzo-org:acme", State: "Running", CreatedTime: started},
|
|
{Id: "a2", Size: "s", Tag: "hanzo-org:acme", State: "Running", CreatedTime: started},
|
|
{Id: "b1", Size: "s", Tag: "hanzo-org:beta", State: "Running", CreatedTime: started},
|
|
{Id: "b2", Size: "s", Tag: "hanzo-org:beta", State: "Stopped", CreatedTime: started},
|
|
}
|
|
meterMachines(context.Background(), machines, at(14))
|
|
|
|
// acme had 12: hours 13 of a1 and a2 take it to 0 — the past is charged —
|
|
// and neither can pay for 14. beta had nothing and pays its past too.
|
|
want := []string{
|
|
"compute-a1-2026070213", "compute-a2-2026070213", "compute-b1-2026070213",
|
|
"compute-b2-disk-2026070213", "compute-b2-disk-2026070214",
|
|
}
|
|
if got := debits(); !slices.Equal(got, want) {
|
|
t.Fatalf("debits = %v, want %v", got, want)
|
|
}
|
|
if got := stopped(); !slices.Equal(got, []string{"acme/a1", "acme/a2", "beta/b1"}) {
|
|
t.Fatalf("stopped = %v", got)
|
|
}
|
|
// Hour 14 was not charged, so it is not recorded as billed.
|
|
if mark, _ := billed().Through("a1"); mark.Hour != "2026070213" {
|
|
t.Fatalf("a1 is billed through %+v", mark)
|
|
}
|
|
}
|
|
|
|
// Enough for one machine's hour and not two: the first is charged, the second
|
|
// is stopped.
|
|
func TestABalancePaysMachinesInTurn(t *testing.T) {
|
|
debits := fundedAt(t, map[string]int64{"acme": 8})
|
|
stopped := stops(t)
|
|
seedCatalog(t, offer{slug: "s", listMicros: 37_500 - ipv4MicrosPerHour, diskGB: 50})
|
|
at := time.Date(2026, 7, 2, 14, 10, 0, 0, time.UTC)
|
|
started := at.Add(-2 * time.Hour).Format(time.RFC3339)
|
|
MarkBilled("x1", at.Add(-time.Hour))
|
|
MarkBilled("x2", at.Add(-time.Hour))
|
|
meterMachines(context.Background(), []*Machine{
|
|
{Id: "x1", Size: "s", Tag: "hanzo-org:acme", State: "Running", CreatedTime: started},
|
|
{Id: "x2", Size: "s", Tag: "hanzo-org:acme", State: "Running", CreatedTime: started},
|
|
}, at)
|
|
if got := debits(); !slices.Equal(got, []string{"compute-x1-2026070214"}) {
|
|
t.Fatalf("debits = %v", got)
|
|
}
|
|
if got := stopped(); !slices.Equal(got, []string{"acme/x2"}) {
|
|
t.Fatalf("stopped = %v", got)
|
|
}
|
|
}
|
|
|
|
// A balance that cannot be read stops nothing: the hour is charged, and the next
|
|
// sweep asks again.
|
|
func TestAnUnreadableBalanceStopsNothing(t *testing.T) {
|
|
debits := fundedAt(t, map[string]int64{})
|
|
stopped := stops(t)
|
|
seedCatalog(t, priced("s", 5))
|
|
at := time.Date(2026, 7, 2, 14, 10, 0, 0, time.UTC)
|
|
MarkBilled("u1", at.Add(-time.Hour))
|
|
meterMachines(context.Background(), []*Machine{
|
|
{Id: "u1", Size: "s", Tag: "hanzo-org:acme", State: "Running", CreatedTime: at.Add(-2 * time.Hour).Format(time.RFC3339)},
|
|
}, at)
|
|
if got := debits(); !slices.Equal(got, []string{"compute-u1-2026070214"}) {
|
|
t.Fatalf("debits = %v", got)
|
|
}
|
|
if got := stopped(); len(got) != 0 {
|
|
t.Fatalf("stopped = %v", got)
|
|
}
|
|
}
|
|
|
|
// sends stands in for CloudWatch: bytes by instance and hour.
|
|
func sends(t *testing.T, sent map[string]map[string]int64, fail error) {
|
|
t.Helper()
|
|
saved := outbound
|
|
outbound = func(_ context.Context, instances []string, from, to time.Time) (map[string]map[string]int64, error) {
|
|
if fail != nil {
|
|
return nil, fail
|
|
}
|
|
return sent, nil
|
|
}
|
|
t.Cleanup(func() { outbound = saved })
|
|
}
|
|
|
|
// answering is a commerce whose balance answers are set per org as the test goes:
|
|
// a balance, or a status other than 200. Debits land and are recorded.
|
|
func answering(t *testing.T) (set func(org string, cents int64, status int), debits func() []string) {
|
|
t.Helper()
|
|
freshLedger(t)
|
|
var mu sync.Mutex
|
|
type answer struct {
|
|
cents int64
|
|
status int
|
|
}
|
|
answers := map[string]answer{}
|
|
var ids []string
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/v1/billing/balance", func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
org := r.Header.Get("X-Org-Id")
|
|
if r.URL.Query().Get("user") != org {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
a, ok := answers[org]
|
|
if !ok {
|
|
a.status = http.StatusServiceUnavailable
|
|
}
|
|
if a.status != http.StatusOK {
|
|
w.WriteHeader(a.status)
|
|
return
|
|
}
|
|
_, _ = io.WriteString(w, `{"available":`+strconv.FormatInt(a.cents, 10)+`,"account":"`+org+`"}`)
|
|
})
|
|
mux.HandleFunc("/v1/billing/usage", func(w http.ResponseWriter, r *http.Request) {
|
|
u := commercetest.Read(r)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
ids = append(ids, u.ID)
|
|
if a, ok := answers[u.Org]; ok {
|
|
a.cents -= u.Cents()
|
|
answers[u.Org] = a
|
|
}
|
|
_, _ = io.WriteString(w, `{"id":"`+u.ID+`"}`)
|
|
})
|
|
commercetest.Serve(t, mux)
|
|
return func(org string, cents int64, status int) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
answers[org] = answer{cents: cents, status: status}
|
|
}, func() []string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return append([]string(nil), ids...)
|
|
}
|
|
}
|
|
|
|
// A balance that cannot be read keeps an org's machines running for mostUnread
|
|
// sweeps in a row, each hour charged; the next one stops them. A read in between
|
|
// starts the count again.
|
|
func TestAnUnreadableBalanceStopsMachinesAfterSixHours(t *testing.T) {
|
|
set, debits := answering(t)
|
|
stopped := stops(t)
|
|
seedCatalog(t, priced("s", 5))
|
|
at := func(h int) time.Time { return time.Date(2026, 7, 2, h, 10, 0, 0, time.UTC) }
|
|
m := []*Machine{{Id: "u1", Size: "s", Tag: "hanzo-org:acme", State: "Running", CreatedTime: at(0).Format(time.RFC3339)}}
|
|
MarkBilled("u1", at(1))
|
|
|
|
set("acme", 0, http.StatusBadGateway)
|
|
for h := 2; h <= 4; h++ {
|
|
meterMachines(context.Background(), m, at(h))
|
|
}
|
|
set("acme", 100_000, http.StatusOK)
|
|
meterMachines(context.Background(), m, at(5)) // read: the count starts again
|
|
set("acme", 0, http.StatusBadGateway)
|
|
if got := stopped(); len(got) != 0 {
|
|
t.Fatalf("stopped = %v", got)
|
|
}
|
|
for h := 6; h < 6+mostUnread; h++ {
|
|
meterMachines(context.Background(), m, at(h))
|
|
}
|
|
if got := stopped(); len(got) != 0 {
|
|
t.Fatalf("stopped within %d unreadable hours: %v", mostUnread, got)
|
|
}
|
|
meterMachines(context.Background(), m, at(6+mostUnread))
|
|
if got := stopped(); !slices.Equal(got, []string{"acme/u1"}) {
|
|
t.Fatalf("after %d unreadable hours in a row, stopped = %v", mostUnread+1, got)
|
|
}
|
|
got := debits()
|
|
if len(got) != 5+mostUnread-1 || got[len(got)-1] != MeterID("u1", at(5+mostUnread)) {
|
|
t.Fatalf("debits = %v: every hour through %d is charged, the hour it stopped in is not", got, 5+mostUnread)
|
|
}
|
|
}
|
|
|
|
// A balance commerce refuses as unpaid stops the org's machines at once; any
|
|
// other refusal is about visor's own request or identity, an outage like any
|
|
// other.
|
|
func TestABalanceRefusedForTheOrgStopsItsMachines(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
status int
|
|
stops bool
|
|
}{
|
|
{http.StatusPaymentRequired, true},
|
|
{http.StatusBadRequest, false},
|
|
{http.StatusNotFound, false},
|
|
{http.StatusUnauthorized, false},
|
|
{http.StatusForbidden, false},
|
|
{http.StatusTooManyRequests, false},
|
|
{http.StatusInternalServerError, false},
|
|
} {
|
|
t.Run(strconv.Itoa(tc.status), func(t *testing.T) {
|
|
set, _ := answering(t)
|
|
stopped := stops(t)
|
|
seedCatalog(t, priced("s", 5))
|
|
at := time.Date(2026, 7, 2, 14, 10, 0, 0, time.UTC)
|
|
MarkBilled("r1", at.Add(-time.Hour))
|
|
set("acme", 0, tc.status)
|
|
meterMachines(context.Background(), []*Machine{
|
|
{Id: "r1", Size: "s", Tag: "hanzo-org:acme", State: "Running", CreatedTime: at.Add(-2 * time.Hour).Format(time.RFC3339)},
|
|
}, at)
|
|
if got := len(stopped()) == 1; got != tc.stops {
|
|
t.Fatalf("a balance answered %d: stopped = %v, want %v", tc.status, got, tc.stops)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Transfer is never charged. A machine is stopped when its org cannot cover its
|
|
// hour and the transfer it could run up in it, at 12 cents a GiB of the NetworkOut
|
|
// it sent in the last settled hour; a machine that is paid for sets aside that
|
|
// transfer from what the org has left for the next.
|
|
func TestAMachineWhoseTransferItsOrgCannotCoverIsStopped(t *testing.T) {
|
|
set, debits := answering(t)
|
|
stopped := stops(t)
|
|
seedCatalog(t, priced("s", 5))
|
|
at := time.Date(2026, 7, 2, 14, 10, 0, 0, time.UTC) // hour 12 is the last settled
|
|
started := at.Add(-5 * time.Hour).Format(time.RFC3339)
|
|
var m []*Machine
|
|
for _, id := range []string{"heavy", "light", "mid1", "mid2"} {
|
|
MarkBilled(id, at.Add(-time.Hour))
|
|
m = append(m, &Machine{Id: id, Size: "s", Tag: "hanzo-org:acme", State: "Running", CreatedTime: started, instance: "i-" + id})
|
|
}
|
|
sends(t, map[string]map[string]int64{
|
|
"i-heavy": {"2026070212": 10 << 30, "2026070213": 1 << 40}, // 120 cents; 13 is not settled
|
|
"i-mid1": {"2026070212": 5 << 30}, // 60 cents
|
|
"i-mid2": {"2026070212": 5 << 30}, // 60 cents
|
|
}, nil)
|
|
set("acme", 100, http.StatusOK)
|
|
meterMachines(context.Background(), m, at)
|
|
|
|
// heavy needs 125 of 100: stopped. light needs 5: paid, 95 left. mid1 needs
|
|
// 65: paid, 30 left. mid2 needs 65 of 30: stopped.
|
|
if got := stopped(); !slices.Equal(got, []string{"acme/heavy", "acme/mid2"}) {
|
|
t.Fatalf("stopped = %v", got)
|
|
}
|
|
if got := debits(); !slices.Equal(got, []string{"compute-light-2026070214", "compute-mid1-2026070214"}) {
|
|
t.Fatalf("debits = %v: only running hours are charged", got)
|
|
}
|
|
}
|
|
|
|
// A NetworkOut that cannot be read stops nothing and charges nothing.
|
|
func TestAnUnreadableNetworkOutStopsNothing(t *testing.T) {
|
|
set, debits := answering(t)
|
|
stopped := stops(t)
|
|
seedCatalog(t, priced("s", 5))
|
|
at := time.Date(2026, 7, 2, 14, 10, 0, 0, time.UTC)
|
|
MarkBilled("n1", at.Add(-time.Hour))
|
|
sends(t, nil, errors.New("cloudwatch unreachable"))
|
|
set("acme", 5, http.StatusOK)
|
|
meterMachines(context.Background(), []*Machine{
|
|
{Id: "n1", Size: "s", Tag: "hanzo-org:acme", State: "Running", CreatedTime: at.Add(-5 * time.Hour).Format(time.RFC3339), instance: "i-n1"},
|
|
}, at)
|
|
if got := stopped(); len(got) != 0 {
|
|
t.Fatalf("stopped = %v", got)
|
|
}
|
|
if got := debits(); !slices.Equal(got, []string{"compute-n1-2026070214"}) {
|
|
t.Fatalf("debits = %v", got)
|
|
}
|
|
}
|
|
|
|
// An org's starting hours are decided under its provisioning hold, so a launch
|
|
// and the sweep never both spend what one balance covers: the balance the sweep
|
|
// reads is read while the hold is taken.
|
|
func TestTheSweepReadsABalanceUnderTheOrgsHold(t *testing.T) {
|
|
freshLedger(t)
|
|
stops(t)
|
|
seedCatalog(t, priced("s", 5))
|
|
var mu sync.Mutex
|
|
var held []bool
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/v1/billing/balance", func(w http.ResponseWriter, r *http.Request) {
|
|
holdsMu.Lock()
|
|
h := holds[r.Header.Get("X-Org-Id")]
|
|
holdsMu.Unlock()
|
|
mu.Lock()
|
|
held = append(held, h != nil)
|
|
mu.Unlock()
|
|
_, _ = io.WriteString(w, `{"available":100}`)
|
|
})
|
|
mux.HandleFunc("/v1/billing/usage", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, `{}`)
|
|
})
|
|
commercetest.Serve(t, mux)
|
|
at := time.Date(2026, 7, 2, 14, 10, 0, 0, time.UTC)
|
|
MarkBilled("h1", at.Add(-time.Hour))
|
|
meterMachines(context.Background(), []*Machine{
|
|
{Id: "h1", Size: "s", Tag: "hanzo-org:acme", State: "Running", CreatedTime: at.Add(-2 * time.Hour).Format(time.RFC3339)},
|
|
}, at)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if !slices.Equal(held, []bool{true}) {
|
|
t.Fatalf("balance reads under the hold = %v, want one, held", held)
|
|
}
|
|
}
|
|
|
|
// A run of unreadable balances is consecutive hours: an hour no sweep asked
|
|
// about ends it, as a read does.
|
|
func TestAGapInSweepsEndsARunOfUnreadableHours(t *testing.T) {
|
|
set, _ := answering(t)
|
|
stopped := stops(t)
|
|
seedCatalog(t, priced("s", 5))
|
|
at := func(h int) time.Time { return time.Date(2026, 7, 2, h, 10, 0, 0, time.UTC) }
|
|
m := []*Machine{{Id: "g1", Size: "s", Tag: "hanzo-org:acme", State: "Running", CreatedTime: at(0).Format(time.RFC3339)}}
|
|
MarkBilled("g1", at(0))
|
|
set("acme", 0, http.StatusBadGateway)
|
|
for h := 1; h <= mostUnread-1; h++ {
|
|
meterMachines(context.Background(), m, at(h))
|
|
}
|
|
// No sweep at hour mostUnread.
|
|
for h := mostUnread + 1; h <= 2*mostUnread; h++ {
|
|
meterMachines(context.Background(), m, at(h))
|
|
}
|
|
if got := stopped(); len(got) != 0 {
|
|
t.Fatalf("two runs of fewer than %d hours stopped %v", mostUnread+1, got)
|
|
}
|
|
}
|