- Replaced the previous file extension checks with a unified parsing function for sandbox configuration, improving code clarity and maintainability. - Introduced a new HostExecConfig structure to manage local execution settings, allowing for more granular control over command execution permissions. - Removed deprecated Moapi API files and related functionality, simplifying the codebase and reducing maintenance overhead. - Updated the Tai node registration process to ensure local capabilities are accurately reflected based on the environment, enhancing overall system robustness.
490 lines
13 KiB
Go
490 lines
13 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
|
"github.com/yaoapp/yao/openapi/response"
|
|
sandboxv2 "github.com/yaoapp/yao/sandbox/v2"
|
|
"github.com/yaoapp/yao/tai"
|
|
"github.com/yaoapp/yao/tai/registry"
|
|
taitypes "github.com/yaoapp/yao/tai/types"
|
|
)
|
|
|
|
// AttachManage registers sandbox management CRUD routes on the given group.
|
|
// - GET / — list sandboxes (filtered by owner)
|
|
// - POST / — create sandbox (owner from token)
|
|
// - GET /:id — get sandbox (owner check)
|
|
// - DELETE /:id — remove sandbox (owner check)
|
|
// - POST /:id/exec — execute command (owner check)
|
|
// - POST /:id/heartbeat — heartbeat (owner check)
|
|
func AttachManage(group *gin.RouterGroup, oauth types.OAuth) {
|
|
group.GET("", oauth.Guard, handleList)
|
|
group.POST("", oauth.Guard, handleCreate)
|
|
group.GET("/:id", oauth.Guard, handleGet)
|
|
group.DELETE("/:id", oauth.Guard, handleRemove)
|
|
group.POST("/:id/exec", oauth.Guard, handleExec)
|
|
group.POST("/:id/heartbeat", oauth.Guard, handleHeartbeat)
|
|
}
|
|
|
|
// resolveOwner returns TeamID if present, otherwise UserID.
|
|
func resolveOwner(authInfo *types.AuthorizedInfo) string {
|
|
if authInfo != nil && authInfo.TeamID != "" {
|
|
return authInfo.TeamID
|
|
}
|
|
if authInfo != nil {
|
|
return authInfo.UserID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// --- request / response types ---
|
|
|
|
type createSandboxRequest struct {
|
|
ID string `json:"id,omitempty"`
|
|
NodeID string `json:"node_id"`
|
|
Image string `json:"image"`
|
|
WorkDir string `json:"work_dir,omitempty"`
|
|
User string `json:"user,omitempty"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
Memory int64 `json:"memory,omitempty"`
|
|
CPUs float64 `json:"cpus,omitempty"`
|
|
VNC bool `json:"vnc,omitempty"`
|
|
Policy string `json:"policy,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
WorkspaceID string `json:"workspace_id,omitempty"`
|
|
MountMode string `json:"mount_mode,omitempty"`
|
|
MountPath string `json:"mount_path,omitempty"`
|
|
}
|
|
|
|
type execRequest struct {
|
|
Cmd []string `json:"cmd" binding:"required"`
|
|
WorkDir string `json:"work_dir,omitempty"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
Timeout int `json:"timeout,omitempty"`
|
|
}
|
|
|
|
type heartbeatRequest struct {
|
|
Active bool `json:"active"`
|
|
ProcessCount int `json:"process_count"`
|
|
}
|
|
|
|
type sandboxSystemInfo struct {
|
|
OS string `json:"os"`
|
|
Arch string `json:"arch"`
|
|
Hostname string `json:"hostname"`
|
|
NumCPU int `json:"num_cpu"`
|
|
TotalMem int64 `json:"total_mem,omitempty"`
|
|
Shell string `json:"shell,omitempty"`
|
|
TempDir string `json:"temp_dir,omitempty"`
|
|
}
|
|
|
|
type sandboxResponse struct {
|
|
Kind string `json:"kind"`
|
|
ID string `json:"id"`
|
|
DisplayName string `json:"display_name"`
|
|
ContainerID string `json:"container_id,omitempty"`
|
|
NodeID string `json:"node_id"`
|
|
Owner string `json:"owner"`
|
|
Status string `json:"status"`
|
|
Policy string `json:"policy,omitempty"`
|
|
Image string `json:"image,omitempty"`
|
|
Mode string `json:"mode,omitempty"`
|
|
Addr string `json:"addr,omitempty"`
|
|
VNC bool `json:"vnc"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastActive time.Time `json:"last_active"`
|
|
ProcessCount int `json:"process_count"`
|
|
System sandboxSystemInfo `json:"system"`
|
|
WorkspaceID string `json:"workspace_id,omitempty"`
|
|
}
|
|
|
|
func boxToResponse(b *sandboxv2.Box) sandboxResponse {
|
|
snap := b.Snapshot()
|
|
info := b.ComputerInfo()
|
|
|
|
displayName := info.DisplayName
|
|
if displayName == "" {
|
|
displayName = info.System.Hostname
|
|
}
|
|
if displayName == "" {
|
|
displayName = snap.ID
|
|
}
|
|
|
|
var mode, addr string
|
|
if ns, ok := tai.GetNodeMeta(snap.NodeID); ok {
|
|
mode = ns.Mode
|
|
addr = ns.Addr
|
|
}
|
|
if addr == "" && snap.NodeID != "" {
|
|
scheme := mode
|
|
if scheme == "" {
|
|
scheme = "local"
|
|
}
|
|
addr = scheme + "://" + snap.NodeID
|
|
}
|
|
|
|
return sandboxResponse{
|
|
Kind: "box",
|
|
ID: snap.ID,
|
|
DisplayName: displayName,
|
|
ContainerID: snap.ContainerID,
|
|
NodeID: snap.NodeID,
|
|
Owner: snap.Owner,
|
|
Status: snap.Status,
|
|
Policy: string(snap.Policy),
|
|
Image: snap.Image,
|
|
Mode: mode,
|
|
Addr: addr,
|
|
VNC: snap.VNC,
|
|
CreatedAt: snap.CreatedAt,
|
|
LastActive: snap.LastActive,
|
|
ProcessCount: snap.ProcessCount,
|
|
WorkspaceID: b.WorkspaceID(),
|
|
System: sandboxSystemInfo{
|
|
OS: info.System.OS,
|
|
Arch: info.System.Arch,
|
|
Hostname: info.System.Hostname,
|
|
NumCPU: info.System.NumCPU,
|
|
TotalMem: info.System.TotalMem,
|
|
Shell: info.System.Shell,
|
|
TempDir: info.System.TempDir,
|
|
},
|
|
}
|
|
}
|
|
|
|
func hostToResponse(s taitypes.NodeMeta) sandboxResponse {
|
|
displayName := s.DisplayName
|
|
if displayName == "" {
|
|
displayName = s.System.Hostname
|
|
}
|
|
if displayName == "" {
|
|
displayName = s.TaiID
|
|
}
|
|
|
|
status := "stopped"
|
|
if s.Status == "online" {
|
|
status = "running"
|
|
}
|
|
|
|
owner := s.Auth.TeamID
|
|
if owner == "" {
|
|
owner = s.Auth.UserID
|
|
}
|
|
|
|
addr := s.Addr
|
|
if addr == "" {
|
|
scheme := s.Mode
|
|
if scheme == "" {
|
|
scheme = "tai"
|
|
}
|
|
addr = scheme + "://" + s.TaiID
|
|
}
|
|
|
|
return sandboxResponse{
|
|
Kind: "host",
|
|
ID: s.TaiID,
|
|
DisplayName: displayName,
|
|
NodeID: s.TaiID,
|
|
Owner: owner,
|
|
Status: status,
|
|
Policy: "persistent",
|
|
Mode: s.Mode,
|
|
Addr: addr,
|
|
VNC: s.Capabilities.VNC,
|
|
CreatedAt: s.ConnectedAt,
|
|
LastActive: s.LastPing,
|
|
System: sandboxSystemInfo{
|
|
OS: s.System.OS,
|
|
Arch: s.System.Arch,
|
|
Hostname: s.System.Hostname,
|
|
NumCPU: s.System.NumCPU,
|
|
TotalMem: s.System.TotalMem,
|
|
Shell: s.System.Shell,
|
|
},
|
|
}
|
|
}
|
|
|
|
func nodeOwnedBy(snap *taitypes.NodeMeta, authInfo *types.AuthorizedInfo) bool {
|
|
if authInfo == nil {
|
|
return true
|
|
}
|
|
if authInfo.TeamID != "" {
|
|
return snap.Auth.TeamID == authInfo.TeamID
|
|
}
|
|
if authInfo.UserID != "" {
|
|
return snap.Auth.TeamID == "" && snap.Auth.UserID == authInfo.UserID
|
|
}
|
|
return true
|
|
}
|
|
|
|
func getManager(c *gin.Context) *sandboxv2.Manager {
|
|
defer func() { recover() }()
|
|
return sandboxv2.M()
|
|
}
|
|
|
|
// checkBoxOwner verifies the caller owns the sandbox.
|
|
func checkBoxOwner(c *gin.Context, box *sandboxv2.Box, owner string) bool {
|
|
if owner == "" {
|
|
return true
|
|
}
|
|
info := box.ComputerInfo()
|
|
if info.Owner != "" && info.Owner != owner {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "no permission to access this sandbox"})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// --- handlers ---
|
|
|
|
func handleList(c *gin.Context) {
|
|
authInfo := authorized.GetInfo(c)
|
|
owner := resolveOwner(authInfo)
|
|
nodeFilter := c.Query("node_id")
|
|
|
|
var result []sandboxResponse
|
|
|
|
// Host entries: list registered nodes that have any compute capability.
|
|
if reg := registry.Global(); reg != nil {
|
|
snaps := reg.List()
|
|
for i := range snaps {
|
|
s := &snaps[i]
|
|
if s.Mode != "local" && !nodeOwnedBy(s, authInfo) {
|
|
continue
|
|
}
|
|
if !s.Capabilities.HostExec && !s.Capabilities.Docker {
|
|
continue
|
|
}
|
|
if nodeFilter != "" && s.TaiID != nodeFilter {
|
|
continue
|
|
}
|
|
result = append(result, hostToResponse(*s))
|
|
}
|
|
}
|
|
|
|
// Box entries: list all, then filter by owner
|
|
if mgr := getManager(c); mgr != nil {
|
|
boxes, err := mgr.List(context.Background(), sandboxv2.ListOptions{
|
|
NodeID: nodeFilter,
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
for _, b := range boxes {
|
|
snap := b.Snapshot()
|
|
if snap.Owner != owner {
|
|
continue
|
|
}
|
|
result = append(result, boxToResponse(b))
|
|
}
|
|
}
|
|
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return strings.ToLower(result[i].DisplayName) < strings.ToLower(result[j].DisplayName)
|
|
})
|
|
|
|
if result == nil {
|
|
result = []sandboxResponse{}
|
|
}
|
|
response.RespondWithSuccess(c, http.StatusOK, result)
|
|
}
|
|
|
|
func handleCreate(c *gin.Context) {
|
|
mgr := getManager(c)
|
|
if mgr == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "sandbox service not available"})
|
|
return
|
|
}
|
|
|
|
var req createSandboxRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if req.Image == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "image is required"})
|
|
return
|
|
}
|
|
|
|
authInfo := authorized.GetInfo(c)
|
|
owner := resolveOwner(authInfo)
|
|
|
|
opts := sandboxv2.CreateOptions{
|
|
ID: req.ID,
|
|
Owner: owner,
|
|
NodeID: req.NodeID,
|
|
Image: req.Image,
|
|
WorkDir: req.WorkDir,
|
|
User: req.User,
|
|
Env: req.Env,
|
|
Memory: req.Memory,
|
|
CPUs: req.CPUs,
|
|
VNC: req.VNC,
|
|
Policy: sandboxv2.LifecyclePolicy(req.Policy),
|
|
Labels: req.Labels,
|
|
WorkspaceID: req.WorkspaceID,
|
|
MountMode: req.MountMode,
|
|
MountPath: req.MountPath,
|
|
}
|
|
|
|
box, err := mgr.Create(context.Background(), opts)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
response.RespondWithSuccess(c, http.StatusCreated, boxToResponse(box))
|
|
}
|
|
|
|
func handleGet(c *gin.Context) {
|
|
mgr := getManager(c)
|
|
if mgr == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "sandbox service not available"})
|
|
return
|
|
}
|
|
|
|
id := c.Param("id")
|
|
box, err := mgr.Get(context.Background(), id)
|
|
if err != nil {
|
|
if err == sandboxv2.ErrNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "sandbox not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
authInfo := authorized.GetInfo(c)
|
|
if !checkBoxOwner(c, box, resolveOwner(authInfo)) {
|
|
return
|
|
}
|
|
|
|
response.RespondWithSuccess(c, http.StatusOK, boxToResponse(box))
|
|
}
|
|
|
|
func handleRemove(c *gin.Context) {
|
|
mgr := getManager(c)
|
|
if mgr == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "sandbox service not available"})
|
|
return
|
|
}
|
|
|
|
id := c.Param("id")
|
|
box, err := mgr.Get(context.Background(), id)
|
|
if err != nil {
|
|
if err == sandboxv2.ErrNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "sandbox not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
authInfo := authorized.GetInfo(c)
|
|
if !checkBoxOwner(c, box, resolveOwner(authInfo)) {
|
|
return
|
|
}
|
|
|
|
if err := mgr.Remove(context.Background(), id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func handleExec(c *gin.Context) {
|
|
mgr := getManager(c)
|
|
if mgr == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "sandbox service not available"})
|
|
return
|
|
}
|
|
|
|
id := c.Param("id")
|
|
box, err := mgr.Get(context.Background(), id)
|
|
if err != nil {
|
|
if err == sandboxv2.ErrNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "sandbox not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
authInfo := authorized.GetInfo(c)
|
|
if !checkBoxOwner(c, box, resolveOwner(authInfo)) {
|
|
return
|
|
}
|
|
|
|
var req execRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
var opts []sandboxv2.ExecOption
|
|
if req.WorkDir != "" {
|
|
opts = append(opts, sandboxv2.WithWorkDir(req.WorkDir))
|
|
}
|
|
if len(req.Env) > 0 {
|
|
opts = append(opts, sandboxv2.WithEnv(req.Env))
|
|
}
|
|
if req.Timeout > 0 {
|
|
opts = append(opts, sandboxv2.WithTimeout(time.Duration(req.Timeout)*time.Second))
|
|
}
|
|
|
|
result, err := box.Exec(context.Background(), req.Cmd, opts...)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
response.RespondWithSuccess(c, http.StatusOK, result)
|
|
}
|
|
|
|
func handleHeartbeat(c *gin.Context) {
|
|
mgr := getManager(c)
|
|
if mgr == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "sandbox service not available"})
|
|
return
|
|
}
|
|
|
|
id := c.Param("id")
|
|
|
|
box, err := mgr.Get(context.Background(), id)
|
|
if err != nil {
|
|
if err == sandboxv2.ErrNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "sandbox not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
authInfo := authorized.GetInfo(c)
|
|
if !checkBoxOwner(c, box, resolveOwner(authInfo)) {
|
|
return
|
|
}
|
|
|
|
var req heartbeatRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := mgr.Heartbeat(id, req.Active, req.ProcessCount); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusNoContent)
|
|
}
|