Files
compute/object/agent_binding_test.go
zeekay 3c7916455f fix(agent-binding): resolve bind target from the resell surface, not the BYOC table
Red review found the load-bearing defect: object.BindAgent /
ReconcileAgentBinding resolved the machine via object.GetMachine, which
reads the XORM BYOC Machine table (Owner = IAM org, provisioned via the
org's own provider creds). But the binding is routed and org-scoped as the
RESELL compute surface (Hanzo house DigitalOcean account; a machine is a
droplet keyed by integer id and owned via the hanzo-org:<org> tag), and
reconcileBindingStatus / machineHasBotRuntimeTag were written for the
droplet shape (State Running/Stopped/Starting, comma-joined Tag) that
getMachineFromDroplet produces. Consequences:

  - Functional break: for a real resell machine, object.GetMachine returns
    nil, so BindAgent errored 'machine not found' and GET returned nil —
    the feature could not operate on the machine the operator launched.
  - Isolation footgun: a BYOC machine whose name happens to equal a droplet
    id in the same org would be bound/reconciled against the WRONG machine
    (two machine populations conflated by a shared name).

Fix: resolve the bind target via service.GetOrgMachine(owner, name) and map
service.Machine -> object.Machine. This operates on the correct machine AND
enforces tenant isolation a second way — GetOrgMachine returns nil unless
the droplet carries the caller org's hanzo-org tag, so even with the id
owner already pinned to resolveComputeOrg a binding can never attach to
another tenant's machine. Non-numeric droplet ids fail closed (clean error
from Atoi), preserving panic-safety.

Also: drop now-orphaned object.GetAgentBinding (no caller after reconcile
switched to getAgentBinding); add TestReBindDifferentAgentNoStaleBound
locking the vector-6 property that a re-bound agent never inherits a stale
Bound from the replaced binding's droplet tag.

go build/vet clean; object/controllers/authz tests pass. Pre-existing
service/TestHanzoPriceMarkup failure is unrelated (pricing drift on main).
2026-07-02 01:21:16 -07:00

234 lines
8.1 KiB
Go

// Copyright 2024 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 object
import (
"strings"
"testing"
)
func TestIsActiveMachineState(t *testing.T) {
active := []string{"Running", "running", "RUNNING"}
for _, s := range active {
if !isActiveMachineState(s) {
t.Errorf("isActiveMachineState(%q) = false, want true", s)
}
}
inactive := []string{"Stopped", "Starting", "new", "off", "deleted", "", "pending"}
for _, s := range inactive {
if isActiveMachineState(s) {
t.Errorf("isActiveMachineState(%q) = true, want false", s)
}
}
}
func TestIsTerminalMachineState(t *testing.T) {
terminal := []string{"deleted", "Deleted", "archive", "errored", "error", "failed", "terminated"}
for _, s := range terminal {
if !isTerminalMachineState(s) {
t.Errorf("isTerminalMachineState(%q) = false, want true", s)
}
}
// Stopped/Starting are provisioning-recoverable, NOT terminal — they must
// map to Pending, never Error.
nonTerminal := []string{"Running", "Stopped", "Starting", "new", "off", ""}
for _, s := range nonTerminal {
if isTerminalMachineState(s) {
t.Errorf("isTerminalMachineState(%q) = true, want false", s)
}
}
}
func TestMachineHasBotRuntimeTag(t *testing.T) {
cases := []struct {
name string
tag string
agent string
want bool
}{
{"exact match", "managed-by:hanzo-visor,hanzo-bot:researcher,", "researcher", true},
{"with spaces", "os:linux, hanzo-bot:researcher , display-name:x", "researcher", true},
{"case insensitive", "HANZO-BOT:Researcher", "researcher", true},
{"wrong agent", "hanzo-bot:writer,", "researcher", false},
{"no bot tag", "managed-by:hanzo-visor,os:linux", "researcher", false},
{"empty tag", "", "researcher", false},
{"substring must not match", "hanzo-bot:researcher-2", "researcher", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := &Machine{Tag: tc.tag}
if got := machineHasBotRuntimeTag(m, tc.agent); got != tc.want {
t.Errorf("machineHasBotRuntimeTag(tag=%q, agent=%q) = %v, want %v", tc.tag, tc.agent, got, tc.want)
}
})
}
}
// TestSplitMachineIdNeverPanics locks the object-layer id split as fail-closed.
// The prior code called util.GetOwnerAndNameFromId (panics on != 2 tokens), so a
// malformed machine id reaching BindAgent/UnbindAgent crashed the request.
// splitMachineId must return empties instead.
func TestSplitMachineIdNeverPanics(t *testing.T) {
cases := []struct {
id string
owner string
name string
}{
{"hanzoai/m1", "hanzoai", "m1"},
{"m1", "", ""},
{"", "", ""},
{"a/b/c", "", ""},
{"/m1", "", ""},
{"hanzoai/", "", ""},
}
for _, tc := range cases {
o, n := splitMachineId(tc.id)
if o != tc.owner || n != tc.name {
t.Errorf("splitMachineId(%q) = (%q,%q), want (%q,%q)", tc.id, o, n, tc.owner, tc.name)
}
}
}
func TestReconcileBindingStatus(t *testing.T) {
binding := func(agent string) *AgentBinding {
return &AgentBinding{Org: "hanzoai", AgentName: agent}
}
cases := []struct {
name string
binding *AgentBinding
machine *Machine
wantStatus string
msgContains string
}{
{
name: "nil machine -> Error",
binding: binding("researcher"),
machine: nil,
wantStatus: AgentBindingError,
msgContains: "not found",
},
{
name: "terminal machine -> Error",
binding: binding("researcher"),
machine: &Machine{State: "deleted"},
wantStatus: AgentBindingError,
msgContains: "terminal",
},
{
name: "starting machine -> Pending",
binding: binding("researcher"),
machine: &Machine{State: "Starting"},
wantStatus: AgentBindingPending,
msgContains: "provisioning",
},
{
name: "stopped machine -> Pending",
binding: binding("researcher"),
machine: &Machine{State: "Stopped"},
wantStatus: AgentBindingPending,
msgContains: "provisioning",
},
{
name: "active but no bot tag -> Pending",
binding: binding("researcher"),
machine: &Machine{State: "Running", Tag: "managed-by:hanzo-visor,"},
wantStatus: AgentBindingPending,
msgContains: "runtime tag",
},
{
name: "active with matching bot tag -> Bound",
binding: binding("researcher"),
machine: &Machine{State: "Running", Tag: "hanzo-bot:researcher,"},
wantStatus: AgentBindingBound,
msgContains: "researcher",
},
{
name: "active with wrong agent tag -> Pending",
binding: binding("researcher"),
machine: &Machine{State: "Running", Tag: "hanzo-bot:writer,"},
wantStatus: AgentBindingPending,
msgContains: "runtime tag",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status, msg := reconcileBindingStatus(tc.binding, tc.machine)
if status != tc.wantStatus {
t.Errorf("status = %q, want %q (msg=%q)", status, tc.wantStatus, msg)
}
if !strings.Contains(strings.ToLower(msg), strings.ToLower(tc.msgContains)) {
t.Errorf("message %q does not contain %q", msg, tc.msgContains)
}
})
}
}
// TestReconcileNeverInventsBound is the load-bearing safety property: no machine
// state other than (Active AND matching bot tag) may ever produce Bound. This
// guards against the honest-state contract regressing into optimistic reporting.
func TestReconcileNeverInventsBound(t *testing.T) {
b := &AgentBinding{Org: "hanzoai", AgentName: "researcher"}
notBoundMachines := []*Machine{
nil,
{State: "Starting"},
{State: "Stopped"},
{State: "deleted"},
{State: "Running"}, // active, no tag
{State: "Running", Tag: "hanzo-bot:other"}, // active, wrong agent
{State: "Starting", Tag: "hanzo-bot:researcher"}, // right tag but not active
}
for i, m := range notBoundMachines {
status, msg := reconcileBindingStatus(b, m)
if status == AgentBindingBound {
t.Errorf("case %d: reconcile invented Bound for a non-bound machine (msg=%q)", i, msg)
}
}
// The one and only Bound path.
status, _ := reconcileBindingStatus(b, &Machine{State: "Running", Tag: "hanzo-bot:researcher,"})
if status != AgentBindingBound {
t.Errorf("the canonical bound machine did not reconcile to Bound, got %q", status)
}
}
// TestReBindDifferentAgentNoStaleBound is the vector-6 safety property. The
// binding row is 1:1 with the machine (PK owner/name) and re-binding a DIFFERENT
// agent fully replaces the prior binding (UpdateAgentBinding AllCols), so no
// layering. The security-relevant consequence is that reconcile must key on the
// CURRENT bound agent: a machine still carrying the OLD agent's
// `hanzo-bot:<oldAgent>` droplet tag (bind never mutates the machine) must NOT
// reconcile to Bound for the NEW agent. It reports Pending until the new agent's
// tag lands — never a stale Bound inherited from the replaced binding.
func TestReBindDifferentAgentNoStaleBound(t *testing.T) {
machine := &Machine{State: "Running", Tag: "hanzo-bot:researcher,"} // old agent's tag
// Re-bound to a different agent; the machine still only carries the old tag.
rebound := &AgentBinding{Org: "hanzoai", AgentName: "writer"}
status, msg := reconcileBindingStatus(rebound, machine)
if status == AgentBindingBound {
t.Fatalf("stale Bound leaked to the re-bound agent from the prior agent's tag (msg=%q)", msg)
}
if status != AgentBindingPending {
t.Errorf("re-bound agent status = %q, want Pending (awaiting its own tag)", status)
}
// Once the new agent's tag lands, and only then, it reconciles to Bound.
machine.Tag = "hanzo-bot:writer,"
if status, _ := reconcileBindingStatus(rebound, machine); status != AgentBindingBound {
t.Errorf("re-bound agent with its own tag = %q, want Bound", status)
}
}