- 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.
261 lines
5.5 KiB
Go
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
|
|
}
|