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

264 lines
10 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 (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/zap-proto/zip"
"github.com/hanzoai/compute/object"
)
// These tests pin the WIRE of the agent ops, which is the half of this change
// that no compiler checks and that a caller in another repo depends on byte for
// byte. hanzoai/cloud's apps/visor/client.go reads these four answers; every
// assertion below is a line in that client, and the fake vm in its
// bots_http_test.go is written to match exactly what is asserted here.
//
// The old wire was casibase's: HTTP 200 always, with {status,msg,data} around
// the value and a logical failure sitting inside a success. A typed op has no
// envelope — the answer IS the value and the status IS the outcome — so these
// assertions are about the SHAPE as much as the content.
//
// Addresses are pinned separately, by routers/router_contract_test.go. What is
// registered here is the same set of ops at the same paths, on a bare app with
// no filter chain, because the question is what a handler ANSWERS.
// TestMain installs the store ONCE for the whole binary, and the "once" is
// load-bearing: `object`'s store is process-wide, so a per-test one would leave
// the previous test's engines open over a dataRoot that its own t.TempDir
// cleanup had already deleted — which reads as a SQLite "disk I/O error" in a
// LATER test and disappears the moment either test is run alone.
//
// The store is the Base backend at a temp dataRoot: no Postgres, no provider
// credential, nothing to reach. That bounds what can be tested here to the paths
// that do not consult the cloud — which is exactly the three answers whose shape
// changed (absent read, unbind, list). A bind resolves the machine at
// DigitalOcean, so it has no honest answer without a provider and is not faked
// into one.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "visor-agent-wire")
if err != nil {
panic(err)
}
_ = os.Setenv("dataRoot", root)
object.InitAdapter()
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}
// org is a test's OWN tenant, named after it. Under the Base backend each org is
// a separate SQLite file, so this is real isolation from one shared store rather
// than a convention two tests could violate — and it is the same property the
// production backend rests on, exercised rather than assumed.
func org(t *testing.T) string {
t.Helper()
return strings.ToLower(t.Name())
}
// wire stands the four ops up on a bare app.
func wire(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{})
zip.Get(app, "/v1/machines/agents", ListAgents)
zip.Put(app, "/v1/machines/:owner/:name/agent", BindAgent)
zip.Get(app, "/v1/machines/:owner/:name/agent", GetAgent)
zip.Delete(app, "/v1/machines/:owner/:name/agent", UnbindAgent)
return app
}
// ask drives one real request and returns the status and the raw body. Method is
// a parameter because an item is PUT and DELETEd where a collection is POSTed, so
// a table of routes is a table of (method, path) — a helper that fixed the method
// answered 404, and a 404 reads as a refusal.
func ask(t *testing.T, app *zip.App, method, path, bearer, body string) (int, string) {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", bearer)
}
res, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer res.Body.Close()
b, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("%s %s: read body: %v", method, path, err)
}
return res.StatusCode, string(b)
}
// TestReadOfAnAbsentBindingIs404 is the answer cloud's getAgent turns into its
// own 404, and the reason it can stop inspecting fields to find out.
//
// The old wire said 200 with `data: null`, so "this machine runs no agent" and
// "here is the agent" were the same status and told apart only by looking inside
// — which is how a caller comes to treat a zero-valued binding as a real one.
func TestReadOfAnAbsentBindingIs404(t *testing.T) {
app := wire(t)
status, body := ask(t, app, http.MethodGet, "/v1/machines/"+org(t)+"/drop-a/agent", "", "")
if status != http.StatusNotFound {
t.Fatalf("GET .../agent (no binding) = %d %s, want 404", status, body)
}
if envelope(t, body) {
t.Fatalf("GET .../agent answered the casibase envelope: %s", body)
}
}
// TestUnbindIs204 pins the void answer. The old wire put the word "Affected" or
// "Unaffected" in a 200 body, which made every caller parse prose to learn a
// distinction none of them acted on; a DELETE that is idempotent has nothing to
// say, and 204 is how that is said.
func TestUnbindIs204(t *testing.T) {
app := wire(t)
status, body := ask(t, app, http.MethodDelete, "/v1/machines/"+org(t)+"/drop-a/agent", "", "")
if status != http.StatusNoContent {
t.Fatalf("DELETE .../agent = %d %s, want 204", status, body)
}
if body != "" {
t.Fatalf("DELETE .../agent answered a body %q, want none", body)
}
}
// TestListIsAnObjectKeyedAgentBindings pins the collection shape cloud decodes
// straight into its own bindingList — one shape, both sides, no translation.
//
// A bare JSON array would decode there too, and that is the point of asserting
// the key: a collection that answers an array has nowhere to put a cursor or a
// count later without breaking every client, and cloud already publishes the
// object form.
func TestListIsAnObjectKeyedAgentBindings(t *testing.T) {
app := wire(t)
mine := org(t)
if _, err := object.AddAgentBinding(&object.AgentBinding{
Owner: mine, Name: "drop-a", MachineId: mine + "/drop-a",
Org: mine, AgentName: "bot-a", Status: object.AgentBindingPending,
}); err != nil {
t.Fatalf("seed binding: %v", err)
}
status, body := ask(t, app, http.MethodGet, "/v1/machines/agents?owner="+mine, "", "")
if status != http.StatusOK {
t.Fatalf("GET /v1/machines/agents = %d %s, want 200", status, body)
}
if envelope(t, body) {
t.Fatalf("GET /v1/machines/agents answered the casibase envelope: %s", body)
}
var got struct {
AgentBindings []object.AgentBinding `json:"agentBindings"`
}
if err := json.Unmarshal([]byte(body), &got); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
if len(got.AgentBindings) != 1 {
t.Fatalf("agentBindings = %d rows in %s, want 1", len(got.AgentBindings), body)
}
if got.AgentBindings[0].AgentName != "bot-a" {
t.Fatalf("agentBindings[0].agentName = %q, want bot-a", got.AgentBindings[0].AgentName)
}
}
// TestListIsScopedToTheCallerOrg is the tenancy control. The list has no id to
// address, so its whole scope is the org `principal` resolved, and a second
// org's rows must never appear in it.
//
// Under the Base backend the two orgs are two SQLite files, so what this pins is
// that the list reads the CALLER's org and not some other one — a read that had
// lost the caller's org would resolve a different engine and come back empty,
// which is what it asserts against. The WHERE-clause form of the same property
// is what the Postgres backend would exercise; both are `GetAgentBindings(owner)`
// being handed the right owner.
func TestListIsScopedToTheCallerOrg(t *testing.T) {
app := wire(t)
mine, theirs := org(t), org(t)+"-other"
for _, b := range []*object.AgentBinding{
{Owner: mine, Name: "drop-a", Org: mine, AgentName: "bot-a"},
{Owner: theirs, Name: "drop-b", Org: theirs, AgentName: "bot-b"},
} {
if _, err := object.AddAgentBinding(b); err != nil {
t.Fatalf("seed %s: %v", b.Owner, err)
}
}
_, body := ask(t, app, http.MethodGet, "/v1/machines/agents?owner="+mine, "", "")
var got struct {
AgentBindings []object.AgentBinding `json:"agentBindings"`
}
if err := json.Unmarshal([]byte(body), &got); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
for _, b := range got.AgentBindings {
if b.Owner != mine {
t.Fatalf("%s's list carried %s's binding: %s", mine, b.Owner, body)
}
}
if len(got.AgentBindings) != 1 {
t.Fatalf("%s sees %d bindings in %s, want 1", mine, len(got.AgentBindings), body)
}
}
// TestNoOrgContextIsRefused pins the fail-closed case: with neither a Bearer nor
// an ?owner there is no tenant, and the ops must refuse rather than read
// whatever the empty org happens to hold.
func TestNoOrgContextIsRefused(t *testing.T) {
app := wire(t)
if status, body := ask(t, app, http.MethodGet, "/v1/machines/agents", "", ""); status != http.StatusForbidden {
t.Errorf("GET /v1/machines/agents anonymous = %d %s, want 403", status, body)
}
// The machine is in the ADDRESS now, so "machine id is required" is no longer
// reachable from a well-formed request — the refusal a caller meets is about
// the machine, not about a missing parameter. Anonymous finds no binding in
// the empty tenant, which is the refusal that matters: it must not read
// whatever an empty org happens to hold.
if status, body := ask(t, app, http.MethodGet, "/v1/machines/acme/drop-a/agent", "", ""); status != http.StatusNotFound {
t.Errorf("GET .../agent anonymous = %d %s, want 404", status, body)
}
}
// envelope reports whether a body is casibase's {status,msg,data} wrapper rather
// than the value itself. It is the ONE thing every assertion above shares, and
// the reason it is a predicate rather than a string match: `status` alone is not
// evidence (an AgentBinding has its own `status` field, which is exactly why a
// naive check would pass on a real answer), so the envelope is recognised by the
// pair of keys only it carries.
func envelope(t *testing.T, body string) bool {
t.Helper()
var m map[string]json.RawMessage
if err := json.Unmarshal([]byte(body), &m); err != nil {
return false
}
_, hasMsg := m["msg"]
_, hasData := m["data"]
return hasMsg && hasData
}