Files
zeekay 2b14fd1659 Compute: the module, the binary and the image say so
Cloud's side of this rename landed some time ago — the app is apps/compute,
the env is COMPUTE_URL — and left a note saying the other half moves with
the manifest that names it. This is the half that does not touch the
cluster: the Go module is github.com/hanzoai/compute, the binary it builds
is compute, and the image is ghcr.io/hanzoai/compute. GitHub already
carries the new name.

What is deliberately NOT here is the cutover. The Service universe declares
is still visor, and so are visor-config, visor-kms-sync and visor-db-bucket.
Pointing anything at compute.hanzo.svc before those move resolves to
nothing, so they move together, in universe, not here.
2026-09-07 17:37:50 -07:00

229 lines
9.0 KiB
Go

// Copyright 2026 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 controllers
import (
"net/http"
"strings"
"testing"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/compute/object"
)
// The tenant a node-pool handler acts on is the caller's own, and the id is
// only ever a NAME. An id naming another org addresses the caller's own pool of
// that name — never the other org's — so the historical `owner/name` form keeps
// working without carrying a tenant.
func TestPoolIdIsAlwaysTheCallersOwn(t *testing.T) {
for name, tc := range map[string]struct{ org, id, want string }{
"owner/name keeps only the name": {"acme", "acme/gpu", "acme/gpu"},
"a foreign owner is discarded": {"acme", "hanzo/gpu", "acme/gpu"},
"a bare name is a name": {"acme", "gpu", "acme/gpu"},
"whitespace is trimmed": {"acme", " acme/gpu ", "acme/gpu"},
"no org context fails closed": {"", "acme/gpu", ""},
"no name fails closed": {"acme", "", ""},
"a trailing slash is no name": {"acme", "acme/", ""},
} {
t.Run(name, func(t *testing.T) {
if got := poolId(tc.org, tc.id); got != tc.want {
t.Fatalf("poolId(%q, %q) = %q, want %q", tc.org, tc.id, got, tc.want)
}
})
}
}
// poolWire stands the node-pool lifecycle up on a bare app, registered exactly as
// routers.Route registers it. The read buffer is raised only because an RS256
// bearer over a 2048-bit key does not fit fasthttp's 4 KiB default — the whole
// point here is to drive real requests carrying a real token.
func poolWire(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{ReadBufferSize: 16384})
handler := func(fn func(*ApiController)) zip.Handler {
return func(c *zip.Ctx) error { fn(New(c)); return nil }
}
app.Get("/v1/pools", handler((*ApiController).GetNodePools))
app.Get("/v1/pools/:owner/:name", handler((*ApiController).GetNodePool))
app.Put("/v1/pools/:owner/:name", handler((*ApiController).UpdateNodePool))
app.Delete("/v1/pools/:owner/:name", handler((*ApiController).DeleteNodePool))
app.Post("/v1/pools", handler((*ApiController).CreateNodePool))
app.Put("/v1/pools/:owner/:name/size", handler((*ApiController).ScaleNodePool))
return app
}
// post drives one real request, with an optional bearer, and returns the body.
func post(t *testing.T, app *zip.App, path, bearer, body string) string {
t.Helper()
_, b := ask(t, app, http.MethodPost, path, bearer, body)
return b
}
// get drives one real GET, with an optional bearer, and returns the body.
func get(t *testing.T, app *zip.App, path, bearer string) string {
t.Helper()
_, b := ask(t, app, http.MethodGet, path, bearer, "")
return b
}
// storedPool plants a DB-only node pool (no provider linkage, so nothing reaches
// a cloud) belonging to owner.
func storedPool(t *testing.T, owner, name string) *object.NodePool {
t.Helper()
now := time.Now().Format(time.RFC3339)
p := &object.NodePool{
Owner: owner, Name: name, OrgID: owner,
Size: "gpu-h100x8-640gb", Count: 4, State: "Active", CostPerHour: 3178,
MaxNodes: 4, CreatedTime: now, UpdatedTime: now,
}
if _, err := object.AddNodePool(p); err != nil {
t.Fatalf("AddNodePool(%s/%s): %v", owner, name, err)
}
return p
}
// A signed-in customer cannot reach another org's pool by naming it in `?id=`.
// The handler took the tenant from the id while the authorization filter took it
// from the id too — so the two agreed, and both were the caller's to choose.
func TestUpdateNodePoolActsOnTheCallersOwnOrgOnly(t *testing.T) {
mint := signer(t, "https://test.id")
app := poolWire(t)
victim := storedPool(t, "victimorg", "gpu")
storedPool(t, "attackerorg", "gpu")
ask(t, app, http.MethodPut, "/v1/pools/victimorg/gpu", mint("attackerorg"),
`{"owner":"victimorg","name":"gpu","maxNodes":99}`)
after, err := object.GetNodePool("victimorg/gpu")
if err != nil || after == nil {
t.Fatalf("read back victim: %v", err)
}
if after.MaxNodes != victim.MaxNodes {
t.Fatalf("another org's pool was edited: maxNodes %d -> %d", victim.MaxNodes, after.MaxNodes)
}
// The edit landed on the CALLER's own pool of that name, which is the whole
// point: the request is not refused, it is re-scoped to the caller.
own, err := object.GetNodePool("attackerorg/gpu")
if err != nil || own == nil {
t.Fatalf("read back caller's own: %v", err)
}
if own.MaxNodes != 99 {
t.Fatalf("the caller's own pool must take the edit, got maxNodes=%d", own.MaxNodes)
}
}
// The address names WHICH pool, never WHOSE — a caller aiming the address at
// another org still deletes its own.
func TestDeleteNodePoolActsOnTheCallersOwnOrgOnly(t *testing.T) {
mint := signer(t, "https://test.id")
app := poolWire(t)
storedPool(t, "victimdel", "gpu")
storedPool(t, "attackerdel", "gpu")
ask(t, app, http.MethodDelete, "/v1/pools/victimdel/gpu", mint("attackerdel"), "")
if p, err := object.GetNodePool("victimdel/gpu"); err != nil || p == nil {
t.Fatalf("another org's pool was deleted (err=%v)", err)
}
if p, err := object.GetNodePool("attackerdel/gpu"); err != nil || p != nil {
t.Fatalf("the caller's own pool must be the one deleted, got %+v (err=%v)", p, err)
}
}
// Every write refuses outright when there is no org context: no bearer and no
// service credential means no tenant, and a tenant-less provision is a platform
// account waiting to happen.
func TestNodePoolWritesFailClosedWithoutAnOrg(t *testing.T) {
app := poolWire(t)
for name, tc := range map[string]struct{ method, path, body string }{
"create": {http.MethodPost, "/v1/pools?provider=do", `{"size":"gpu-h100x8-640gb","count":1}`},
} {
t.Run(name, func(t *testing.T) {
_, body := ask(t, app, tc.method, tc.path, "", tc.body)
if !strings.Contains(body, refuseNoOrg) {
t.Fatalf("a tenant-less %s must be refused, got %s", name, body)
}
})
}
}
// The provision paths take their org from the signed claim, so `?owner=` cannot
// point the provision at another tenant's provider credentials, balance and
// invoice. They fail on the missing PROVIDER — which is proof they got past the
// tenant resolution carrying the caller's own org, and looked it up there.
func TestProvisionUsesTheSignedOrgNotTheQuery(t *testing.T) {
mint := signer(t, "https://test.id")
app := poolWire(t)
storedPool(t, "acmeprov", "p-1")
for name, tc := range map[string]struct{ method, path string }{
"create": {http.MethodPost, "/v1/pools?owner=hanzo&provider=platformdo"},
"scale": {http.MethodPut, "/v1/pools/hanzo/p-1/size?count=8"},
} {
t.Run(name, func(t *testing.T) {
_, body := ask(t, app, tc.method, tc.path, mint("acmeprov"), `{"owner":"hanzo","size":"gpu-h100x8-640gb","count":8}`)
if !strings.Contains(body, "acmeprov") {
t.Fatalf("the provision must resolve the provider in the CALLER's org, got %s", body)
}
if strings.Contains(body, "hanzo") {
t.Fatalf("the query's org reached the provision: %s", body)
}
})
}
}
// The READS take their tenant from the caller too. Authorization keys a GET on
// the very ?owner= the handler used to read, so the two agreed and the caller
// chose both — a customer listing another org's pools was one query parameter
// away, and the agreement is exactly what hid it.
func TestNodePoolReadsAreScopedToTheCaller(t *testing.T) {
mint := signer(t, "https://test.id")
app := poolWire(t)
storedPool(t, "victimread", "secret-gpu")
storedPool(t, "attackerread", "own-gpu")
list := get(t, app, "/v1/pools?owner=victimread", mint("attackerread"))
if strings.Contains(list, "secret-gpu") || strings.Contains(list, "victimread") {
t.Fatalf("another org's pools were listed: %s", list)
}
if !strings.Contains(list, "own-gpu") {
t.Fatalf("the caller must still see its OWN pools: %s", list)
}
one := get(t, app, "/v1/pools/victimread/secret-gpu", mint("attackerread"))
if strings.Contains(one, "secret-gpu") || strings.Contains(one, "victimread") {
t.Fatalf("another org's pool was readable by id: %s", one)
}
}
// A read with no org context is refused rather than served somebody's rows.
func TestNodePoolReadsFailClosedWithoutAnOrg(t *testing.T) {
app := poolWire(t)
// Only the collection. An item address NAMES its org, so "no org context" is
// not a state it can be in — what must be refused there is reading ANOTHER
// org's item, which is the test above and the filter in front of it.
for name, path := range map[string]string{
"list": "/v1/pools",
} {
t.Run(name, func(t *testing.T) {
if body := get(t, app, path, ""); !strings.Contains(body, refuseNoOrg) {
t.Fatalf("a tenant-less %s must be refused, got %s", name, body)
}
})
}
}