feat(sandbox): enhance sandbox configuration with display name and identifier updates
- Introduced a human-readable display name for the sandbox, constructed from the agent and workspace names. - Updated the BuildIdentifier function to include workspace ID in identifiers for long-running and persistent lifecycles. - Modified sandbox creation options to incorporate the display name, improving clarity in sandbox management. - Refactored related tests to ensure compatibility with the new identifier structure and display name integration. Made-with: Cursor
This commit is contained in:
parent
c4dbcdba2b
commit
d26bbd1e1f
15 changed files with 231 additions and 55 deletions
|
|
@ -48,7 +48,10 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
|
||||||
return nil, nil, nil, "", fmt.Errorf("get connector: %w", err)
|
return nil, nil, nil, "", fmt.Errorf("get connector: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Obtain Computer (passes connector for OPENAI_PROXY_* env injection).
|
// 2. Build human-readable DisplayName from real Agent name + Workspace name.
|
||||||
|
cfg.DisplayName = buildBoxDisplayName(ctx, ast.ID, ast.Name)
|
||||||
|
|
||||||
|
// 3. Obtain Computer (passes connector for OPENAI_PROXY_* env injection).
|
||||||
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager, conn)
|
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager, conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
|
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
|
||||||
|
|
@ -56,7 +59,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
|
||||||
}
|
}
|
||||||
_ = identifier
|
_ = identifier
|
||||||
|
|
||||||
// 3. Get Runner.
|
// 4. Get Runner.
|
||||||
runner, err := sandboxv2.Get(cfg.Runner.Name)
|
runner, err := sandboxv2.Get(cfg.Runner.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager)
|
sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager)
|
||||||
|
|
@ -64,7 +67,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
|
||||||
return nil, nil, nil, "", fmt.Errorf("get runner %q: %w", cfg.Runner.Name, err)
|
return nil, nil, nil, "", fmt.Errorf("get runner %q: %w", cfg.Runner.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Resolve skills directory.
|
// 5. Resolve skills directory.
|
||||||
skillsDir := ""
|
skillsDir := ""
|
||||||
if ast.Path != "" {
|
if ast.Path != "" {
|
||||||
dir := filepath.Join(config.Conf.AppSource, ast.Path, "skills")
|
dir := filepath.Join(config.Conf.AppSource, ast.Path, "skills")
|
||||||
|
|
@ -73,7 +76,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Convert MCP servers.
|
// 6. Convert MCP servers.
|
||||||
var mcpServers []sandboxTypes.MCPServer
|
var mcpServers []sandboxTypes.MCPServer
|
||||||
if ast.MCP != nil {
|
if ast.MCP != nil {
|
||||||
for _, s := range ast.MCP.Servers {
|
for _, s := range ast.MCP.Servers {
|
||||||
|
|
@ -85,7 +88,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Runner.Prepare (standard context).
|
// 7. Runner.Prepare (standard context).
|
||||||
err = runner.Prepare(stdCtx, &sandboxTypes.PrepareRequest{
|
err = runner.Prepare(stdCtx, &sandboxTypes.PrepareRequest{
|
||||||
Computer: computer,
|
Computer: computer,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
|
|
@ -199,6 +202,34 @@ func (ast *Assistant) initStandaloneWorkspace(ctx *context.Context) {
|
||||||
ctx.SetWorkspace(wsFS)
|
ctx.SetWorkspace(wsFS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildBoxDisplayName constructs a human-readable display name for a Box
|
||||||
|
// using the locale-resolved Agent name and Workspace name (matching the UI list pages).
|
||||||
|
func buildBoxDisplayName(ctx *context.Context, assistantID, rawName string) string {
|
||||||
|
agentName := i18n.Tr(assistantID, ctx.Locale, rawName)
|
||||||
|
|
||||||
|
wsName := ""
|
||||||
|
if ctx.Metadata != nil {
|
||||||
|
if wsID, ok := ctx.Metadata["workspace_id"].(string); ok && wsID != "" {
|
||||||
|
if wsm := workspace.M(); wsm != nil {
|
||||||
|
if ws, err := wsm.Get(ctx.Context, wsID); err == nil && ws != nil {
|
||||||
|
wsName = ws.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if agentName != "" && wsName != "" {
|
||||||
|
return agentName + " / " + wsName
|
||||||
|
}
|
||||||
|
if agentName != "" {
|
||||||
|
return agentName
|
||||||
|
}
|
||||||
|
if wsName != "" {
|
||||||
|
return wsName
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func closeLoadingV2(ctx *context.Context, loadingMsgID, msgKey string) {
|
func closeLoadingV2(ctx *context.Context, loadingMsgID, msgKey string) {
|
||||||
if loadingMsgID == "" || ctx == nil {
|
if loadingMsgID == "" || ctx == nil {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import (
|
||||||
|
|
||||||
// BuildIdentifier determines the Computer identifier based on lifecycle policy
|
// BuildIdentifier determines the Computer identifier based on lifecycle policy
|
||||||
// and optional metadata override. Returns "" for oneshot (always new).
|
// and optional metadata override. Returns "" for oneshot (always new).
|
||||||
func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID string, metadata map[string]any) string {
|
func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID, workspaceID string, metadata map[string]any) string {
|
||||||
if cfg.Lifecycle == "oneshot" {
|
if cfg.Lifecycle == "oneshot" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -25,7 +25,7 @@ func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID stri
|
||||||
// Custom identifier from metadata takes precedence.
|
// Custom identifier from metadata takes precedence.
|
||||||
if metadata != nil {
|
if metadata != nil {
|
||||||
if cid, ok := metadata["computer_id"].(string); ok && cid != "" {
|
if cid, ok := metadata["computer_id"].(string); ok && cid != "" {
|
||||||
return fmt.Sprintf("%s-%s", ownerID, cid)
|
return fmt.Sprintf("%s-%s.%s", ownerID, cid, workspaceID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@ func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID stri
|
||||||
case "session":
|
case "session":
|
||||||
return fmt.Sprintf("%s-%s", ownerID, chatID)
|
return fmt.Sprintf("%s-%s", ownerID, chatID)
|
||||||
case "longrunning", "persistent":
|
case "longrunning", "persistent":
|
||||||
return fmt.Sprintf("%s-%s", ownerID, assistantID)
|
return fmt.Sprintf("%s-%s.%s", ownerID, assistantID, workspaceID)
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -44,11 +44,6 @@ func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID stri
|
||||||
// Returns the Computer, the resolved identifier, and any error.
|
// Returns the Computer, the resolved identifier, and any error.
|
||||||
func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *infra.Manager, conn ...connector.Connector) (infra.Computer, string, error) {
|
func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *infra.Manager, conn ...connector.Connector) (infra.Computer, string, error) {
|
||||||
ownerID := resolveOwnerID(ctx)
|
ownerID := resolveOwnerID(ctx)
|
||||||
identifier := BuildIdentifier(cfg, ownerID, ctx.ChatID, ctx.AssistantID, ctx.Metadata)
|
|
||||||
|
|
||||||
// Fill runtime fields.
|
|
||||||
cfg.Owner = ownerID
|
|
||||||
cfg.ID = identifier
|
|
||||||
|
|
||||||
workspaceID := ""
|
workspaceID := ""
|
||||||
if ctx.Metadata != nil {
|
if ctx.Metadata != nil {
|
||||||
|
|
@ -59,6 +54,12 @@ func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *i
|
||||||
if workspaceID == "" {
|
if workspaceID == "" {
|
||||||
workspaceID = ownerID
|
workspaceID = ownerID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
identifier := BuildIdentifier(cfg, ownerID, ctx.ChatID, ctx.AssistantID, workspaceID, ctx.Metadata)
|
||||||
|
|
||||||
|
// Fill runtime fields.
|
||||||
|
cfg.Owner = ownerID
|
||||||
|
cfg.ID = identifier
|
||||||
cfg.WorkspaceID = workspaceID
|
cfg.WorkspaceID = workspaceID
|
||||||
|
|
||||||
// Resolve computer_id from metadata to determine kind and nodeID.
|
// Resolve computer_id from metadata to determine kind and nodeID.
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import (
|
||||||
|
|
||||||
func TestBuildIdentifier_Oneshot(t *testing.T) {
|
func TestBuildIdentifier_Oneshot(t *testing.T) {
|
||||||
cfg := &types.SandboxConfig{Lifecycle: "oneshot"}
|
cfg := &types.SandboxConfig{Lifecycle: "oneshot"}
|
||||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", nil)
|
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", "ws1", nil)
|
||||||
if id != "" {
|
if id != "" {
|
||||||
t.Errorf("oneshot should return empty, got %q", id)
|
t.Errorf("oneshot should return empty, got %q", id)
|
||||||
}
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ func TestBuildIdentifier_Oneshot(t *testing.T) {
|
||||||
|
|
||||||
func TestBuildIdentifier_Session(t *testing.T) {
|
func TestBuildIdentifier_Session(t *testing.T) {
|
||||||
cfg := &types.SandboxConfig{Lifecycle: "session"}
|
cfg := &types.SandboxConfig{Lifecycle: "session"}
|
||||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat42", "ast1", nil)
|
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat42", "ast1", "ws1", nil)
|
||||||
if id != "owner1-chat42" {
|
if id != "owner1-chat42" {
|
||||||
t.Errorf("session: got %q, want %q", id, "owner1-chat42")
|
t.Errorf("session: got %q, want %q", id, "owner1-chat42")
|
||||||
}
|
}
|
||||||
|
|
@ -36,33 +36,33 @@ func TestBuildIdentifier_Session(t *testing.T) {
|
||||||
|
|
||||||
func TestBuildIdentifier_Longrunning(t *testing.T) {
|
func TestBuildIdentifier_Longrunning(t *testing.T) {
|
||||||
cfg := &types.SandboxConfig{Lifecycle: "longrunning"}
|
cfg := &types.SandboxConfig{Lifecycle: "longrunning"}
|
||||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast99", nil)
|
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast99", "ws1", nil)
|
||||||
if id != "owner1-ast99" {
|
if id != "owner1-ast99.ws1" {
|
||||||
t.Errorf("longrunning: got %q, want %q", id, "owner1-ast99")
|
t.Errorf("longrunning: got %q, want %q", id, "owner1-ast99.ws1")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildIdentifier_Persistent(t *testing.T) {
|
func TestBuildIdentifier_Persistent(t *testing.T) {
|
||||||
cfg := &types.SandboxConfig{Lifecycle: "persistent"}
|
cfg := &types.SandboxConfig{Lifecycle: "persistent"}
|
||||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast99", nil)
|
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast99", "ws1", nil)
|
||||||
if id != "owner1-ast99" {
|
if id != "owner1-ast99.ws1" {
|
||||||
t.Errorf("persistent: got %q, want %q", id, "owner1-ast99")
|
t.Errorf("persistent: got %q, want %q", id, "owner1-ast99.ws1")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildIdentifier_MetadataOverride(t *testing.T) {
|
func TestBuildIdentifier_MetadataOverride(t *testing.T) {
|
||||||
cfg := &types.SandboxConfig{Lifecycle: "session"}
|
cfg := &types.SandboxConfig{Lifecycle: "session"}
|
||||||
meta := map[string]any{"computer_id": "custom-box"}
|
meta := map[string]any{"computer_id": "custom-box"}
|
||||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", meta)
|
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", "ws1", meta)
|
||||||
if id != "owner1-custom-box" {
|
if id != "owner1-custom-box.ws1" {
|
||||||
t.Errorf("metadata override: got %q, want %q", id, "owner1-custom-box")
|
t.Errorf("metadata override: got %q, want %q", id, "owner1-custom-box.ws1")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildIdentifier_MetadataEmptyIgnored(t *testing.T) {
|
func TestBuildIdentifier_MetadataEmptyIgnored(t *testing.T) {
|
||||||
cfg := &types.SandboxConfig{Lifecycle: "session"}
|
cfg := &types.SandboxConfig{Lifecycle: "session"}
|
||||||
meta := map[string]any{"computer_id": ""}
|
meta := map[string]any{"computer_id": ""}
|
||||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat42", "ast1", meta)
|
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat42", "ast1", "ws1", meta)
|
||||||
if id != "owner1-chat42" {
|
if id != "owner1-chat42" {
|
||||||
t.Errorf("empty metadata should fall through to session, got %q", id)
|
t.Errorf("empty metadata should fall through to session, got %q", id)
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +70,7 @@ func TestBuildIdentifier_MetadataEmptyIgnored(t *testing.T) {
|
||||||
|
|
||||||
func TestBuildIdentifier_UnknownLifecycle(t *testing.T) {
|
func TestBuildIdentifier_UnknownLifecycle(t *testing.T) {
|
||||||
cfg := &types.SandboxConfig{Lifecycle: "unknown"}
|
cfg := &types.SandboxConfig{Lifecycle: "unknown"}
|
||||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", nil)
|
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", "ws1", nil)
|
||||||
if id != "" {
|
if id != "" {
|
||||||
t.Errorf("unknown lifecycle should return empty, got %q", id)
|
t.Errorf("unknown lifecycle should return empty, got %q", id)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspace
|
||||||
MountMode: cfg.Computer.MountMode,
|
MountMode: cfg.Computer.MountMode,
|
||||||
WorkspaceID: workspaceID,
|
WorkspaceID: workspaceID,
|
||||||
Labels: cfg.Labels,
|
Labels: cfg.Labels,
|
||||||
|
DisplayName: cfg.DisplayName,
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Labels == nil {
|
if opts.Labels == nil {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ type SandboxConfig struct {
|
||||||
NodeID string `json:"-" yaml:"-"`
|
NodeID string `json:"-" yaml:"-"`
|
||||||
Kind string `json:"-" yaml:"-"`
|
Kind string `json:"-" yaml:"-"`
|
||||||
WorkspaceID string `json:"-" yaml:"-"`
|
WorkspaceID string `json:"-" yaml:"-"`
|
||||||
|
DisplayName string `json:"-" yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComputerFilter defines the query parameters for GET /computer/options.
|
// ComputerFilter defines the query parameters for GET /computer/options.
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@ type computerOption struct {
|
||||||
Image string `json:"image,omitempty"`
|
Image string `json:"image,omitempty"`
|
||||||
Policy string `json:"policy,omitempty"`
|
Policy string `json:"policy,omitempty"`
|
||||||
VNC bool `json:"vnc"`
|
VNC bool `json:"vnc"`
|
||||||
Labels map[string]string `json:"labels,omitempty"`
|
|
||||||
System computerSystemInfo `json:"system"`
|
System computerSystemInfo `json:"system"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,7 +254,10 @@ func boxToOption(b *sandboxv2.Box) computerOption {
|
||||||
snap := b.Snapshot()
|
snap := b.Snapshot()
|
||||||
info := b.ComputerInfo()
|
info := b.ComputerInfo()
|
||||||
|
|
||||||
displayName := info.System.Hostname
|
displayName := info.DisplayName
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = info.System.Hostname
|
||||||
|
}
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = snap.ID
|
displayName = snap.ID
|
||||||
}
|
}
|
||||||
|
|
@ -285,7 +287,6 @@ func boxToOption(b *sandboxv2.Box) computerOption {
|
||||||
Image: snap.Image,
|
Image: snap.Image,
|
||||||
Policy: string(snap.Policy),
|
Policy: string(snap.Policy),
|
||||||
VNC: snap.VNC,
|
VNC: snap.VNC,
|
||||||
Labels: snap.Labels,
|
|
||||||
System: computerSystemInfo{
|
System: computerSystemInfo{
|
||||||
OS: info.System.OS,
|
OS: info.System.OS,
|
||||||
Arch: info.System.Arch,
|
Arch: info.System.Arch,
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,6 @@ type sandboxResponse struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Policy string `json:"policy,omitempty"`
|
Policy string `json:"policy,omitempty"`
|
||||||
Labels map[string]string `json:"labels,omitempty"`
|
|
||||||
Image string `json:"image,omitempty"`
|
Image string `json:"image,omitempty"`
|
||||||
Mode string `json:"mode,omitempty"`
|
Mode string `json:"mode,omitempty"`
|
||||||
Addr string `json:"addr,omitempty"`
|
Addr string `json:"addr,omitempty"`
|
||||||
|
|
@ -110,7 +109,10 @@ func boxToResponse(b *sandboxv2.Box) sandboxResponse {
|
||||||
snap := b.Snapshot()
|
snap := b.Snapshot()
|
||||||
info := b.ComputerInfo()
|
info := b.ComputerInfo()
|
||||||
|
|
||||||
displayName := info.System.Hostname
|
displayName := info.DisplayName
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = info.System.Hostname
|
||||||
|
}
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = snap.ID
|
displayName = snap.ID
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +139,6 @@ func boxToResponse(b *sandboxv2.Box) sandboxResponse {
|
||||||
Owner: snap.Owner,
|
Owner: snap.Owner,
|
||||||
Status: snap.Status,
|
Status: snap.Status,
|
||||||
Policy: string(snap.Policy),
|
Policy: string(snap.Policy),
|
||||||
Labels: snap.Labels,
|
|
||||||
Image: snap.Image,
|
Image: snap.Image,
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ type Box struct {
|
||||||
image string
|
image string
|
||||||
workspaceID string
|
workspaceID string
|
||||||
system SystemInfo
|
system SystemInfo
|
||||||
|
displayName string
|
||||||
workDir string
|
workDir string
|
||||||
ws taiworkspace.FS
|
ws taiworkspace.FS
|
||||||
manager *Manager
|
manager *Manager
|
||||||
|
|
@ -56,6 +57,7 @@ func (b *Box) ComputerInfo() ComputerInfo {
|
||||||
Image: b.image,
|
Image: b.image,
|
||||||
Policy: b.policy,
|
Policy: b.policy,
|
||||||
Labels: b.labels,
|
Labels: b.labels,
|
||||||
|
DisplayName: b.displayName,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ package sandbox
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
goruntime "runtime"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -172,7 +175,9 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
|
||||||
return nil, fmt.Errorf("sandbox: node %q has no container runtime", nodeID)
|
return nil, fmt.Errorf("sandbox: node %q has no container runtime", nodeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
taiOpts := m.buildTaiCreateOptions(opts, nodeID, id)
|
sys := inferSystemInfo(ctx, res, opts.Image)
|
||||||
|
|
||||||
|
taiOpts := m.buildTaiCreateOptions(opts, nodeID, id, sys)
|
||||||
|
|
||||||
containerID, err := res.Runtime.Create(ctx, taiOpts)
|
containerID, err := res.Runtime.Create(ctx, taiOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -189,16 +194,6 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
|
||||||
policy = Session
|
policy = Session
|
||||||
}
|
}
|
||||||
|
|
||||||
sys := SystemInfo{
|
|
||||||
OS: res.System.OS,
|
|
||||||
Arch: res.System.Arch,
|
|
||||||
Hostname: res.System.Hostname,
|
|
||||||
NumCPU: res.System.NumCPU,
|
|
||||||
TotalMem: res.System.TotalMem,
|
|
||||||
Shell: res.System.Shell,
|
|
||||||
TempDir: res.System.TempDir,
|
|
||||||
}
|
|
||||||
|
|
||||||
boxWorkDir := opts.WorkDir
|
boxWorkDir := opts.WorkDir
|
||||||
if boxWorkDir == "" {
|
if boxWorkDir == "" {
|
||||||
boxWorkDir = "/workspace"
|
boxWorkDir = "/workspace"
|
||||||
|
|
@ -220,6 +215,7 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
|
||||||
image: opts.Image,
|
image: opts.Image,
|
||||||
workspaceID: opts.WorkspaceID,
|
workspaceID: opts.WorkspaceID,
|
||||||
workDir: boxWorkDir,
|
workDir: boxWorkDir,
|
||||||
|
displayName: opts.DisplayName,
|
||||||
system: sys,
|
system: sys,
|
||||||
}
|
}
|
||||||
box.lastCall.Store(time.Now().UnixMilli())
|
box.lastCall.Store(time.Now().UnixMilli())
|
||||||
|
|
@ -348,7 +344,7 @@ func (m *Manager) getNode(name string) (*tai.ConnResources, error) {
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID string) tairuntime.CreateOptions {
|
func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID string, sys SystemInfo) tairuntime.CreateOptions {
|
||||||
env := make(map[string]string)
|
env := make(map[string]string)
|
||||||
|
|
||||||
reg := registry.Global()
|
reg := registry.Global()
|
||||||
|
|
@ -378,6 +374,27 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID st
|
||||||
if opts.WorkspaceID != "" {
|
if opts.WorkspaceID != "" {
|
||||||
labels["workspace-id"] = opts.WorkspaceID
|
labels["workspace-id"] = opts.WorkspaceID
|
||||||
}
|
}
|
||||||
|
if opts.DisplayName != "" {
|
||||||
|
labels["sandbox-display-name"] = opts.DisplayName
|
||||||
|
}
|
||||||
|
if sys.OS != "" {
|
||||||
|
labels["sandbox-sys-os"] = sys.OS
|
||||||
|
}
|
||||||
|
if sys.Arch != "" {
|
||||||
|
labels["sandbox-sys-arch"] = sys.Arch
|
||||||
|
}
|
||||||
|
if sys.Hostname != "" {
|
||||||
|
labels["sandbox-sys-hostname"] = sys.Hostname
|
||||||
|
}
|
||||||
|
if sys.NumCPU > 0 {
|
||||||
|
labels["sandbox-sys-numcpu"] = strconv.Itoa(sys.NumCPU)
|
||||||
|
}
|
||||||
|
if sys.TotalMem > 0 {
|
||||||
|
labels["sandbox-sys-totalmem"] = strconv.FormatInt(sys.TotalMem, 10)
|
||||||
|
}
|
||||||
|
if sys.Shell != "" {
|
||||||
|
labels["sandbox-sys-shell"] = sys.Shell
|
||||||
|
}
|
||||||
for k, v := range opts.Labels {
|
for k, v := range opts.Labels {
|
||||||
labels[k] = v
|
labels[k] = v
|
||||||
}
|
}
|
||||||
|
|
@ -467,6 +484,10 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sys := systemInfoFromLabels(c.Labels)
|
||||||
|
if sys.OS == "" {
|
||||||
|
sys = inferSystemInfo(ctx, res, c.Image)
|
||||||
|
}
|
||||||
box := &Box{
|
box := &Box{
|
||||||
id: sandboxID,
|
id: sandboxID,
|
||||||
containerID: cid,
|
containerID: cid,
|
||||||
|
|
@ -479,6 +500,8 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn
|
||||||
workspaceID: c.Labels["workspace-id"],
|
workspaceID: c.Labels["workspace-id"],
|
||||||
vnc: hasVNC,
|
vnc: hasVNC,
|
||||||
workDir: "/workspace",
|
workDir: "/workspace",
|
||||||
|
displayName: c.Labels["sandbox-display-name"],
|
||||||
|
system: sys,
|
||||||
manager: m,
|
manager: m,
|
||||||
}
|
}
|
||||||
box.lastCall.Store(time.Now().UnixMilli())
|
box.lastCall.Store(time.Now().UnixMilli())
|
||||||
|
|
@ -486,6 +509,51 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inferSystemInfo derives static SystemInfo for a container from image metadata
|
||||||
|
// and Tai host resources. OS/Arch/Shell come from the image; Hostname/NumCPU/TotalMem
|
||||||
|
// come from the Tai host.
|
||||||
|
func inferSystemInfo(ctx context.Context, res *tai.ConnResources, imageRef string) SystemInfo {
|
||||||
|
sys := SystemInfo{
|
||||||
|
Hostname: res.System.Hostname,
|
||||||
|
NumCPU: res.System.NumCPU,
|
||||||
|
TotalMem: res.System.TotalMem,
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.Image != nil {
|
||||||
|
meta, err := res.Image.Inspect(ctx, imageRef)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[sandbox/v2] image inspect %q: %v (using fallback)", imageRef, err)
|
||||||
|
}
|
||||||
|
if meta != nil {
|
||||||
|
sys.OS = meta.OS
|
||||||
|
sys.Arch = meta.Arch
|
||||||
|
sys.Shell = meta.Shell
|
||||||
|
return sys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sys.OS = "linux"
|
||||||
|
sys.Arch = goruntime.GOARCH
|
||||||
|
sys.Shell = "bash"
|
||||||
|
return sys
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemInfoFromLabels restores SystemInfo from Docker container labels that
|
||||||
|
// were persisted at creation time, so recovery doesn't depend on the Tai node
|
||||||
|
// being connected.
|
||||||
|
func systemInfoFromLabels(labels map[string]string) SystemInfo {
|
||||||
|
numCPU, _ := strconv.Atoi(labels["sandbox-sys-numcpu"])
|
||||||
|
totalMem, _ := strconv.ParseInt(labels["sandbox-sys-totalmem"], 10, 64)
|
||||||
|
return SystemInfo{
|
||||||
|
OS: labels["sandbox-sys-os"],
|
||||||
|
Arch: labels["sandbox-sys-arch"],
|
||||||
|
Hostname: labels["sandbox-sys-hostname"],
|
||||||
|
NumCPU: numCPU,
|
||||||
|
TotalMem: totalMem,
|
||||||
|
Shell: labels["sandbox-sys-shell"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ImageExists reports whether the given image ref exists on the target node.
|
// ImageExists reports whether the given image ref exists on the target node.
|
||||||
func (m *Manager) ImageExists(ctx context.Context, nodeID, ref string) (bool, error) {
|
func (m *Manager) ImageExists(ctx context.Context, nodeID, ref string) (bool, error) {
|
||||||
res, err := m.getNode(nodeID)
|
res, err := m.getNode(nodeID)
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ type ComputerInfo struct {
|
||||||
Image string
|
Image string
|
||||||
Policy LifecyclePolicy
|
Policy LifecyclePolicy
|
||||||
Labels map[string]string
|
Labels map[string]string
|
||||||
|
DisplayName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemInfo describes the hardware and environment of a Tai node.
|
// SystemInfo describes the hardware and environment of a Tai node.
|
||||||
|
|
@ -104,6 +105,7 @@ type CreateOptions struct {
|
||||||
WorkspaceID string
|
WorkspaceID string
|
||||||
MountMode string
|
MountMode string
|
||||||
MountPath string
|
MountPath string
|
||||||
|
DisplayName string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListOptions struct {
|
type ListOptions struct {
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,20 @@ import (
|
||||||
// Image manages container images on a runtime node.
|
// Image manages container images on a runtime node.
|
||||||
type Image interface {
|
type Image interface {
|
||||||
Exists(ctx context.Context, ref string) (bool, error)
|
Exists(ctx context.Context, ref string) (bool, error)
|
||||||
|
Inspect(ctx context.Context, ref string) (*ImageMeta, error)
|
||||||
Pull(ctx context.Context, ref string, opts PullOptions) (<-chan PullProgress, error)
|
Pull(ctx context.Context, ref string, opts PullOptions) (<-chan PullProgress, error)
|
||||||
Remove(ctx context.Context, ref string, force bool) error
|
Remove(ctx context.Context, ref string, force bool) error
|
||||||
List(ctx context.Context) ([]ImageInfo, error)
|
List(ctx context.Context) ([]ImageInfo, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ImageMeta holds static metadata extracted from a container image.
|
||||||
|
type ImageMeta struct {
|
||||||
|
OS string // "linux", "windows"
|
||||||
|
Arch string // "amd64", "arm64"
|
||||||
|
Shell string // preferred shell: "bash", "sh", "cmd.exe", "pwsh"
|
||||||
|
WorkDir string // default working directory from Dockerfile WORKDIR
|
||||||
|
}
|
||||||
|
|
||||||
// PullOptions configures an image pull operation.
|
// PullOptions configures an image pull operation.
|
||||||
type PullOptions struct {
|
type PullOptions struct {
|
||||||
Auth *RegistryAuth // nil = anonymous / public
|
Auth *RegistryAuth // nil = anonymous / public
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
|
|
@ -35,6 +36,44 @@ func (d *dockerImage) Exists(ctx context.Context, ref string) (bool, error) {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *dockerImage) Inspect(ctx context.Context, ref string) (*ImageMeta, error) {
|
||||||
|
inspect, _, err := d.cli.ImageInspectWithRaw(ctx, ref)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("image inspect %q: %w", ref, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := &ImageMeta{
|
||||||
|
OS: inspect.Os,
|
||||||
|
Arch: inspect.Architecture,
|
||||||
|
}
|
||||||
|
|
||||||
|
if inspect.Config != nil {
|
||||||
|
meta.WorkDir = inspect.Config.WorkingDir
|
||||||
|
|
||||||
|
if len(inspect.Config.Shell) > 0 {
|
||||||
|
meta.Shell = inspect.Config.Shell[0]
|
||||||
|
}
|
||||||
|
if meta.Shell == "" {
|
||||||
|
for _, e := range inspect.Config.Env {
|
||||||
|
if strings.HasPrefix(e, "SHELL=") {
|
||||||
|
meta.Shell = e[6:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.Shell == "" {
|
||||||
|
if strings.EqualFold(meta.OS, "windows") {
|
||||||
|
meta.Shell = "cmd.exe"
|
||||||
|
} else {
|
||||||
|
meta.Shell = "bash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d *dockerImage) Pull(ctx context.Context, ref string, opts PullOptions) (<-chan PullProgress, error) {
|
func (d *dockerImage) Pull(ctx context.Context, ref string, opts PullOptions) (<-chan PullProgress, error) {
|
||||||
pullOpts := image.PullOptions{}
|
pullOpts := image.PullOptions{}
|
||||||
if opts.Auth != nil {
|
if opts.Auth != nil {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ func (k *k8sImage) Exists(_ context.Context, _ string) (bool, error) {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (k *k8sImage) Inspect(_ context.Context, _ string) (*ImageMeta, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (k *k8sImage) Pull(_ context.Context, _ string, _ PullOptions) (<-chan PullProgress, error) {
|
func (k *k8sImage) Pull(_ context.Context, _ string, _ PullOptions) (<-chan PullProgress, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,17 +113,26 @@ func resolveTargetPort(c *gin.Context, node *types.NodeMeta) int {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// rewriteRequest clones the request and strips everything up to and including
|
// rewriteRequest clones the request and strips the Yao-side route prefix,
|
||||||
// /tai/:taiID from the path, handling any baseURL prefix (e.g. /v1/tai/abc/proxy/x → /proxy/x).
|
// leaving only what the Tai-side handler expects.
|
||||||
|
//
|
||||||
|
// The Tai httpproxy expects /{containerID}:{port}/..., so the /proxy prefix
|
||||||
|
// is stripped. The Tai VNC router expects /vnc/{containerID}/ws, so the /vnc
|
||||||
|
// prefix is kept.
|
||||||
|
//
|
||||||
|
// /v1/tai/abc/proxy/cid:8080/foo → /cid:8080/foo
|
||||||
|
// /v1/tai/abc/vnc/cid/ws → /vnc/cid/ws
|
||||||
func rewriteRequest(orig *http.Request, taiID string) *http.Request {
|
func rewriteRequest(orig *http.Request, taiID string) *http.Request {
|
||||||
r := orig.Clone(orig.Context())
|
r := orig.Clone(orig.Context())
|
||||||
|
|
||||||
marker := "/tai/" + taiID
|
marker := "/tai/" + taiID
|
||||||
if idx := strings.Index(r.URL.Path, marker); idx >= 0 {
|
if idx := strings.Index(r.URL.Path, marker); idx >= 0 {
|
||||||
r.URL.Path = r.URL.Path[idx+len(marker):]
|
rest := r.URL.Path[idx+len(marker):]
|
||||||
if r.URL.Path == "" {
|
rest = strings.TrimPrefix(rest, "/proxy")
|
||||||
r.URL.Path = "/"
|
if rest == "" {
|
||||||
|
rest = "/"
|
||||||
}
|
}
|
||||||
|
r.URL.Path = rest
|
||||||
}
|
}
|
||||||
|
|
||||||
r.RequestURI = r.URL.RequestURI()
|
r.RequestURI = r.URL.RequestURI()
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,8 @@ func TestRewriteRequest(t *testing.T) {
|
||||||
"proxy_path",
|
"proxy_path",
|
||||||
"/tai/abc123/proxy/api/v1/data",
|
"/tai/abc123/proxy/api/v1/data",
|
||||||
"abc123",
|
"abc123",
|
||||||
"/proxy/api/v1/data",
|
"/api/v1/data",
|
||||||
"/proxy/api/v1/data",
|
"/api/v1/data",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"vnc_path",
|
"vnc_path",
|
||||||
|
|
@ -98,8 +98,8 @@ func TestRewriteRequest(t *testing.T) {
|
||||||
"with_query",
|
"with_query",
|
||||||
"/tai/node-1/proxy/api?foo=bar",
|
"/tai/node-1/proxy/api?foo=bar",
|
||||||
"node-1",
|
"node-1",
|
||||||
"/proxy/api",
|
"/api",
|
||||||
"/proxy/api?foo=bar",
|
"/api?foo=bar",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"exact_prefix",
|
"exact_prefix",
|
||||||
|
|
@ -112,8 +112,8 @@ func TestRewriteRequest(t *testing.T) {
|
||||||
"with_base_url",
|
"with_base_url",
|
||||||
"/v1/tai/node-1/proxy/api/v1/data",
|
"/v1/tai/node-1/proxy/api/v1/data",
|
||||||
"node-1",
|
"node-1",
|
||||||
"/proxy/api/v1/data",
|
"/api/v1/data",
|
||||||
"/proxy/api/v1/data",
|
"/api/v1/data",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"with_base_url_vnc",
|
"with_base_url_vnc",
|
||||||
|
|
@ -122,6 +122,13 @@ func TestRewriteRequest(t *testing.T) {
|
||||||
"/vnc/__host__/ws",
|
"/vnc/__host__/ws",
|
||||||
"/vnc/__host__/ws",
|
"/vnc/__host__/ws",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"proxy_container_port",
|
||||||
|
"/v1/tai/abc/proxy/cid123:8080/foo",
|
||||||
|
"abc",
|
||||||
|
"/cid123:8080/foo",
|
||||||
|
"/cid123:8080/foo",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"no_match",
|
"no_match",
|
||||||
"/other/path",
|
"/other/path",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue