yao/sandbox/v2/manager.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

519 lines
11 KiB
Go

package sandbox
import (
"context"
"fmt"
"sync"
"time"
"github.com/yaoapp/yao/tai"
taisandbox "github.com/yaoapp/yao/tai/sandbox"
)
// Manager manages a pool of tai.Client connections and sandbox lifecycle.
type Manager struct {
pool map[string]*tai.Client
poolDefs []Pool
defaultPool string
config Config
boxes sync.Map
mu sync.Mutex
cancel context.CancelFunc
grpcPort int
}
func newManager(cfg Config) (*Manager, error) {
m := &Manager{
pool: make(map[string]*tai.Client),
poolDefs: cfg.Pool,
config: cfg,
grpcPort: 9099,
}
if len(cfg.Pool) > 0 {
m.defaultPool = cfg.Pool[0].Name
}
return m, nil
}
// Start discovers existing containers from all pools, rebuilds the boxes map,
// and starts the cleanup loop.
func (m *Manager) Start(ctx context.Context) error {
if len(m.poolDefs) == 0 {
return nil
}
for _, pd := range m.poolDefs {
client, err := m.getPool(pd.Name)
if err != nil {
continue
}
m.recoverBoxes(ctx, &pd, client)
}
loopCtx, cancel := context.WithCancel(ctx)
m.cancel = cancel
go m.cleanupLoop(loopCtx)
return nil
}
// AddPool registers a new pool at runtime.
func (m *Manager) AddPool(_ context.Context, p Pool) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, pd := range m.poolDefs {
if pd.Name == p.Name {
return fmt.Errorf("sandbox: pool %q already exists", p.Name)
}
}
m.poolDefs = append(m.poolDefs, p)
if m.defaultPool == "" {
m.defaultPool = p.Name
}
return nil
}
// RemovePool removes a pool by name.
func (m *Manager) RemovePool(ctx context.Context, name string, force bool) error {
m.mu.Lock()
defer m.mu.Unlock()
idx := -1
for i, pd := range m.poolDefs {
if pd.Name == name {
idx = i
break
}
}
if idx < 0 {
return ErrPoolNotFound
}
count := 0
m.boxes.Range(func(_, value any) bool {
if value.(*Box).pool == name {
count++
}
return true
})
if count > 0 && !force {
return ErrPoolInUse
}
if count > 0 {
m.boxes.Range(func(key, value any) bool {
b := value.(*Box)
if b.pool == name {
b.Remove(ctx)
}
return true
})
}
m.poolDefs = append(m.poolDefs[:idx], m.poolDefs[idx+1:]...)
if client, ok := m.pool[name]; ok {
client.Close()
delete(m.pool, name)
}
return nil
}
// Pools returns all registered pool names and their status.
func (m *Manager) Pools() []PoolInfo {
m.mu.Lock()
defer m.mu.Unlock()
result := make([]PoolInfo, 0, len(m.poolDefs))
for _, pd := range m.poolDefs {
_, connected := m.pool[pd.Name]
count := 0
m.boxes.Range(func(_, value any) bool {
if value.(*Box).pool == pd.Name {
count++
}
return true
})
result = append(result, PoolInfo{
Name: pd.Name,
Addr: pd.Addr,
Connected: connected,
Boxes: count,
MaxPerUser: pd.MaxPerUser,
MaxTotal: pd.MaxTotal,
IdleTimeout: pd.IdleTimeout,
MaxLifetime: pd.MaxLifetime,
})
}
return result
}
// Heartbeat updates the box's last heartbeat timestamp.
func (m *Manager) Heartbeat(sandboxID string, active bool, processCount int) error {
v, ok := m.boxes.Load(sandboxID)
if !ok {
return ErrNotFound
}
b := v.(*Box)
if active {
b.lastHeartbeat.Store(time.Now().UnixMilli())
}
b.processCount.Store(int32(processCount))
return nil
}
// Create creates and starts a new sandbox.
func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) {
if len(m.poolDefs) == 0 {
return nil, ErrNotAvailable
}
if opts.Image == "" {
return nil, fmt.Errorf("sandbox: image is required")
}
poolName := opts.Pool
if poolName == "" {
poolName = m.defaultPool
}
pd := m.findPoolDef(poolName)
if pd == nil {
return nil, ErrPoolNotFound
}
if err := m.checkLimits(pd, opts.Owner); err != nil {
return nil, err
}
id := opts.ID
if id == "" {
id = fmt.Sprintf("sb-%d", time.Now().UnixNano())
}
client, err := m.getPool(poolName)
if err != nil {
return nil, fmt.Errorf("sandbox: connect pool %q: %w", poolName, err)
}
access, refresh, err := CreateContainerTokens(id, opts.Owner, nil)
if err != nil {
return nil, fmt.Errorf("sandbox: create tokens: %w", err)
}
taiOpts := m.buildTaiCreateOptions(opts, pd, id, access, refresh)
containerID, err := client.Sandbox().Create(ctx, taiOpts)
if err != nil {
return nil, fmt.Errorf("sandbox: create container: %w", err)
}
if err := client.Sandbox().Start(ctx, containerID); err != nil {
client.Sandbox().Remove(ctx, containerID, true)
return nil, fmt.Errorf("sandbox: start container: %w", err)
}
policy := opts.Policy
if policy == "" {
policy = Session
}
box := &Box{
id: id,
containerID: containerID,
pool: poolName,
owner: opts.Owner,
policy: policy,
labels: opts.Labels,
idleTimeoutD: opts.IdleTimeout,
createdAt: time.Now(),
refreshToken: refresh,
manager: m,
vnc: opts.VNC,
image: opts.Image,
}
box.lastCall.Store(time.Now().UnixMilli())
m.boxes.Store(id, box)
return box, nil
}
// Get returns an existing sandbox by ID.
func (m *Manager) Get(_ context.Context, id string) (*Box, error) {
v, ok := m.boxes.Load(id)
if !ok {
return nil, ErrNotFound
}
return v.(*Box), nil
}
// GetOrCreate returns existing sandbox by ID or creates a new one.
func (m *Manager) GetOrCreate(ctx context.Context, opts CreateOptions) (*Box, error) {
if opts.ID != "" {
if v, ok := m.boxes.Load(opts.ID); ok {
return v.(*Box), nil
}
}
return m.Create(ctx, opts)
}
// List returns all sandboxes, optionally filtered.
func (m *Manager) List(_ context.Context, opts ListOptions) ([]*Box, error) {
var result []*Box
m.boxes.Range(func(_, value any) bool {
b := value.(*Box)
if opts.Owner != "" && b.owner != opts.Owner {
return true
}
if opts.Pool != "" && b.pool != opts.Pool {
return true
}
if len(opts.Labels) > 0 {
for k, v := range opts.Labels {
if b.labels[k] != v {
return true
}
}
}
result = append(result, b)
return true
})
return result, nil
}
// Remove stops and removes a sandbox.
func (m *Manager) Remove(ctx context.Context, id string) error {
v, ok := m.boxes.Load(id)
if !ok {
return ErrNotFound
}
b := v.(*Box)
client, err := m.getPool(b.pool)
if err == nil {
client.Sandbox().Stop(ctx, b.containerID, 10*time.Second)
client.Sandbox().Remove(ctx, b.containerID, true)
}
if b.refreshToken != "" {
RevokeContainerTokens(b.refreshToken)
}
m.boxes.Delete(id)
return nil
}
// Cleanup removes idle/expired sandboxes.
func (m *Manager) Cleanup(ctx context.Context) error {
now := time.Now()
m.boxes.Range(func(key, value any) bool {
b := value.(*Box)
idle := now.Sub(b.lastActiveTime())
switch b.policy {
case OneShot:
// handled after Exec
case Session:
if timeout := b.idleTimeout(); timeout > 0 && idle > timeout {
m.Remove(ctx, b.id)
}
case LongRunning:
if timeout := b.idleTimeout(); timeout > 0 && idle > timeout {
if client, err := m.getPool(b.pool); err == nil {
client.Sandbox().Stop(ctx, b.containerID, 10*time.Second)
}
}
if lifetime := b.maxLifetime(); lifetime > 0 && now.Sub(b.createdAt) > lifetime {
m.Remove(ctx, b.id)
}
case Persistent:
// never auto-cleaned
}
return true
})
return nil
}
// Close stops the cleanup loop and releases all pool connections.
func (m *Manager) Close() error {
if m.cancel != nil {
m.cancel()
}
m.mu.Lock()
defer m.mu.Unlock()
for name, client := range m.pool {
client.Close()
delete(m.pool, name)
}
return nil
}
// SetGRPCPort sets the local gRPC port for container env injection.
func (m *Manager) SetGRPCPort(port int) {
m.grpcPort = port
}
func (m *Manager) cleanupLoop(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.Cleanup(ctx)
case <-ctx.Done():
return
}
}
}
func (m *Manager) getPool(name string) (*tai.Client, error) {
m.mu.Lock()
defer m.mu.Unlock()
if client, ok := m.pool[name]; ok {
return client, nil
}
pd := m.findPoolDefLocked(name)
if pd == nil {
return nil, ErrPoolNotFound
}
client, err := tai.New(pd.Addr, pd.Options...)
if err != nil {
return nil, err
}
m.pool[name] = client
return client, nil
}
func (m *Manager) findPoolDef(name string) *Pool {
m.mu.Lock()
defer m.mu.Unlock()
return m.findPoolDefLocked(name)
}
func (m *Manager) findPoolDefLocked(name string) *Pool {
for i := range m.poolDefs {
if m.poolDefs[i].Name == name {
return &m.poolDefs[i]
}
}
return nil
}
func (m *Manager) checkLimits(pd *Pool, owner string) error {
if pd.MaxTotal > 0 {
count := 0
m.boxes.Range(func(_, value any) bool {
if value.(*Box).pool == pd.Name {
count++
}
return true
})
if count >= pd.MaxTotal {
return ErrLimitExceeded
}
}
if pd.MaxPerUser > 0 && owner != "" {
count := 0
m.boxes.Range(func(_, value any) bool {
b := value.(*Box)
if b.pool == pd.Name && b.owner == owner {
count++
}
return true
})
if count >= pd.MaxPerUser {
return ErrLimitExceeded
}
}
return nil
}
func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID, access, refresh string) taisandbox.CreateOptions {
env := make(map[string]string)
for k, v := range opts.Env {
env[k] = v
}
grpcEnv := BuildGRPCEnv(pd, sandboxID, access, refresh, m.grpcPort)
for k, v := range grpcEnv {
env[k] = v
}
labels := map[string]string{
"managed-by": "yao-sandbox",
"sandbox-id": sandboxID,
"sandbox-owner": opts.Owner,
"sandbox-pool": pd.Name,
"sandbox-policy": string(opts.Policy),
}
for k, v := range opts.Labels {
labels[k] = v
}
workDir := opts.WorkDir
if workDir == "" {
workDir = "/workspace"
}
cmd := []string{"sleep", "infinity"}
var ports []taisandbox.PortMapping
for _, p := range opts.Ports {
ports = append(ports, taisandbox.PortMapping{
ContainerPort: p.ContainerPort,
HostPort: p.HostPort,
HostIP: p.HostIP,
Protocol: p.Protocol,
})
}
return taisandbox.CreateOptions{
Name: sandboxID,
Image: opts.Image,
Cmd: cmd,
Env: env,
WorkingDir: workDir,
User: opts.User,
Memory: opts.Memory,
CPUs: opts.CPUs,
VNC: opts.VNC,
Ports: ports,
Labels: labels,
}
}
func (m *Manager) recoverBoxes(ctx context.Context, pd *Pool, client *tai.Client) {
containers, err := client.Sandbox().List(ctx, taisandbox.ListOptions{
All: true,
Labels: map[string]string{"managed-by": "yao-sandbox"},
})
if err != nil {
return
}
for _, c := range containers {
sandboxID := c.Labels["sandbox-id"]
if sandboxID == "" {
continue
}
if _, loaded := m.boxes.Load(sandboxID); loaded {
continue
}
box := &Box{
id: sandboxID,
containerID: c.ID,
pool: c.Labels["sandbox-pool"],
owner: c.Labels["sandbox-owner"],
policy: LifecyclePolicy(c.Labels["sandbox-policy"]),
labels: c.Labels,
createdAt: time.Now(),
image: c.Image,
manager: m,
}
box.lastCall.Store(time.Now().UnixMilli())
m.boxes.Store(sandboxID, box)
}
}