Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e090c4928e |
@@ -0,0 +1,422 @@
|
||||
// bots.go mounts the Hanzo Cloud BOT surface (/v1/bots) plus the machine
|
||||
// agent-binding proxies (/v1/machines/:id/{bind-agent,agent-binding},
|
||||
// /v1/agent-bindings). It is the SIBLING of machines: a Bot is not a new state
|
||||
// this subsystem owns, it is a composition of two things vm already owns — a
|
||||
// kind=bot Machine and an AgentBinding. So every route here is a thin, org-scoped
|
||||
// translation over the SAME Visor client the machines routes use (client.go),
|
||||
// never a second store.
|
||||
//
|
||||
// A Bot = Agent (cloud /v1/agents) + Machine (vm, kind=bot) + the binding between
|
||||
// them. Composition, one way per verb:
|
||||
//
|
||||
// launch = vm POST /v1/machines/launch {kind:bot} THEN vm POST .../bind-agent
|
||||
// list = vm GET /v1/machines?kind=bot joined with the org's bindings
|
||||
// get = vm GET /v1/machines/:id joined with its binding
|
||||
// delete = vm DELETE .../agent-binding (unbind) THEN vm DELETE /v1/machines/:id
|
||||
// message = the AGENT path: run the bot's bound agent via /v1/agents/:agent/run
|
||||
// stop = vm DELETE .../agent-binding — halt the bot's @hanzo/bot runtime
|
||||
// pause = the same halt: DigitalOcean/vm expose no VM-suspend primitive, so a
|
||||
// bot's stop and pause are one honest capability (detach the agent
|
||||
// runtime); powering the underlying machine off/on is a machine-lifecycle
|
||||
// concern handled by launch/delete, not a fabricated bot state.
|
||||
//
|
||||
// Tenancy is identical to machines: the org is the VALIDATED principal
|
||||
// (principal.Tenant, taken from the IAM owner claim), forwarded to vm as
|
||||
// ?owner=<org>, so a caller can only ever read or mutate its OWN bots. No
|
||||
// validated principal ⇒ 403, before anything reaches vm.
|
||||
package visor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// agentBinding mirrors vm/object.AgentBinding — the record that a machine runs
|
||||
// the @hanzo/bot runtime for a cloud Agent. Emitted verbatim so vm stays the one
|
||||
// source of truth for the binding shape (status/message are vm's honest,
|
||||
// reconciled values, never invented here).
|
||||
type agentBinding struct {
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
MachineId string `json:"machineId,omitempty"`
|
||||
Org string `json:"org,omitempty"`
|
||||
AgentName string `json:"agentName,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
PublicIp string `json:"publicIp,omitempty"`
|
||||
BotVersion string `json:"botVersion,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
CreatedTime string `json:"createdTime,omitempty"`
|
||||
UpdatedTime string `json:"updatedTime,omitempty"`
|
||||
}
|
||||
|
||||
// identifies reports whether a binding carries any real identity — used to tell a
|
||||
// present binding from an empty (no-binding) zero value returned by vm.
|
||||
func (b agentBinding) identifies() bool {
|
||||
return b.Name != "" || b.MachineId != "" || b.AgentName != ""
|
||||
}
|
||||
|
||||
// botView is what /v1/bots emits: the bot's machine (the clean machineView the
|
||||
// console already consumes) with the bound agent surfaced. binding carries the
|
||||
// honest, vm-reconciled lifecycle status when present.
|
||||
type botView struct {
|
||||
machineView
|
||||
Agent string `json:"agent,omitempty"`
|
||||
Binding *agentBinding `json:"binding,omitempty"`
|
||||
}
|
||||
|
||||
func toBotView(m visorMachine, b *agentBinding) botView {
|
||||
v := botView{machineView: toMachineView(m)}
|
||||
if b != nil && b.identifies() {
|
||||
v.Agent = b.AgentName
|
||||
v.Binding = b
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// machineIsBot reports whether a machine is a Bot, from its own tags — the
|
||||
// read-back of vm's launch-time hanzo-kind:bot stamp (SetKind). It is the same
|
||||
// signal vm's own ?kind=bot list filter uses, so a get is consistent with a list.
|
||||
func machineIsBot(m visorMachine) bool {
|
||||
for _, t := range strings.Split(m.Tag, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(t), "hanzo-kind:bot") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- bots ----
|
||||
|
||||
func (s *svc) listBots(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
var machines []visorMachine
|
||||
if err := s.cl.call(c, http.MethodGet, "/v1/machines", q("owner", org, "kind", "bot"), nil, &machines); err != nil {
|
||||
return err
|
||||
}
|
||||
// Join the org's bindings ONCE (O(1), not N+1), keyed by machine id — the same
|
||||
// id vm binds a machine by. Enrichment only: a bindings read failure never
|
||||
// blanks the list (a bot still lists without its reconciled status).
|
||||
byMachine := map[string]*agentBinding{}
|
||||
var bindings []agentBinding
|
||||
if err := s.cl.call(c, http.MethodGet, "/v1/agent-bindings", q("owner", org), nil, &bindings); err == nil {
|
||||
for i := range bindings {
|
||||
byMachine[bindings[i].Name] = &bindings[i]
|
||||
}
|
||||
}
|
||||
out := make([]botView, 0, len(machines))
|
||||
for _, m := range machines {
|
||||
out = append(out, toBotView(m, byMachine[firstNonEmpty(m.Id, m.Name)]))
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"bots": out})
|
||||
}
|
||||
|
||||
// botLaunchReq is the POST /v1/bots/launch body. A bot needs a machine size and,
|
||||
// for a real launch, a name; agent is the cloud /v1/agents identity the bot runs
|
||||
// (defaulting to the bot's name so a bot is self-named by default).
|
||||
type botLaunchReq struct {
|
||||
Name string `json:"name"`
|
||||
Agent string `json:"agent"`
|
||||
Size string `json:"size"`
|
||||
InstanceType string `json:"instanceType"`
|
||||
Region string `json:"region"`
|
||||
BotVersion string `json:"botVersion"`
|
||||
DryRun bool `json:"dryRun"`
|
||||
}
|
||||
|
||||
func (s *svc) launchBot(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
var body botLaunchReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
size := strings.TrimSpace(firstNonEmpty(body.Size, body.InstanceType))
|
||||
if size == "" {
|
||||
return zip.ErrBadRequest("size is required")
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if !body.DryRun && name == "" {
|
||||
return zip.ErrBadRequest("name is required to launch a bot")
|
||||
}
|
||||
|
||||
// The machine half: launch a kind=bot machine. vm stamps hanzo-kind:bot and
|
||||
// bootstraps the @hanzo/bot runtime cloud-init for a bot spec (specIsBot).
|
||||
launch := map[string]any{
|
||||
"name": name, "size": size, "region": body.Region,
|
||||
"kind": "bot", "dryRun": body.DryRun,
|
||||
}
|
||||
var data json.RawMessage
|
||||
if err := s.cl.call(c, http.MethodPost, "/v1/machines/launch", q("owner", org), launch, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
// dryRun: pass vm's price quote through unchanged (the authoritative price).
|
||||
if body.DryRun {
|
||||
var quote any
|
||||
if len(data) > 0 {
|
||||
_ = json.Unmarshal(data, "e)
|
||||
}
|
||||
return c.JSON(http.StatusOK, quote)
|
||||
}
|
||||
|
||||
// vm returns {machine, quote[, meteringError]} — extract the launched machine.
|
||||
var wrap struct {
|
||||
Machine visorMachine `json:"machine"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &wrap)
|
||||
if wrap.Machine.Name == "" && wrap.Machine.Id == "" {
|
||||
_ = json.Unmarshal(data, &wrap.Machine)
|
||||
}
|
||||
machineID := firstNonEmpty(wrap.Machine.Id, wrap.Machine.Name)
|
||||
if machineID == "" {
|
||||
return zip.Errorf(http.StatusBadGateway, "bot launch: vm returned no machine")
|
||||
}
|
||||
|
||||
// The agent half: bind the cloud Agent to the freshly-launched machine. org is
|
||||
// the validated tenant (never a client field); agent defaults to the bot name.
|
||||
agent := firstNonEmpty(strings.TrimSpace(body.Agent), name)
|
||||
var binding agentBinding
|
||||
if err := s.cl.call(c, http.MethodPost, "/v1/machines/"+url.PathEscape(machineID)+"/bind-agent",
|
||||
q("owner", org),
|
||||
map[string]any{"org": org, "agentName": agent, "botVersion": body.BotVersion},
|
||||
&binding); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusCreated, toBotView(wrap.Machine, &binding))
|
||||
}
|
||||
|
||||
func (s *svc) getBot(c *zip.Ctx) error {
|
||||
org, id, err := s.botScope(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var m visorMachine
|
||||
if err := s.cl.call(c, http.MethodGet, "/v1/machines/"+url.PathEscape(id), q("owner", org), nil, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Name == "" && m.Id == "" {
|
||||
return zip.ErrNotFound("bot not found")
|
||||
}
|
||||
// Attach the binding (best-effort). A machine is a Bot if it carries the
|
||||
// hanzo-kind:bot tag OR has an agent binding — either signal is authoritative,
|
||||
// so a bot resolves even before its cloud-init has stamped every tag.
|
||||
var binding agentBinding
|
||||
_ = s.cl.call(c, http.MethodGet, "/v1/machines/"+url.PathEscape(id)+"/agent-binding", q("owner", org), nil, &binding)
|
||||
if !machineIsBot(m) && !binding.identifies() {
|
||||
return zip.ErrNotFound("bot not found")
|
||||
}
|
||||
return c.JSON(http.StatusOK, toBotView(m, &binding))
|
||||
}
|
||||
|
||||
func (s *svc) deleteBot(c *zip.Ctx) error {
|
||||
org, id, err := s.botScope(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Tear down both halves: unbind the agent first (best-effort — a bot with no
|
||||
// binding still deletes), then terminate the machine.
|
||||
_ = s.cl.call(c, http.MethodDelete, "/v1/machines/"+url.PathEscape(id)+"/agent-binding", q("owner", org), nil, nil)
|
||||
if err := s.cl.call(c, http.MethodDelete, "/v1/machines/"+url.PathEscape(id), q("owner", org), nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// botAction dispatches /v1/bots/:id/:action. message routes to the AGENT path;
|
||||
// stop and pause both halt the bot's agent runtime (one honest capability — see
|
||||
// the package doc). An unknown action is a clean 400, never a silent no-op.
|
||||
func (s *svc) botAction(c *zip.Ctx) error {
|
||||
org, id, err := s.botScope(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(c.Param("action"))) {
|
||||
case "message":
|
||||
return s.messageBot(c, org, id)
|
||||
case "stop", "pause":
|
||||
return s.stopBot(c, org, id)
|
||||
default:
|
||||
return zip.ErrBadRequest("unknown bot action (want stop|pause|message)")
|
||||
}
|
||||
}
|
||||
|
||||
// stopBot halts the bot's runtime by unbinding its agent — the machine stays
|
||||
// (re-bind to resume, or DELETE /v1/bots/:id to tear it down). Idempotent: a bot
|
||||
// with no binding still reports stopped.
|
||||
func (s *svc) stopBot(c *zip.Ctx, org, id string) error {
|
||||
if err := s.cl.call(c, http.MethodDelete, "/v1/machines/"+url.PathEscape(id)+"/agent-binding", q("owner", org), nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"id": id, "status": "stopped"})
|
||||
}
|
||||
|
||||
// messageBot runs the bot's bound agent with the caller's message. It resolves
|
||||
// the agent from the machine's binding, then forwards the body to the ONE agent
|
||||
// runner (/v1/agents/:agent/run) so a message is a real agent run — recorded,
|
||||
// billed and traced exactly like any other. The caller's identity is forwarded so
|
||||
// the run is scoped + gated as the same principal (never a fabricated identity).
|
||||
func (s *svc) messageBot(c *zip.Ctx, org, id string) error {
|
||||
var binding agentBinding
|
||||
if err := s.cl.call(c, http.MethodGet, "/v1/machines/"+url.PathEscape(id)+"/agent-binding", q("owner", org), nil, &binding); err != nil {
|
||||
return err
|
||||
}
|
||||
agent := strings.TrimSpace(binding.AgentName)
|
||||
if agent == "" {
|
||||
return zip.ErrBadRequest("bot has no bound agent to message")
|
||||
}
|
||||
target := agentsBase() + "/v1/agents/" + url.PathEscape(agent) + "/run"
|
||||
req, err := http.NewRequestWithContext(c.Context(), http.MethodPost, target, bytes.NewReader(c.Body()))
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "bots: build agent request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for _, h := range selfIdentityHeaders {
|
||||
if v := c.Header(h); v != "" {
|
||||
req.Header.Set(h, v)
|
||||
}
|
||||
}
|
||||
resp, err := selfClient.Do(req)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "bots: agent path unreachable: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
||||
c.SetHeader("Content-Type", ct)
|
||||
}
|
||||
return c.Bytes(resp.StatusCode, rb)
|
||||
}
|
||||
|
||||
// botScope validates the principal and extracts the bot id in one place, so every
|
||||
// bot :id handler gates identically (403 before vm) and rejects an empty id.
|
||||
func (s *svc) botScope(c *zip.Ctx) (org, id string, err error) {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return "", "", zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
id = strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
return "", "", zip.ErrBadRequest("bot id required")
|
||||
}
|
||||
return org, id, nil
|
||||
}
|
||||
|
||||
// ---- machine agent-binding proxies (thin, mirror vm exactly) ----
|
||||
|
||||
type bindAgentReq struct {
|
||||
AgentName string `json:"agentName"`
|
||||
BotVersion string `json:"botVersion"`
|
||||
}
|
||||
|
||||
func (s *svc) bindMachineAgent(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
return zip.ErrBadRequest("machine id required")
|
||||
}
|
||||
var body bindAgentReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(body.AgentName) == "" {
|
||||
return zip.ErrBadRequest("agentName is required")
|
||||
}
|
||||
// org is the validated tenant (never a client field) — vm records it as the
|
||||
// Agent's owning org and scopes the machine by ?owner.
|
||||
var binding agentBinding
|
||||
if err := s.cl.call(c, http.MethodPost, "/v1/machines/"+url.PathEscape(id)+"/bind-agent",
|
||||
q("owner", org),
|
||||
map[string]any{"org": org, "agentName": body.AgentName, "botVersion": body.BotVersion},
|
||||
&binding); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, binding)
|
||||
}
|
||||
|
||||
func (s *svc) getMachineAgentBinding(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
return zip.ErrBadRequest("machine id required")
|
||||
}
|
||||
var binding agentBinding
|
||||
if err := s.cl.call(c, http.MethodGet, "/v1/machines/"+url.PathEscape(id)+"/agent-binding", q("owner", org), nil, &binding); err != nil {
|
||||
return err
|
||||
}
|
||||
if !binding.identifies() {
|
||||
return zip.ErrNotFound("no agent binding for machine")
|
||||
}
|
||||
return c.JSON(http.StatusOK, binding)
|
||||
}
|
||||
|
||||
func (s *svc) unbindMachineAgent(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
return zip.ErrBadRequest("machine id required")
|
||||
}
|
||||
if err := s.cl.call(c, http.MethodDelete, "/v1/machines/"+url.PathEscape(id)+"/agent-binding", q("owner", org), nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *svc) listAgentBindings(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
var bindings []agentBinding
|
||||
if err := s.cl.call(c, http.MethodGet, "/v1/agent-bindings", q("owner", org), nil, &bindings); err != nil {
|
||||
return err
|
||||
}
|
||||
if bindings == nil {
|
||||
bindings = []agentBinding{}
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"agentBindings": bindings})
|
||||
}
|
||||
|
||||
// ---- agent path (self) ----
|
||||
|
||||
// selfIdentityHeaders are the gateway-minted identity a bot message forwards to
|
||||
// the agent run so the run is scoped + gated as the SAME principal.
|
||||
var selfIdentityHeaders = []string{
|
||||
"Authorization", "X-Org-Id", "X-User-Id", "X-User-Email", "X-Project-Id", "X-Environment",
|
||||
}
|
||||
|
||||
// selfClient reaches the cloud binary's OWN agent surface. A bot message is a real
|
||||
// agent run — the run path (records, billing, tracing) is not re-implemented here.
|
||||
var selfClient = &http.Client{Timeout: 60 * time.Second}
|
||||
|
||||
// agentsBase is the base of the /v1/agents surface. In the unified binary the
|
||||
// agents subsystem is mounted on THIS process's app listener, so the default is
|
||||
// self (CLOUD_LISTEN :8000); CLOUD_AGENTS_URL overrides it (a split deploy, or a
|
||||
// test's fake agents server).
|
||||
func agentsBase() string {
|
||||
if v := strings.TrimSpace(os.Getenv("CLOUD_AGENTS_URL")); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return "http://127.0.0.1:8000"
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package visor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// botVM is a stand-in for the vm (Visor) resell compute + agent-binding surface.
|
||||
// It speaks the casibase {status,msg,data} envelope, scopes every read by the
|
||||
// ?owner query (so a test proves cloud forwards the VALIDATED principal's org),
|
||||
// and records the last bind/unbind it saw so a test can assert the composition.
|
||||
type botVM struct {
|
||||
bots map[string]map[string]any // id -> machine (kind=bot)
|
||||
bindings map[string]agentBinding // id -> binding
|
||||
lastOwner string
|
||||
lastBindOrg string
|
||||
lastBindName string // agentName last bound
|
||||
lastUnbind string // id last unbound
|
||||
}
|
||||
|
||||
func newBotVM() *botVM {
|
||||
return &botVM{bots: map[string]map[string]any{}, bindings: map[string]agentBinding{}}
|
||||
}
|
||||
|
||||
func (f *botVM) server(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// GET /v1/machines?owner=&kind=bot — the resell list, kind-filtered.
|
||||
mux.HandleFunc("/v1/machines", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.lastOwner = r.URL.Query().Get("owner")
|
||||
out := []map[string]any{}
|
||||
if r.URL.Query().Get("kind") == "bot" || r.URL.Query().Get("kind") == "" {
|
||||
for _, m := range f.bots {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
envelope200(w, out)
|
||||
})
|
||||
|
||||
// POST /v1/machines/launch — quote (dryRun) or launch a machine.
|
||||
mux.HandleFunc("/v1/machines/launch", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.lastOwner = r.URL.Query().Get("owner")
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
quote := map[string]any{"size": body["size"], "region": body["region"], "priceHourly": 1.57, "currency": "usd"}
|
||||
if dry, _ := body["dryRun"].(bool); dry {
|
||||
envelope200(w, quote)
|
||||
return
|
||||
}
|
||||
name, _ := body["name"].(string)
|
||||
id := "drop-" + name
|
||||
machine := map[string]any{
|
||||
"owner": f.lastOwner, "name": name, "id": id,
|
||||
"size": body["size"], "region": body["region"], "state": "provisioning",
|
||||
"tag": "hanzo-kind:bot",
|
||||
}
|
||||
f.bots[id] = machine
|
||||
envelope200(w, map[string]any{"machine": machine, "quote": quote})
|
||||
})
|
||||
|
||||
// /v1/machines/{id}[/bind-agent|/agent-binding] — the machine sub-resources.
|
||||
mux.HandleFunc("/v1/machines/", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.lastOwner = r.URL.Query().Get("owner")
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/v1/machines/")
|
||||
parts := strings.Split(rest, "/")
|
||||
id := parts[0]
|
||||
switch {
|
||||
case len(parts) == 1 && r.Method == http.MethodGet: // GetComputeMachine
|
||||
if m, ok := f.bots[id]; ok {
|
||||
envelope200(w, m)
|
||||
return
|
||||
}
|
||||
envelope200(w, map[string]any{}) // not found -> empty machine
|
||||
case len(parts) == 1 && r.Method == http.MethodDelete: // DeleteComputeMachine
|
||||
delete(f.bots, id)
|
||||
envelope200(w, "deleted")
|
||||
case len(parts) == 2 && parts[1] == "bind-agent" && r.Method == http.MethodPost:
|
||||
var b struct {
|
||||
Org, AgentName, BotVersion string
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&b)
|
||||
f.lastBindOrg, f.lastBindName = b.Org, b.AgentName
|
||||
binding := agentBinding{
|
||||
Owner: b.Org, Name: id, MachineId: b.Org + "/" + id, Org: b.Org,
|
||||
AgentName: b.AgentName, BotVersion: b.BotVersion, Status: "Pending",
|
||||
Message: "machine provisioning; @hanzo/bot runtime not yet confirmed",
|
||||
}
|
||||
f.bindings[id] = binding
|
||||
envelope200(w, binding)
|
||||
case len(parts) == 2 && parts[1] == "agent-binding" && r.Method == http.MethodGet:
|
||||
if b, ok := f.bindings[id]; ok {
|
||||
envelope200(w, b)
|
||||
return
|
||||
}
|
||||
envelope200(w, map[string]any{}) // no binding -> empty
|
||||
case len(parts) == 2 && parts[1] == "agent-binding" && r.Method == http.MethodDelete:
|
||||
f.lastUnbind = id
|
||||
delete(f.bindings, id)
|
||||
envelope200(w, "Affected")
|
||||
default:
|
||||
http.Error(w, "unhandled "+r.Method+" "+r.URL.Path, http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
// GET /v1/agent-bindings?owner= — the org's bindings.
|
||||
mux.HandleFunc("/v1/agent-bindings", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.lastOwner = r.URL.Query().Get("owner")
|
||||
out := []agentBinding{}
|
||||
for _, b := range f.bindings {
|
||||
out = append(out, b)
|
||||
}
|
||||
envelope200(w, out)
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// mountBots wires the visor surface against the fake vm (and, when given, a fake
|
||||
// agents server for the message path).
|
||||
func mountBots(t *testing.T, f *botVM, agentsURL string) *zip.App {
|
||||
t.Helper()
|
||||
srv := f.server(t)
|
||||
t.Setenv("VISOR_URL", srv.URL)
|
||||
t.Setenv("VISOR_CLIENT_ID", "")
|
||||
t.Setenv("VISOR_CLIENT_SECRET", "")
|
||||
if agentsURL != "" {
|
||||
t.Setenv("CLOUD_AGENTS_URL", agentsURL)
|
||||
}
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test")}); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func TestBotsGatedNoPrincipal(t *testing.T) {
|
||||
app := mountBots(t, newBotVM(), "")
|
||||
// Every bot + agent-binding route must 403 (not 404) without a validated
|
||||
// principal — routed and org-gated exactly like /v1/machines.
|
||||
cases := []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodGet, "/v1/bots"},
|
||||
{http.MethodPost, "/v1/bots/launch"},
|
||||
{http.MethodGet, "/v1/bots/launch"}, // matches /v1/bots/:id — still gated
|
||||
{http.MethodGet, "/v1/bots/drop-x"},
|
||||
{http.MethodDelete, "/v1/bots/drop-x"},
|
||||
{http.MethodPost, "/v1/bots/drop-x/stop"},
|
||||
{http.MethodPost, "/v1/bots/drop-x/message"},
|
||||
{http.MethodPost, "/v1/machines/drop-x/bind-agent"},
|
||||
{http.MethodGet, "/v1/machines/drop-x/agent-binding"},
|
||||
{http.MethodDelete, "/v1/machines/drop-x/agent-binding"},
|
||||
{http.MethodGet, "/v1/agent-bindings"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if code, _ := do(t, app, tc.method, tc.path, "", nil); code != http.StatusForbidden {
|
||||
t.Errorf("%s %s no-principal = %d, want 403", tc.method, tc.path, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotLaunchQuoteAndReal(t *testing.T) {
|
||||
f := newBotVM()
|
||||
app := mountBots(t, f, "")
|
||||
|
||||
// dryRun → the price quote verbatim, no machine, no bind.
|
||||
code, body := do(t, app, http.MethodPost, "/v1/bots/launch", "acme",
|
||||
map[string]any{"size": "s-2vcpu-4gb", "region": "sfo3", "name": "helper", "dryRun": true})
|
||||
if code != http.StatusOK || !strings.Contains(string(body), `"priceHourly"`) {
|
||||
t.Fatalf("dryRun want 200 quote, got %d %s", code, body)
|
||||
}
|
||||
if len(f.bots) != 0 || f.lastBindName != "" {
|
||||
t.Fatalf("dryRun must not launch or bind (bots=%d bind=%q)", len(f.bots), f.lastBindName)
|
||||
}
|
||||
|
||||
// A real launch → 201 botView with the agent bound (agent defaults to name).
|
||||
code, body = do(t, app, http.MethodPost, "/v1/bots/launch", "acme",
|
||||
map[string]any{"size": "s-2vcpu-4gb", "region": "sfo3", "name": "helper"})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("launch want 201, got %d %s", code, body)
|
||||
}
|
||||
var bv botView
|
||||
if err := json.Unmarshal(body, &bv); err != nil {
|
||||
t.Fatalf("shape: %v (%s)", err, body)
|
||||
}
|
||||
if bv.Name != "helper" || bv.Status != "provisioning" || bv.Agent != "helper" || bv.Binding == nil {
|
||||
t.Fatalf("bot view mismatch: %+v", bv)
|
||||
}
|
||||
if f.lastOwner != "acme" || f.lastBindOrg != "acme" || f.lastBindName != "helper" {
|
||||
t.Fatalf("cloud must forward validated org+agent: owner=%q bindOrg=%q agent=%q", f.lastOwner, f.lastBindOrg, f.lastBindName)
|
||||
}
|
||||
|
||||
// An explicit agent overrides the name default.
|
||||
_, body = do(t, app, http.MethodPost, "/v1/bots/launch", "acme",
|
||||
map[string]any{"size": "s-2vcpu-4gb", "region": "sfo3", "name": "sup", "agent": "support"})
|
||||
_ = json.Unmarshal(body, &bv)
|
||||
if f.lastBindName != "support" || bv.Agent != "support" {
|
||||
t.Fatalf("explicit agent want support, got bind=%q view=%q", f.lastBindName, bv.Agent)
|
||||
}
|
||||
|
||||
// size required; real launch requires a name.
|
||||
if code, _ := do(t, app, http.MethodPost, "/v1/bots/launch", "acme", map[string]any{"region": "sfo3"}); code != http.StatusBadRequest {
|
||||
t.Fatalf("launch without size want 400, got %d", code)
|
||||
}
|
||||
if code, _ := do(t, app, http.MethodPost, "/v1/bots/launch", "acme", map[string]any{"size": "s-2vcpu-4gb"}); code != http.StatusBadRequest {
|
||||
t.Fatalf("real launch without name want 400, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotListGetDelete(t *testing.T) {
|
||||
f := newBotVM()
|
||||
app := mountBots(t, f, "")
|
||||
// Launch two bots for acme.
|
||||
for _, n := range []string{"a", "b"} {
|
||||
if code, body := do(t, app, http.MethodPost, "/v1/bots/launch", "acme",
|
||||
map[string]any{"size": "s-2vcpu-4gb", "region": "sfo3", "name": n}); code != http.StatusCreated {
|
||||
t.Fatalf("seed launch %s: %d %s", n, code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// list → both bots, kind=bot, each joined with its binding.
|
||||
code, body := do(t, app, http.MethodGet, "/v1/bots", "acme", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("list want 200, got %d %s", code, body)
|
||||
}
|
||||
var listed struct {
|
||||
Bots []botView `json:"bots"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &listed); err != nil {
|
||||
t.Fatalf("shape: %v", err)
|
||||
}
|
||||
if len(listed.Bots) != 2 {
|
||||
t.Fatalf("want 2 bots, got %d", len(listed.Bots))
|
||||
}
|
||||
for _, b := range listed.Bots {
|
||||
if b.Agent == "" || b.Binding == nil {
|
||||
t.Fatalf("listed bot missing joined binding: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// get one → botView.
|
||||
code, body = do(t, app, http.MethodGet, "/v1/bots/drop-a", "acme", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("get want 200, got %d %s", code, body)
|
||||
}
|
||||
var bv botView
|
||||
_ = json.Unmarshal(body, &bv)
|
||||
if bv.Agent != "a" {
|
||||
t.Fatalf("get bot agent want a, got %q", bv.Agent)
|
||||
}
|
||||
|
||||
// a non-bot machine (no kind tag, no binding) is not a bot → 404.
|
||||
f.bots["plain"] = map[string]any{"owner": "acme", "name": "plain", "id": "plain", "state": "running"}
|
||||
if code, _ := do(t, app, http.MethodGet, "/v1/bots/plain", "acme", nil); code != http.StatusNotFound {
|
||||
t.Fatalf("get non-bot want 404, got %d", code)
|
||||
}
|
||||
|
||||
// delete → 204, and the bot unbinds AND the machine is gone.
|
||||
if code, _ := do(t, app, http.MethodDelete, "/v1/bots/drop-a", "acme", nil); code != http.StatusNoContent {
|
||||
t.Fatalf("delete want 204, got %d", code)
|
||||
}
|
||||
if f.lastUnbind != "drop-a" {
|
||||
t.Fatalf("delete must unbind the agent, lastUnbind=%q", f.lastUnbind)
|
||||
}
|
||||
if _, ok := f.bots["drop-a"]; ok {
|
||||
t.Fatalf("delete must terminate the machine")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotStopPause(t *testing.T) {
|
||||
f := newBotVM()
|
||||
app := mountBots(t, f, "")
|
||||
if code, _ := do(t, app, http.MethodPost, "/v1/bots/launch", "acme",
|
||||
map[string]any{"size": "s-2vcpu-4gb", "region": "sfo3", "name": "c"}); code != http.StatusCreated {
|
||||
t.Fatal("seed launch")
|
||||
}
|
||||
// stop → unbind (halt the agent), 200 {status:stopped}. Machine stays.
|
||||
code, body := do(t, app, http.MethodPost, "/v1/bots/drop-c/stop", "acme", nil)
|
||||
if code != http.StatusOK || !strings.Contains(string(body), `"stopped"`) {
|
||||
t.Fatalf("stop want 200 stopped, got %d %s", code, body)
|
||||
}
|
||||
if f.lastUnbind != "drop-c" {
|
||||
t.Fatalf("stop must unbind, lastUnbind=%q", f.lastUnbind)
|
||||
}
|
||||
if _, ok := f.bots["drop-c"]; !ok {
|
||||
t.Fatalf("stop must NOT delete the machine")
|
||||
}
|
||||
// pause routes to the same halt.
|
||||
f.lastUnbind = ""
|
||||
if code, _ := do(t, app, http.MethodPost, "/v1/bots/drop-c/pause", "acme", nil); code != http.StatusOK {
|
||||
t.Fatalf("pause want 200, got %d", code)
|
||||
}
|
||||
if f.lastUnbind != "drop-c" {
|
||||
t.Fatalf("pause must unbind, lastUnbind=%q", f.lastUnbind)
|
||||
}
|
||||
// an unknown action is a clean 400.
|
||||
if code, _ := do(t, app, http.MethodPost, "/v1/bots/drop-c/frobnicate", "acme", nil); code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown action want 400, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotMessageRunsAgent(t *testing.T) {
|
||||
f := newBotVM()
|
||||
// Fake agents surface: records the agent + input, returns a run result.
|
||||
var gotAgent, gotInput string
|
||||
agents := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAgent = strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1/agents/"), "/run")
|
||||
var b struct {
|
||||
Input string `json:"input"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&b)
|
||||
gotInput = b.Input
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ok","output":"pong"}`))
|
||||
}))
|
||||
defer agents.Close()
|
||||
|
||||
app := mountBots(t, f, agents.URL)
|
||||
if code, _ := do(t, app, http.MethodPost, "/v1/bots/launch", "acme",
|
||||
map[string]any{"size": "s-2vcpu-4gb", "region": "sfo3", "name": "chat", "agent": "concierge"}); code != http.StatusCreated {
|
||||
t.Fatal("seed launch")
|
||||
}
|
||||
|
||||
// message → runs the BOUND agent with the caller's input; response passes through.
|
||||
code, body := do(t, app, http.MethodPost, "/v1/bots/drop-chat/message", "acme",
|
||||
map[string]any{"input": "ping"})
|
||||
if code != http.StatusOK || !strings.Contains(string(body), `"pong"`) {
|
||||
t.Fatalf("message want 200 pong, got %d %s", code, body)
|
||||
}
|
||||
if gotAgent != "concierge" || gotInput != "ping" {
|
||||
t.Fatalf("message must run the bound agent: agent=%q input=%q", gotAgent, gotInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMachineAgentBindingProxies(t *testing.T) {
|
||||
f := newBotVM()
|
||||
app := mountBots(t, f, "")
|
||||
// Seed a resell machine to bind against.
|
||||
f.bots["drop-m"] = map[string]any{"owner": "acme", "name": "m", "id": "drop-m", "state": "running"}
|
||||
|
||||
// bind-agent → 200 binding, org forwarded from the validated principal.
|
||||
code, body := do(t, app, http.MethodPost, "/v1/machines/drop-m/bind-agent", "acme",
|
||||
map[string]any{"agentName": "worker", "botVersion": "1.2.3"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("bind want 200, got %d %s", code, body)
|
||||
}
|
||||
var b agentBinding
|
||||
_ = json.Unmarshal(body, &b)
|
||||
if b.AgentName != "worker" || f.lastBindOrg != "acme" || f.lastBindName != "worker" {
|
||||
t.Fatalf("bind mismatch: view=%+v bindOrg=%q", b, f.lastBindOrg)
|
||||
}
|
||||
if code, _ := do(t, app, http.MethodPost, "/v1/machines/drop-m/bind-agent", "acme", map[string]any{}); code != http.StatusBadRequest {
|
||||
t.Fatalf("bind without agentName want 400, got %d", code)
|
||||
}
|
||||
|
||||
// GET the binding → 200; a machine with none → 404.
|
||||
if code, _ := do(t, app, http.MethodGet, "/v1/machines/drop-m/agent-binding", "acme", nil); code != http.StatusOK {
|
||||
t.Fatalf("get binding want 200, got %d", code)
|
||||
}
|
||||
if code, _ := do(t, app, http.MethodGet, "/v1/machines/nope/agent-binding", "acme", nil); code != http.StatusNotFound {
|
||||
t.Fatalf("get missing binding want 404, got %d", code)
|
||||
}
|
||||
|
||||
// list agent-bindings → 200 {agentBindings:[...]} with the one we bound.
|
||||
code, body = do(t, app, http.MethodGet, "/v1/agent-bindings", "acme", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("list bindings want 200, got %d", code)
|
||||
}
|
||||
var out struct {
|
||||
AgentBindings []agentBinding `json:"agentBindings"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &out)
|
||||
if len(out.AgentBindings) != 1 || out.AgentBindings[0].AgentName != "worker" {
|
||||
t.Fatalf("list bindings mismatch: %+v", out.AgentBindings)
|
||||
}
|
||||
|
||||
// DELETE the binding → 204, and it is gone.
|
||||
if code, _ := do(t, app, http.MethodDelete, "/v1/machines/drop-m/agent-binding", "acme", nil); code != http.StatusNoContent {
|
||||
t.Fatalf("unbind want 204, got %d", code)
|
||||
}
|
||||
if f.lastUnbind != "drop-m" || len(f.bindings) != 0 {
|
||||
t.Fatalf("unbind must remove the binding, lastUnbind=%q left=%d", f.lastUnbind, len(f.bindings))
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,11 @@ type visorMachine struct {
|
||||
PublicIp string `json:"publicIp"`
|
||||
PrivateIp string `json:"privateIp"`
|
||||
CpuSize string `json:"cpuSize"`
|
||||
// Tag is the comma-joined provider tag list (the resell /v1/machines surface
|
||||
// carries it). It records `hanzo-kind:<kind>` (kind=bot for a Bot) and, once
|
||||
// the launch cloud-init installs the runtime, `hanzo-bot:<agentName>`. bots.go
|
||||
// reads it to tell a Bot machine from a plain one.
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// visorNodePool mirrors visor/object.NodePool (JSON subset relevant to clusters).
|
||||
|
||||
@@ -24,6 +24,15 @@
|
||||
// POST /v1/clusters/:clusterId/pools add a node pool -> nodePoolView
|
||||
// POST /v1/clusters/:clusterId/pools/:poolId/scale scale a node pool -> nodePoolView
|
||||
// DELETE /v1/clusters/:clusterId/pools/:poolId delete a node pool -> 204
|
||||
// POST /v1/machines/:id/bind-agent bind a cloud Agent to a machine -> agentBinding
|
||||
// GET /v1/machines/:id/agent-binding the machine's agent binding -> agentBinding (404 if none)
|
||||
// DELETE /v1/machines/:id/agent-binding unbind the agent -> 204
|
||||
// GET /v1/agent-bindings the org's agent bindings -> {agentBindings:[agentBinding]}
|
||||
// GET /v1/bots the org's bots (kind=bot) -> {bots:[botView]}
|
||||
// POST /v1/bots/launch launch a bot (machine+bind) -> botView | quote
|
||||
// GET /v1/bots/:id one bot by id -> botView (404 if not a bot)
|
||||
// DELETE /v1/bots/:id terminate a bot (unbind+delete) -> 204
|
||||
// POST /v1/bots/:id/:action stop|pause|message the bot -> action result
|
||||
//
|
||||
// The tenant (principal.Tenant) is passed to Visor as ?owner=<org>, so a caller
|
||||
// can only ever read or mutate their OWN tenant's compute; the org is taken from
|
||||
@@ -81,6 +90,23 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
app.Get("/v1/compute/regions", s.listRegions)
|
||||
app.Get("/v1/compute/sizes", s.listSizes)
|
||||
|
||||
// Agent↔machine binding — thin proxy over vm's binding surface (mark a machine
|
||||
// as running the @hanzo/bot runtime for a cloud Agent). Deeper than
|
||||
// /v1/machines/:id so no machine id captures these literals. See bots.go.
|
||||
app.Post("/v1/machines/:id/bind-agent", s.bindMachineAgent)
|
||||
app.Get("/v1/machines/:id/agent-binding", s.getMachineAgentBinding)
|
||||
app.Delete("/v1/machines/:id/agent-binding", s.unbindMachineAgent)
|
||||
app.Get("/v1/agent-bindings", s.listAgentBindings)
|
||||
|
||||
// Bots — a Bot is a kind=bot machine + an agent binding, composed from the vm
|
||||
// compute + binding surface (bots.go). The sibling of /v1/machines. launch is
|
||||
// an explicit literal, registered before :id so it never binds as an id.
|
||||
app.Get("/v1/bots", s.listBots)
|
||||
app.Post("/v1/bots/launch", s.launchBot)
|
||||
app.Get("/v1/bots/:id", s.getBot)
|
||||
app.Delete("/v1/bots/:id", s.deleteBot)
|
||||
app.Post("/v1/bots/:id/:action", s.botAction)
|
||||
|
||||
s.log.Info("visor compute surface mounted", "target", s.cl.target,
|
||||
"serviceAuth", serviceClientID() != "", "brand", deps.Brand)
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user