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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
|
||||
|
|
@ -56,7 +59,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
|
|||
}
|
||||
_ = identifier
|
||||
|
||||
// 3. Get Runner.
|
||||
// 4. Get Runner.
|
||||
runner, err := sandboxv2.Get(cfg.Runner.Name)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// 4. Resolve skills directory.
|
||||
// 5. Resolve skills directory.
|
||||
skillsDir := ""
|
||||
if ast.Path != "" {
|
||||
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
|
||||
if ast.MCP != nil {
|
||||
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{
|
||||
Computer: computer,
|
||||
Config: cfg,
|
||||
|
|
@ -199,6 +202,34 @@ func (ast *Assistant) initStandaloneWorkspace(ctx *context.Context) {
|
|||
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) {
|
||||
if loadingMsgID == "" || ctx == nil {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import (
|
|||
|
||||
// BuildIdentifier determines the Computer identifier based on lifecycle policy
|
||||
// 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" {
|
||||
return ""
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID stri
|
|||
// Custom identifier from metadata takes precedence.
|
||||
if metadata != nil {
|
||||
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":
|
||||
return fmt.Sprintf("%s-%s", ownerID, chatID)
|
||||
case "longrunning", "persistent":
|
||||
return fmt.Sprintf("%s-%s", ownerID, assistantID)
|
||||
return fmt.Sprintf("%s-%s.%s", ownerID, assistantID, workspaceID)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
|
@ -44,11 +44,6 @@ func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID stri
|
|||
// 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) {
|
||||
ownerID := resolveOwnerID(ctx)
|
||||
identifier := BuildIdentifier(cfg, ownerID, ctx.ChatID, ctx.AssistantID, ctx.Metadata)
|
||||
|
||||
// Fill runtime fields.
|
||||
cfg.Owner = ownerID
|
||||
cfg.ID = identifier
|
||||
|
||||
workspaceID := ""
|
||||
if ctx.Metadata != nil {
|
||||
|
|
@ -59,6 +54,12 @@ func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *i
|
|||
if workspaceID == "" {
|
||||
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
|
||||
|
||||
// Resolve computer_id from metadata to determine kind and nodeID.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import (
|
|||
|
||||
func TestBuildIdentifier_Oneshot(t *testing.T) {
|
||||
cfg := &types.SandboxConfig{Lifecycle: "oneshot"}
|
||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", nil)
|
||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", "ws1", nil)
|
||||
if 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) {
|
||||
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" {
|
||||
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) {
|
||||
cfg := &types.SandboxConfig{Lifecycle: "longrunning"}
|
||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast99", nil)
|
||||
if id != "owner1-ast99" {
|
||||
t.Errorf("longrunning: got %q, want %q", id, "owner1-ast99")
|
||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast99", "ws1", nil)
|
||||
if id != "owner1-ast99.ws1" {
|
||||
t.Errorf("longrunning: got %q, want %q", id, "owner1-ast99.ws1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIdentifier_Persistent(t *testing.T) {
|
||||
cfg := &types.SandboxConfig{Lifecycle: "persistent"}
|
||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast99", nil)
|
||||
if id != "owner1-ast99" {
|
||||
t.Errorf("persistent: got %q, want %q", id, "owner1-ast99")
|
||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast99", "ws1", nil)
|
||||
if id != "owner1-ast99.ws1" {
|
||||
t.Errorf("persistent: got %q, want %q", id, "owner1-ast99.ws1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIdentifier_MetadataOverride(t *testing.T) {
|
||||
cfg := &types.SandboxConfig{Lifecycle: "session"}
|
||||
meta := map[string]any{"computer_id": "custom-box"}
|
||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", meta)
|
||||
if id != "owner1-custom-box" {
|
||||
t.Errorf("metadata override: got %q, want %q", id, "owner1-custom-box")
|
||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", "ws1", meta)
|
||||
if id != "owner1-custom-box.ws1" {
|
||||
t.Errorf("metadata override: got %q, want %q", id, "owner1-custom-box.ws1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIdentifier_MetadataEmptyIgnored(t *testing.T) {
|
||||
cfg := &types.SandboxConfig{Lifecycle: "session"}
|
||||
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" {
|
||||
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) {
|
||||
cfg := &types.SandboxConfig{Lifecycle: "unknown"}
|
||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", nil)
|
||||
id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", "ws1", nil)
|
||||
if 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,
|
||||
WorkspaceID: workspaceID,
|
||||
Labels: cfg.Labels,
|
||||
DisplayName: cfg.DisplayName,
|
||||
}
|
||||
|
||||
if opts.Labels == nil {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ type SandboxConfig struct {
|
|||
NodeID string `json:"-" yaml:"-"`
|
||||
Kind string `json:"-" yaml:"-"`
|
||||
WorkspaceID string `json:"-" yaml:"-"`
|
||||
DisplayName string `json:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
// ComputerFilter defines the query parameters for GET /computer/options.
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ type computerOption struct {
|
|||
Image string `json:"image,omitempty"`
|
||||
Policy string `json:"policy,omitempty"`
|
||||
VNC bool `json:"vnc"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
System computerSystemInfo `json:"system"`
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +254,10 @@ func boxToOption(b *sandboxv2.Box) computerOption {
|
|||
snap := b.Snapshot()
|
||||
info := b.ComputerInfo()
|
||||
|
||||
displayName := info.System.Hostname
|
||||
displayName := info.DisplayName
|
||||
if displayName == "" {
|
||||
displayName = info.System.Hostname
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = snap.ID
|
||||
}
|
||||
|
|
@ -285,7 +287,6 @@ func boxToOption(b *sandboxv2.Box) computerOption {
|
|||
Image: snap.Image,
|
||||
Policy: string(snap.Policy),
|
||||
VNC: snap.VNC,
|
||||
Labels: snap.Labels,
|
||||
System: computerSystemInfo{
|
||||
OS: info.System.OS,
|
||||
Arch: info.System.Arch,
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@ type sandboxResponse struct {
|
|||
Owner string `json:"owner"`
|
||||
Status string `json:"status"`
|
||||
Policy string `json:"policy,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Addr string `json:"addr,omitempty"`
|
||||
|
|
@ -110,7 +109,10 @@ func boxToResponse(b *sandboxv2.Box) sandboxResponse {
|
|||
snap := b.Snapshot()
|
||||
info := b.ComputerInfo()
|
||||
|
||||
displayName := info.System.Hostname
|
||||
displayName := info.DisplayName
|
||||
if displayName == "" {
|
||||
displayName = info.System.Hostname
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = snap.ID
|
||||
}
|
||||
|
|
@ -137,7 +139,6 @@ func boxToResponse(b *sandboxv2.Box) sandboxResponse {
|
|||
Owner: snap.Owner,
|
||||
Status: snap.Status,
|
||||
Policy: string(snap.Policy),
|
||||
Labels: snap.Labels,
|
||||
Image: snap.Image,
|
||||
Mode: mode,
|
||||
Addr: addr,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ type Box struct {
|
|||
image string
|
||||
workspaceID string
|
||||
system SystemInfo
|
||||
displayName string
|
||||
workDir string
|
||||
ws taiworkspace.FS
|
||||
manager *Manager
|
||||
|
|
@ -56,6 +57,7 @@ func (b *Box) ComputerInfo() ComputerInfo {
|
|||
Image: b.image,
|
||||
Policy: b.policy,
|
||||
Labels: b.labels,
|
||||
DisplayName: b.displayName,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ package sandbox
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
goruntime "runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"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)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
|
|
@ -189,16 +194,6 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
|
|||
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
|
||||
if boxWorkDir == "" {
|
||||
boxWorkDir = "/workspace"
|
||||
|
|
@ -220,6 +215,7 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
|
|||
image: opts.Image,
|
||||
workspaceID: opts.WorkspaceID,
|
||||
workDir: boxWorkDir,
|
||||
displayName: opts.DisplayName,
|
||||
system: sys,
|
||||
}
|
||||
box.lastCall.Store(time.Now().UnixMilli())
|
||||
|
|
@ -348,7 +344,7 @@ func (m *Manager) getNode(name string) (*tai.ConnResources, error) {
|
|||
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)
|
||||
|
||||
reg := registry.Global()
|
||||
|
|
@ -378,6 +374,27 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID st
|
|||
if 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 {
|
||||
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{
|
||||
id: sandboxID,
|
||||
containerID: cid,
|
||||
|
|
@ -479,6 +500,8 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn
|
|||
workspaceID: c.Labels["workspace-id"],
|
||||
vnc: hasVNC,
|
||||
workDir: "/workspace",
|
||||
displayName: c.Labels["sandbox-display-name"],
|
||||
system: sys,
|
||||
manager: m,
|
||||
}
|
||||
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.
|
||||
func (m *Manager) ImageExists(ctx context.Context, nodeID, ref string) (bool, error) {
|
||||
res, err := m.getNode(nodeID)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ type ComputerInfo struct {
|
|||
Image string
|
||||
Policy LifecyclePolicy
|
||||
Labels map[string]string
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// SystemInfo describes the hardware and environment of a Tai node.
|
||||
|
|
@ -104,6 +105,7 @@ type CreateOptions struct {
|
|||
WorkspaceID string
|
||||
MountMode string
|
||||
MountPath string
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
type ListOptions struct {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,20 @@ import (
|
|||
// Image manages container images on a runtime node.
|
||||
type Image interface {
|
||||
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)
|
||||
Remove(ctx context.Context, ref string, force bool) 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.
|
||||
type PullOptions struct {
|
||||
Auth *RegistryAuth // nil = anonymous / public
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
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) {
|
||||
pullOpts := image.PullOptions{}
|
||||
if opts.Auth != nil {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ func (k *k8sImage) Exists(_ context.Context, _ string) (bool, error) {
|
|||
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) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,17 +113,26 @@ func resolveTargetPort(c *gin.Context, node *types.NodeMeta) int {
|
|||
return 0
|
||||
}
|
||||
|
||||
// rewriteRequest clones the request and strips everything up to and including
|
||||
// /tai/:taiID from the path, handling any baseURL prefix (e.g. /v1/tai/abc/proxy/x → /proxy/x).
|
||||
// rewriteRequest clones the request and strips the Yao-side route prefix,
|
||||
// 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 {
|
||||
r := orig.Clone(orig.Context())
|
||||
|
||||
marker := "/tai/" + taiID
|
||||
if idx := strings.Index(r.URL.Path, marker); idx >= 0 {
|
||||
r.URL.Path = r.URL.Path[idx+len(marker):]
|
||||
if r.URL.Path == "" {
|
||||
r.URL.Path = "/"
|
||||
rest := r.URL.Path[idx+len(marker):]
|
||||
rest = strings.TrimPrefix(rest, "/proxy")
|
||||
if rest == "" {
|
||||
rest = "/"
|
||||
}
|
||||
r.URL.Path = rest
|
||||
}
|
||||
|
||||
r.RequestURI = r.URL.RequestURI()
|
||||
|
|
|
|||
|
|
@ -84,8 +84,8 @@ func TestRewriteRequest(t *testing.T) {
|
|||
"proxy_path",
|
||||
"/tai/abc123/proxy/api/v1/data",
|
||||
"abc123",
|
||||
"/proxy/api/v1/data",
|
||||
"/proxy/api/v1/data",
|
||||
"/api/v1/data",
|
||||
"/api/v1/data",
|
||||
},
|
||||
{
|
||||
"vnc_path",
|
||||
|
|
@ -98,8 +98,8 @@ func TestRewriteRequest(t *testing.T) {
|
|||
"with_query",
|
||||
"/tai/node-1/proxy/api?foo=bar",
|
||||
"node-1",
|
||||
"/proxy/api",
|
||||
"/proxy/api?foo=bar",
|
||||
"/api",
|
||||
"/api?foo=bar",
|
||||
},
|
||||
{
|
||||
"exact_prefix",
|
||||
|
|
@ -112,8 +112,8 @@ func TestRewriteRequest(t *testing.T) {
|
|||
"with_base_url",
|
||||
"/v1/tai/node-1/proxy/api/v1/data",
|
||||
"node-1",
|
||||
"/proxy/api/v1/data",
|
||||
"/proxy/api/v1/data",
|
||||
"/api/v1/data",
|
||||
"/api/v1/data",
|
||||
},
|
||||
{
|
||||
"with_base_url_vnc",
|
||||
|
|
@ -122,6 +122,13 @@ func TestRewriteRequest(t *testing.T) {
|
|||
"/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",
|
||||
"/other/path",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue