- Updated benchmark functions to utilize TaiID instead of pool names for improved consistency and accuracy in tests. - Refactored test cases across various files to ensure compatibility with the new TaiID structure. - Enhanced setup functions to accept pointers to poolConfig for better memory management. - Removed deprecated config struct and adjusted related documentation to reflect the changes in the sandbox architecture. Made-with: Cursor
301 lines
6.9 KiB
Go
301 lines
6.9 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
|
|
maxLifetimeD time.Duration
|
|
stopTimeoutD time.Duration
|
|
createdAt time.Time
|
|
vnc bool
|
|
image string
|
|
workspaceID string
|
|
ws workspace.FS
|
|
manager *Manager
|
|
}
|
|
|
|
// Compile-time check: *Box implements Computer.
|
|
var _ Computer = (*Box)(nil)
|
|
|
|
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 }
|
|
|
|
// ComputerInfo returns identity and registry information for this Box.
|
|
func (b *Box) ComputerInfo() ComputerInfo {
|
|
return ComputerInfo{
|
|
Kind: "box",
|
|
Pool: b.pool,
|
|
Status: "online",
|
|
BoxID: b.id,
|
|
ContainerID: b.containerID,
|
|
Owner: b.owner,
|
|
Image: b.image,
|
|
Policy: b.policy,
|
|
Labels: b.labels,
|
|
}
|
|
}
|
|
|
|
// BindWorkplace binds (or rebinds) a workspace to this Box. Subsequent calls
|
|
// to Workplace() return the FS for this workspace. Overrides the workspace
|
|
// set during Create.
|
|
func (b *Box) BindWorkplace(workspaceID string) {
|
|
b.workspaceID = workspaceID
|
|
b.ws = nil // clear cache so Workplace() re-resolves
|
|
}
|
|
|
|
// Workplace returns the workspace FS bound to this Box.
|
|
// If a workspace was bound via CreateOptions.WorkspaceID or BindWorkplace(),
|
|
// returns that workspace's FS. Otherwise returns nil.
|
|
func (b *Box) Workplace() workspace.FS {
|
|
return b.Workspace()
|
|
}
|
|
|
|
// 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.
|
|
// If a workspace is mounted (WorkspaceID set), uses the workspace ID as session;
|
|
// otherwise falls back to the sandbox ID (backward compatible).
|
|
func (b *Box) Workspace() workspace.FS {
|
|
b.touch()
|
|
if b.ws != nil {
|
|
return b.ws
|
|
}
|
|
sessionID := b.workspaceID
|
|
if sessionID == "" {
|
|
sessionID = b.id
|
|
}
|
|
client, err := b.manager.getPool(b.pool)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
b.ws = client.Workspace(sessionID)
|
|
return b.ws
|
|
}
|
|
|
|
// WorkspaceID returns the workspace ID mounted to this sandbox, or empty string.
|
|
func (b *Box) WorkspaceID() string { return b.workspaceID }
|
|
|
|
// 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, b.stopTimeout())
|
|
}
|
|
|
|
// 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 {
|
|
return b.idleTimeoutD
|
|
}
|
|
|
|
func (b *Box) maxLifetime() time.Duration {
|
|
return b.maxLifetimeD
|
|
}
|
|
|
|
func (b *Box) stopTimeout() time.Duration {
|
|
if b.stopTimeoutD > 0 {
|
|
return b.stopTimeoutD
|
|
}
|
|
return DefaultStopTimeout
|
|
}
|