yao/sandbox/v2/box.go
Max dfb33681f9 Implement Sandbox V2 support in the Yao SDK
- Add new gRPC endpoint for Heartbeat in the Yao service, enabling communication with the sandbox.
- Update Makefile to include a dedicated unit test target for Sandbox V2, ensuring proper testing of new features.
- Enhance CI workflows to incorporate Sandbox V2 tests, allowing for dual-mode testing (local and remote) with Docker.
- Modify .gitignore to exclude specific Docker files while allowing shell scripts for Sandbox V2.
- Update documentation in DESIGN.md to reflect the new architecture and capabilities of the Sandbox V2.

These changes enhance the Yao SDK's functionality, providing improved support for sandbox operations and testing.
2026-03-05 13:21:09 +08:00

261 lines
5.5 KiB
Go

package sandbox
import (
"context"
"io"
"sync/atomic"
"time"
"github.com/yaoapp/yao/tai/proxy"
taisandbox "github.com/yaoapp/yao/tai/sandbox"
"github.com/yaoapp/yao/tai/workspace"
)
// Box represents a single sandbox instance.
type Box struct {
id string
containerID string
pool string
owner string
policy LifecyclePolicy
labels map[string]string
lastCall atomic.Int64
lastHeartbeat atomic.Int64
processCount atomic.Int32
idleTimeoutD time.Duration
createdAt time.Time
refreshToken string
vnc bool
image string
ws workspace.FS
manager *Manager
}
func (b *Box) ID() string { return b.id }
func (b *Box) Owner() string { return b.owner }
func (b *Box) ContainerID() string { return b.containerID }
func (b *Box) Pool() string { return b.pool }
// Exec runs a command and waits for it to finish.
func (b *Box) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) {
b.touch()
cfg := &execConfig{}
for _, o := range opts {
o(cfg)
}
client, err := b.manager.getPool(b.pool)
if err != nil {
return nil, err
}
result, err := client.Sandbox().Exec(ctx, b.containerID, cmd, taisandbox.ExecOptions{
WorkDir: cfg.WorkDir,
Env: cfg.Env,
})
if err != nil {
return nil, err
}
r := &ExecResult{
ExitCode: result.ExitCode,
Stdout: result.Stdout,
Stderr: result.Stderr,
}
if b.policy == OneShot {
b.manager.Remove(ctx, b.id)
}
return r, nil
}
// Stream runs a command with real-time streaming I/O.
func (b *Box) Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecStream, error) {
b.touch()
cfg := &execConfig{}
for _, o := range opts {
o(cfg)
}
client, err := b.manager.getPool(b.pool)
if err != nil {
return nil, err
}
handle, err := client.Sandbox().ExecStream(ctx, b.containerID, cmd, taisandbox.ExecOptions{
WorkDir: cfg.WorkDir,
Env: cfg.Env,
})
if err != nil {
return nil, err
}
return &ExecStream{
Stdout: io.NopCloser(handle.Stdout),
Stderr: io.NopCloser(handle.Stderr),
Stdin: handle.Stdin,
Wait: handle.Wait,
Cancel: handle.Cancel,
}, nil
}
// Attach connects to a service running inside the sandbox on the given container port.
func (b *Box) Attach(ctx context.Context, port int, opts ...AttachOption) (*ServiceConn, error) {
b.touch()
cfg := &attachConfig{Protocol: "ws"}
for _, o := range opts {
o(cfg)
}
client, err := b.manager.getPool(b.pool)
if err != nil {
return nil, err
}
conn, err := client.Proxy().Connect(ctx, b.containerID, proxy.ConnectOptions{
Port: port,
Path: cfg.Path,
Protocol: cfg.Protocol,
})
if err != nil {
return nil, err
}
sc := &ServiceConn{
Write: conn.Send,
Events: conn.Messages,
Close: conn.Close,
}
if cfg.Protocol == "ws" {
ch := conn.Messages
sc.Read = func() ([]byte, error) {
msg, ok := <-ch
if !ok {
return nil, io.EOF
}
return msg, nil
}
}
return sc, nil
}
// Workspace returns an fs.FS-compatible filesystem for this sandbox.
func (b *Box) Workspace() workspace.FS {
b.touch()
if b.ws != nil {
return b.ws
}
client, err := b.manager.getPool(b.pool)
if err != nil {
return nil
}
b.ws = client.Workspace(b.id)
return b.ws
}
// VNC returns the VNC WebSocket URL.
func (b *Box) VNC(ctx context.Context) (string, error) {
b.touch()
client, err := b.manager.getPool(b.pool)
if err != nil {
return "", err
}
return client.VNC().URL(ctx, b.containerID)
}
// Proxy returns the HTTP URL for a service on the given port inside the sandbox.
func (b *Box) Proxy(ctx context.Context, port int, path string) (string, error) {
b.touch()
client, err := b.manager.getPool(b.pool)
if err != nil {
return "", err
}
return client.Proxy().URL(ctx, b.containerID, port, path)
}
// Start starts a stopped sandbox.
func (b *Box) Start(ctx context.Context) error {
client, err := b.manager.getPool(b.pool)
if err != nil {
return err
}
return client.Sandbox().Start(ctx, b.containerID)
}
// Stop stops the sandbox without removing it.
func (b *Box) Stop(ctx context.Context) error {
client, err := b.manager.getPool(b.pool)
if err != nil {
return err
}
return client.Sandbox().Stop(ctx, b.containerID, 10*time.Second)
}
// Remove stops and removes the sandbox.
func (b *Box) Remove(ctx context.Context) error {
return b.manager.Remove(ctx, b.id)
}
// Info returns current sandbox status.
func (b *Box) Info(ctx context.Context) (*BoxInfo, error) {
client, err := b.manager.getPool(b.pool)
if err != nil {
return nil, err
}
info, err := client.Sandbox().Inspect(ctx, b.containerID)
if err != nil {
return nil, err
}
return &BoxInfo{
ID: b.id,
ContainerID: b.containerID,
Pool: b.pool,
Owner: b.owner,
Status: info.Status,
Policy: b.policy,
Labels: b.labels,
Image: info.Image,
CreatedAt: b.createdAt,
LastActive: b.lastActiveTime(),
ProcessCount: int(b.processCount.Load()),
VNC: b.vnc,
}, nil
}
func (b *Box) touch() {
b.lastCall.Store(time.Now().UnixMilli())
}
func (b *Box) lastActiveTime() time.Time {
call := b.lastCall.Load()
hb := b.lastHeartbeat.Load()
ts := call
if hb > ts {
ts = hb
}
return time.UnixMilli(ts)
}
func (b *Box) idleTimeout() time.Duration {
if b.idleTimeoutD > 0 {
return b.idleTimeoutD
}
pd := b.manager.findPoolDef(b.pool)
if pd != nil {
return pd.IdleTimeout
}
return 0
}
func (b *Box) maxLifetime() time.Duration {
pd := b.manager.findPoolDef(b.pool)
if pd != nil {
return pd.MaxLifetime
}
return 0
}