mirror of
https://github.com/hanzoai/compute.git
synced 2026-10-30 14:50:41 +00:00
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.
229 lines
9.0 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|