yao/sandbox/v2/docs/API.md
Max e633640998 refactor(sandbox/v2): transition from pool to node configuration
- Updated benchmark and test functions to utilize node configurations instead of pool configurations for improved clarity and consistency.
- Refactored related setup functions and test cases to align with the new node-based architecture.
- Adjusted error messages and documentation to reflect the transition from pool to node terminology.

Made-with: Cursor
2026-03-09 03:20:09 +08:00

714 lines
17 KiB
Markdown

# Sandbox V2 — Go API Reference
Package: `github.com/yaoapp/yao/sandbox/v2`
Sandbox V2 manages sandboxes through a set of Tai nodes. Two primary abstractions:
- **Box** — a container (Docker or K8s pod). Created via `Manager.Create`.
- **Host** — the Tai host machine itself. Obtained via `Manager.Host` (no Create needed).
Supports workspace mounting, VNC, WebSocket proxying, and HostExec.
---
## Initialization
### Init
```go
func Init()
```
Initializes the global Manager singleton. Must be called once at startup.
No configuration is needed — node discovery is handled by `tai/registry`.
```go
sandbox.Init()
```
### M
```go
func M() *Manager
```
Returns the global Manager. Panics if `Init` was not called.
```go
mgr := sandbox.M()
```
---
## Node Discovery
Sandbox V2 no longer uses static node configuration. Nodes are discovered dynamically
through `tai/registry`. Each Tai node registers itself with a unique **TaiID** (e.g.
`"192.168.1.10-19100"` for direct mode, `"local"` for Docker). The TaiID is used as the
`NodeID` identifier in `CreateOptions`, `ListOptions`, `Host()`, `ImageExists()`, etc.
---
## Lifecycle Policies
```go
type LifecyclePolicy string
const (
OneShot LifecyclePolicy = "oneshot" // removed after first Exec
Session LifecyclePolicy = "session" // removed after idle timeout
LongRunning LifecyclePolicy = "longrunning" // stopped after idle, removed after max lifetime
Persistent LifecyclePolicy = "persistent" // never auto-cleaned
)
```
---
## Manager
### Start
```go
func (m *Manager) Start(ctx context.Context) error
```
Recovers existing containers from all nodes and starts the background cleanup loop (1 min interval).
```go
ctx := context.Background()
err := sandbox.M().Start(ctx)
```
### Close
```go
func (m *Manager) Close() error
```
Stops the cleanup loop and closes all node connections.
### Create
```go
func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
```
Creates and starts a new sandbox container. Returns a `Box` handle.
```go
box, err := sandbox.M().Create(ctx, sandbox.CreateOptions{
Image: "alpine:latest",
Owner: "user-123",
NodeID: "192.168.1.10-19100", // TaiID from registry
Policy: sandbox.Session,
WorkDir: "/workspace",
Env: map[string]string{"LANG": "en_US.UTF-8"},
Memory: 512 * 1024 * 1024, // 512MB
CPUs: 1.0,
VNC: true,
Labels: map[string]string{"project": "demo"},
Ports: []sandbox.PortMapping{
{ContainerPort: 8080, HostPort: 0, Protocol: "tcp"},
},
IdleTimeout: 15 * time.Minute,
StopTimeout: 3 * time.Second,
WorkspaceID: "ws-abc",
MountMode: "rw",
MountPath: "/workspace",
})
```
### Host
```go
func (m *Manager) Host(ctx context.Context, nodeID string) (*Host, error)
```
Returns a `Host` handle for the given node (identified by TaiID). Unlike `Create`, no
container is provisioned — the Host is available as long as the Tai server reports
`host_exec` capability. Returns `ErrNodeNotFound` if the TaiID is not registered,
`ErrNodeMissing` if the nodeID argument is empty, or an error if the node has no `host_exec`.
```go
host, err := sandbox.M().Host(ctx, "192.168.1.10-19100")
```
### Get
```go
func (m *Manager) Get(ctx context.Context, id string) (*Box, error)
```
Returns an existing sandbox by ID. Returns `ErrNotFound` if absent.
```go
box, err := sandbox.M().Get(ctx, "sb-12345")
```
### GetOrCreate
```go
func (m *Manager) GetOrCreate(ctx context.Context, opts CreateOptions) (*Box, error)
```
Returns existing sandbox by `opts.ID` or creates a new one.
```go
box, err := sandbox.M().GetOrCreate(ctx, sandbox.CreateOptions{
ID: "sb-session-xyz",
Image: "alpine:latest",
Owner: "user-123",
})
```
### List
```go
func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Box, error)
```
Returns all sandboxes matching the given filters. Empty fields = no filter.
```go
boxes, err := sandbox.M().List(ctx, sandbox.ListOptions{
Owner: "user-123",
NodeID: "192.168.1.10-19100",
Labels: map[string]string{"project": "demo"},
})
```
### Remove
```go
func (m *Manager) Remove(ctx context.Context, id string) error
```
Force-removes a sandbox (SIGKILL + delete).
```go
err := sandbox.M().Remove(ctx, "sb-12345")
```
### Cleanup
```go
func (m *Manager) Cleanup(ctx context.Context) error
```
Removes idle/expired sandboxes based on lifecycle policies. Called automatically by
the cleanup loop, but can also be invoked manually.
### Heartbeat
```go
func (m *Manager) Heartbeat(sandboxID string, active bool, processCount int) error
```
Updates a sandbox's last-active timestamp. Called by the gRPC heartbeat service.
```go
err := sandbox.M().Heartbeat("sb-12345", true, 3)
```
### Nodes
```go
func (m *Manager) Nodes() []registry.NodeSnapshot
```
Returns all registered Tai nodes from the `tai/registry`.
```go
for _, n := range sandbox.M().Nodes() {
fmt.Printf("tai_id=%s mode=%s addr=%s status=%s\n",
n.TaiID, n.Mode, n.Addr, n.Status)
}
```
### ImageExists
```go
func (m *Manager) ImageExists(ctx context.Context, nodeID, ref string) (bool, error)
```
Reports whether the given image ref exists on the target node.
Returns `(true, nil)` when the node has no image service (e.g. K8s — kubelet handles pulls).
```go
exists, err := sandbox.M().ImageExists(ctx, "192.168.1.10-19100", "alpine:latest")
```
### PullImage
```go
func (m *Manager) PullImage(ctx context.Context, nodeID, ref string, opts ImagePullOptions) (<-chan taisandbox.PullProgress, error)
```
Pulls an image to the target node. Returns a channel of `taisandbox.PullProgress`
(from `github.com/yaoapp/yao/tai/sandbox`). Returns `(nil, nil)` when the node has no image
service (e.g. K8s).
`PullProgress` fields: `Status string`, `Layer string`, `Current int64`, `Total int64`, `Error string`.
```go
ch, err := sandbox.M().PullImage(ctx, "192.168.1.10-19100", "myapp:v2", sandbox.ImagePullOptions{
Auth: &sandbox.RegistryAuth{
Username: "user",
Password: "pass",
Server: "registry.example.com",
},
})
for p := range ch {
fmt.Printf("pull: %s layer=%s %d/%d\n", p.Status, p.Layer, p.Current, p.Total)
}
```
### EnsureImage
```go
func (m *Manager) EnsureImage(ctx context.Context, nodeID, ref string, opts ImagePullOptions) error
```
Checks if the image exists; if not, pulls it and blocks until complete.
```go
err := sandbox.M().EnsureImage(ctx, "192.168.1.10-19100", "alpine:latest", sandbox.ImagePullOptions{})
```
---
## Box
A `Box` is a handle to a running sandbox container.
### Accessors
```go
func (b *Box) ID() string
func (b *Box) Owner() string
func (b *Box) ContainerID() string
func (b *Box) NodeID() string
func (b *Box) WorkspaceID() string
```
### Exec
```go
func (b *Box) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error)
```
Runs a command and waits for completion. If the box policy is `OneShot`, the box is
auto-removed after execution.
```go
result, err := box.Exec(ctx, []string{"python3", "-c", "print('hello')"},
sandbox.WithWorkDir("/workspace"),
sandbox.WithEnv(map[string]string{"PYTHONPATH": "/lib"}),
sandbox.WithTimeout(30*time.Second),
)
fmt.Printf("exit=%d stdout=%s stderr=%s\n", result.ExitCode, result.Stdout, result.Stderr)
```
### Stream
```go
func (b *Box) Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecStream, error)
```
Runs a command with real-time streaming I/O.
```go
stream, err := box.Stream(ctx, []string{"bash"})
go io.Copy(os.Stdout, stream.Stdout)
go io.Copy(os.Stderr, stream.Stderr)
fmt.Fprintln(stream.Stdin, "echo hello")
stream.Stdin.Close()
exitCode, _ := stream.Wait()
```
### Attach
```go
func (b *Box) Attach(ctx context.Context, port int, opts ...AttachOption) (*ServiceConn, error)
```
Connects to a service running inside the sandbox via WebSocket proxy.
```go
conn, err := box.Attach(ctx, 8080,
sandbox.WithProtocol("ws"),
sandbox.WithPath("/api/stream"),
sandbox.WithHeaders(map[string]string{"Authorization": "Bearer xxx"}),
)
defer conn.Close()
conn.Write([]byte(`{"action":"subscribe"}`))
data, _ := conn.Read()
```
### VNC
```go
func (b *Box) VNC(ctx context.Context) (string, error)
```
Returns the VNC WebSocket URL for the sandbox (requires `VNC: true` at creation).
```go
url, err := box.VNC(ctx)
// url = "ws://tai-host:16080/vnc/xxx/ws"
```
### Proxy
```go
func (b *Box) Proxy(ctx context.Context, port int, path string) (string, error)
```
Returns the HTTP proxy URL for a service on the given port.
```go
url, err := box.Proxy(ctx, 3000, "/api/health")
// url = "http://tai-host:8099/container-id:3000/api/health"
```
### Workspace
```go
func (b *Box) Workspace() workspace.FS
```
Returns a `workspace.FS` interface (`github.com/yaoapp/yao/tai/workspace`) for file
operations on the sandbox's workspace volume. The interface embeds `fs.FS`, `fs.StatFS`,
`fs.ReadFileFS`, `fs.ReadDirFS`, `io.Closer`, and adds write methods (`WriteFile`,
`Remove`, `RemoveAll`, `Rename`, `MkdirAll`).
```go
ws := box.Workspace()
data, _ := ws.ReadFile("main.py")
ws.WriteFile("output.txt", []byte("result"), 0644)
ws.MkdirAll("src/pkg", 0755)
ws.Remove("tmp.log")
```
### Start / Stop / Remove
```go
func (b *Box) Start(ctx context.Context) error
func (b *Box) Stop(ctx context.Context) error
func (b *Box) Remove(ctx context.Context) error
```
```go
box.Stop(ctx) // SIGTERM with grace period, then SIGKILL
box.Start(ctx) // restart a stopped sandbox
box.Remove(ctx) // force remove
```
### Info
```go
func (b *Box) Info(ctx context.Context) (*BoxInfo, error)
```
Returns current sandbox status from the underlying container runtime.
```go
info, err := box.Info(ctx)
fmt.Printf("status=%s processes=%d vnc=%v created=%s\n",
info.Status, info.ProcessCount, info.VNC, info.CreatedAt)
```
---
## Host
A `Host` represents a Tai host machine execution environment, distinct from `Box` (containers).
No `Create` call is needed — a Host is available as long as the node's Tai server reports `host_exec`.
### Accessors
```go
func (h *Host) NodeID() string
```
### Exec
```go
func (h *Host) Exec(ctx context.Context, cmd string, args []string, opts ...HostExecOption) (*HostExecResult, error)
```
Runs a command directly on the Tai host machine via HostExec gRPC.
```go
host, _ := sandbox.M().Host(ctx, "192.168.1.10-19100")
result, err := host.Exec(ctx, "git", []string{"status"},
sandbox.WithHostWorkDir("/data/repos/project"),
sandbox.WithHostEnv(map[string]string{"GIT_AUTHOR_NAME": "bot"}),
sandbox.WithHostTimeout(10000), // 10s
sandbox.WithHostMaxOutput(1024*1024), // 1MB
)
fmt.Printf("exit=%d stdout=%s duration=%dms\n",
result.ExitCode, string(result.Stdout), result.DurationMs)
```
### Stream
```go
func (h *Host) Stream(ctx context.Context, cmd string, args []string, opts ...HostExecOption) (*HostExecStream, error)
```
Runs a command on the Tai host and streams stdout/stderr in real time via HostExec gRPC
ExecStream. Returns a `HostExecStream` with separate channels for stdout and stderr.
```go
host, _ := sandbox.M().Host(ctx, "192.168.1.10-19100")
stream, err := host.Stream(ctx, "tail", []string{"-f", "/var/log/app.log"},
sandbox.WithHostWorkDir("/data"),
sandbox.WithHostTimeout(60000),
)
go func() {
for chunk := range stream.Stderr {
fmt.Fprintf(os.Stderr, "%s", chunk)
}
}()
for chunk := range stream.Stdout {
fmt.Printf("%s", chunk)
}
exitCode, err := stream.Wait()
```
To cancel a long-running stream early:
```go
stream.Cancel()
```
### Workspace
```go
func (h *Host) Workspace(sessionID string) workspace.FS
```
Returns a `workspace.FS` for the given session on the host. Files are stored under
`dataDir/{sessionID}/` on the Tai host, accessed via Volume gRPC (independent of container
bind mounts).
```go
ws := host.Workspace("ws-abc")
ws.WriteFile("input.txt", []byte("data"), 0644)
data, _ := ws.ReadFile("output.txt")
entries, _ := ws.ReadDir(".")
```
---
## ExecOption Functions
```go
func WithWorkDir(dir string) ExecOption
func WithEnv(env map[string]string) ExecOption
func WithTimeout(timeout time.Duration) ExecOption
```
## AttachOption Functions
```go
func WithProtocol(protocol string) AttachOption // "ws" (default) or "sse"
func WithPath(path string) AttachOption // URL path on the target service
func WithHeaders(headers map[string]string) AttachOption
```
## HostExecOption Functions
```go
func WithHostWorkDir(dir string) HostExecOption
func WithHostEnv(env map[string]string) HostExecOption
func WithHostStdin(data []byte) HostExecOption
func WithHostTimeout(ms int64) HostExecOption
func WithHostMaxOutput(bytes int64) HostExecOption
```
---
## Types
### CreateOptions
```go
type CreateOptions struct {
ID string
Owner string
Labels map[string]string
NodeID string // TaiID from registry (required unless WorkspaceID routes to a node)
Image string // required
WorkDir string // default "/workspace"
User string // container user
Env map[string]string
Memory int64 // bytes; 0 = unlimited
CPUs float64 // 0 = unlimited
VNC bool
Ports []PortMapping
Policy LifecyclePolicy // default Session
IdleTimeout time.Duration // 0 = no idle cleanup
MaxLifetime time.Duration // 0 = no max lifetime
StopTimeout time.Duration // SIGTERM grace period; 0 = DefaultStopTimeout (2s)
WorkspaceID string // workspace to mount; empty = none
MountMode string // "rw" (default) or "ro"
MountPath string // default "/workspace"
}
```
### ListOptions
```go
type ListOptions struct {
Owner string
NodeID string
Labels map[string]string
}
```
### PortMapping
```go
type PortMapping struct {
ContainerPort int
HostPort int // 0 = auto-assign
HostIP string
Protocol string // "tcp" (default), "udp"
}
```
### ExecResult
```go
type ExecResult struct {
ExitCode int
Stdout string
Stderr string
}
```
### ExecStream
```go
type ExecStream struct {
Stdout io.ReadCloser
Stderr io.ReadCloser
Stdin io.WriteCloser
Wait func() (int, error) // blocks until exit; returns exit code
Cancel func() // kills the process
}
```
### ServiceConn
```go
type ServiceConn struct {
Read func() ([]byte, error)
Write func(data []byte) error
Events <-chan []byte
URL string
Close func() error
}
```
### BoxInfo
```go
type BoxInfo struct {
ID string
ContainerID string
NodeID string
Owner string
Status string // "running", "stopped", etc.
Policy LifecyclePolicy
Labels map[string]string
Image string
CreatedAt time.Time
LastActive time.Time
ProcessCount int
VNC bool
}
```
### ImagePullOptions / RegistryAuth
```go
type ImagePullOptions struct {
Auth *RegistryAuth // nil = anonymous
}
type RegistryAuth struct {
Username string
Password string
Server string
}
```
### HostExecResult
```go
type HostExecResult struct {
ExitCode int
Stdout []byte
Stderr []byte
DurationMs int64
Error string
Truncated bool
}
```
### HostExecStream
```go
type HostExecStream struct {
Stdout <-chan []byte
Stderr <-chan []byte
Wait func() (int, error) // blocks until exit; returns exit code
Cancel func() // cancels the stream context
}
```
---
## Errors
```go
var (
ErrNotAvailable = errors.New("sandbox: not available (no nodes registered)")
ErrNotFound = errors.New("sandbox: not found")
ErrNodeNotFound = errors.New("sandbox: node not found")
ErrNodeMissing = errors.New("sandbox: node ID is required")
)
```
---
## Helper Functions
### BuildGRPCEnv
```go
func BuildGRPCEnv(mode, addr, sandboxID string) map[string]string
```
Builds environment variables injected into sandbox containers. The gRPC port is read from
`config.Conf.GRPC.Port` (defaults to `9099`).
- `mode` — the `TaiNode.Mode` (`"local"`, `"direct"`, `"tunnel"`)
- `addr` — the `TaiNode.Addr` (e.g. `"tai://192.168.1.10:19100"` for direct mode)
- `sandboxID` — the container's sandbox identifier
| Variable | Description |
|------------------|------------------------------------|
| `YAO_SANDBOX_ID` | Sandbox identifier |
| `YAO_GRPC_ADDR` | gRPC server address (auto-derived) |
Address derivation logic:
- `local``host.docker.internal:<grpcPort>`
- `direct` with `tai://host:port``host:port`
- `tunnel``127.0.0.1:<grpcPort>`
Token injection (`YAO_TOKEN`, `YAO_REFRESH_TOKEN`) is the **caller's responsibility** via
`CreateOptions.Env`. See IMPL.md "OAuth Decoupling" for details.