yao/openapi/setting/sandbox.go
Max bde4442ff6 feat(sandbox): add sandbox management endpoints and data structures
- Introduced new endpoints for managing sandbox operations, including GET, PUT, and POST methods for registry and image management.
- Added data structures for sandbox configuration and image details, enhancing the OpenAPI settings to support sandbox functionality.
- Updated Makefile to skip sandbox-related tests in CI, ensuring local execution only.
2026-04-29 18:47:38 +08:00

796 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package setting
import (
"context"
"encoding/base64"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/openapi/oauth/authorized"
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
sandboxv2 "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/setting"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/runtime"
taitypes "github.com/yaoapp/yao/tai/types"
)
const sandboxRegistryNS = "sandbox.registry"
// pullState tracks an in-progress image pull operation.
type pullState struct {
ImageRef string
NodeID string
Progress int // 0-100
Error string // non-empty on failure
Done bool
}
var pullTracker sync.Map // key: "nodeID:imageRef"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func imageRefToID(ref string) string {
return base64.RawURLEncoding.EncodeToString([]byte(ref))
}
func idToImageRef(id string) (string, error) {
b, err := base64.RawURLEncoding.DecodeString(id)
return string(b), err
}
func friendlyImageError(locale string, msg string) string {
isCN := strings.HasPrefix(strings.ToLower(locale), "zh")
if strings.Contains(msg, "conflict") || strings.Contains(msg, "must force") {
if isCN {
return "该镜像正在被运行中的沙箱使用,请先停止相关沙箱后再删除"
}
return "This image is in use by a running sandbox. Please stop the sandbox first before removing."
}
if strings.Contains(msg, "No such image") || strings.Contains(msg, "not found") {
if isCN {
return "镜像不存在或已被删除"
}
return "Image not found or already removed"
}
if strings.Contains(msg, "no matching manifest") {
if isCN {
return "该镜像不支持当前系统架构(" + msg + ""
}
return "This image does not support the current architecture (" + msg + ")"
}
if strings.Contains(msg, "pull access denied") || strings.Contains(msg, "repository does not exist") {
if isCN {
return "镜像不存在或无拉取权限,请检查镜像名称和仓库配置"
}
return "Image not found or access denied. Please check the image name and registry config."
}
if strings.Contains(msg, "dial tcp") || strings.Contains(msg, "timeout") || strings.Contains(msg, "TLS handshake") {
if isCN {
return "无法连接镜像仓库,请检查网络连接"
}
return "Cannot connect to the image registry. Please check your network."
}
if isCN {
return "操作失败: " + msg
}
return "Operation failed: " + msg
}
func friendlyOS(goos string) string {
switch strings.ToLower(goos) {
case "darwin":
return "macOS"
case "linux":
return "Linux"
case "windows":
return "Windows"
default:
return goos
}
}
func getSandboxManager() *sandboxv2.Manager {
defer func() { recover() }()
return sandboxv2.M()
}
func sandboxNodeOwnedBy(snap *taitypes.NodeMeta, authInfo *oauthTypes.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
}
type dockerInfoResult struct {
Version string
MemTotal int64
NCPU int
}
func fetchDockerInfo(nodeID string) *dockerInfoResult {
res, ok := tai.GetResources(nodeID)
if !ok || res.Runtime == nil {
return nil
}
cli := runtime.DockerCli(res.Runtime)
if cli == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
info, err := cli.Info(ctx)
if err != nil {
return nil
}
return &dockerInfoResult{
Version: info.ServerVersion,
MemTotal: info.MemTotal,
NCPU: info.NCPU,
}
}
// collectAssistantImages traverses assistant cache to find sandbox image requirements.
// Returns map[imageRef][]assistantDisplayName (locale-resolved).
func collectAssistantImages(locale string) map[string][]string {
cache := assistant.GetCache()
if cache == nil {
return nil
}
result := make(map[string][]string)
for _, ast := range cache.All() {
if ast == nil {
continue
}
var imageRef string
if ast.SandboxV2 != nil && ast.SandboxV2.Computer.Image != "" {
imageRef = ast.SandboxV2.Computer.Image
} else if ast.Sandbox != nil && ast.Sandbox.Image != "" {
imageRef = ast.Sandbox.Image
}
if imageRef != "" {
name := ast.GetName(locale)
if name == "" {
name = ast.ID
}
result[imageRef] = append(result[imageRef], name)
}
}
return result
}
// splitImageRef splits "repo/name:tag" into (imageName, tag).
func splitImageRef(ref string) (string, string) {
if idx := strings.LastIndex(ref, ":"); idx > 0 && !strings.Contains(ref[idx:], "/") {
return ref[:idx], ref[idx+1:]
}
return ref, "latest"
}
// getNodeResources retrieves ConnResources for a node with image capability.
// Returns (resources, httpStatus, errorMessage).
func getNodeResources(nodeID string) (*tai.ConnResources, int, string) {
reg := registry.Global()
if reg == nil {
return nil, http.StatusServiceUnavailable, "tai registry not initialized"
}
meta, ok := reg.Get(nodeID)
if !ok {
return nil, http.StatusNotFound, "node not found: " + nodeID
}
if meta.Status != "online" {
return nil, http.StatusBadRequest, "node is offline: " + nodeID
}
res, ok := tai.GetResources(nodeID)
if !ok {
return nil, http.StatusBadGateway, "cannot reach node: " + nodeID
}
if res.Image == nil {
return nil, http.StatusBadRequest, "Docker not available on this node"
}
return res, 0, ""
}
// ---------------------------------------------------------------------------
// GET /setting/sandbox
// ---------------------------------------------------------------------------
func handleSandboxGet(c *gin.Context) {
info := authorized.GetInfo(c)
locale := strings.ToLower(c.DefaultQuery("locale", "en-us"))
reg := registry.Global()
var snaps []taitypes.NodeMeta
if reg != nil {
snaps = reg.List()
}
// Filter nodes by ownership
var filtered []taitypes.NodeMeta
for i := range snaps {
s := &snaps[i]
if s.Mode != "local" && !sandboxNodeOwnedBy(s, info) {
continue
}
if !s.Capabilities.Docker {
continue
}
filtered = append(filtered, *s)
}
mgr := getSandboxManager()
// Build nodes concurrently
nodes := make([]ComputerNode, len(filtered))
var wg sync.WaitGroup
for i, snap := range filtered {
wg.Add(1)
go func(idx int, s taitypes.NodeMeta) {
defer wg.Done()
kind := "tai-link"
if s.Mode == "local" {
kind = "local"
}
node := ComputerNode{
NodeID: s.TaiID,
DisplayName: s.DisplayName,
Kind: kind,
OS: friendlyOS(s.System.OS),
Arch: s.System.Arch,
CPU: s.System.NumCPU,
MemoryGB: int(s.System.TotalMem / (1024 * 1024 * 1024)),
Online: s.Status == "online",
}
if node.DisplayName == "" {
node.DisplayName = s.System.Hostname
}
if node.DisplayName == "" {
node.DisplayName = s.TaiID
}
// Fetch Docker info for online nodes
if node.Online {
if di := fetchDockerInfo(s.TaiID); di != nil {
node.DockerVersion = di.Version
if node.MemoryGB == 0 && di.MemTotal > 0 {
node.MemoryGB = int(di.MemTotal / (1024 * 1024 * 1024))
}
if node.CPU == 0 && di.NCPU > 0 {
node.CPU = di.NCPU
}
}
}
// Count running sandboxes
if mgr != nil {
boxes, err := mgr.List(context.Background(), sandboxv2.ListOptions{NodeID: s.TaiID})
if err == nil {
node.RunningSandboxes = len(boxes)
}
}
nodes[idx] = node
}(i, snap)
}
wg.Wait()
// Registry config
regConfig := SandboxRegistryConfig{}
if setting.Global != nil {
saved, _ := setting.Global.GetMerged(info.UserID, info.TeamID, sandboxRegistryNS)
if v, ok := saved["registry_url"].(string); ok {
regConfig.RegistryURL = v
}
if v, ok := saved["username"].(string); ok {
regConfig.Username = v
}
if v, ok := saved["password"].(string); ok && v != "" {
regConfig.Password = cloudMaskKey(cloudDecrypt(v))
}
}
// Collect assistant images (locale-resolved names)
assistantImages := collectAssistantImages(locale)
// Build image list per node concurrently
images := make(map[string][]SandboxImage)
var imgWg sync.WaitGroup
var imgMu sync.Mutex
for _, node := range nodes {
if !node.Online {
imgMu.Lock()
images[node.NodeID] = []SandboxImage{}
imgMu.Unlock()
continue
}
imgWg.Add(1)
go func(nodeID string) {
defer imgWg.Done()
nodeImages := buildNodeImages(nodeID, assistantImages, locale)
imgMu.Lock()
images[nodeID] = nodeImages
imgMu.Unlock()
}(node.NodeID)
}
imgWg.Wait()
data := SandboxPageData{
Nodes: nodes,
Registry: regConfig,
Images: images,
}
if data.Nodes == nil {
data.Nodes = []ComputerNode{}
}
response.RespondWithSuccess(c, http.StatusOK, data)
}
func buildNodeImages(nodeID string, assistantImages map[string][]string, locale string) []SandboxImage {
res, ok := tai.GetResources(nodeID)
if !ok || res.Image == nil {
return []SandboxImage{}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
localImages, err := res.Image.List(ctx)
if err != nil {
return []SandboxImage{}
}
// Build tag index from local images
tagIndex := make(map[string]runtime.ImageInfo)
for _, img := range localImages {
for _, tag := range img.Tags {
tagIndex[tag] = img
}
}
var result []SandboxImage
for imageRef, names := range assistantImages {
imgName, tag := splitImageRef(imageRef)
si := SandboxImage{
ID: imageRefToID(imageRef),
AssistantNames: names,
ImageName: imgName,
Tag: tag,
Status: "not_downloaded",
}
// Check if already downloaded
if info, ok := tagIndex[imageRef]; ok {
si.Status = "downloaded"
si.SizeMB = int(info.Size / (1024 * 1024))
}
trackerKey := nodeID + ":" + imageRef
if v, ok := pullTracker.Load(trackerKey); ok {
ps := v.(*pullState)
if !ps.Done {
si.Status = "downloading"
p := ps.Progress
si.Progress = &p
} else if ps.Error != "" {
si.Status = "error"
si.ErrorMessage = friendlyImageError(locale, ps.Error)
} else {
si.Status = "downloaded"
}
}
result = append(result, si)
}
if result == nil {
return []SandboxImage{}
}
return result
}
// ---------------------------------------------------------------------------
// PUT /setting/sandbox/registry
// ---------------------------------------------------------------------------
func handleSandboxRegistry(c *gin.Context) {
if !guardOwner(c) {
return
}
info := authorized.GetInfo(c)
scope := cloudScope(info)
var body SandboxRegistryConfig
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, http.StatusBadRequest, "invalid request body")
return
}
if setting.Global == nil {
respondError(c, http.StatusInternalServerError, "setting registry not initialized")
return
}
m := map[string]interface{}{
"registry_url": body.RegistryURL,
"username": body.Username,
}
if body.Password != "" {
m["password"] = cloudEncrypt(body.Password)
} else {
existing, _ := setting.Global.Get(scope, sandboxRegistryNS)
if v, ok := existing["password"].(string); ok {
m["password"] = v
}
}
if _, err := setting.Global.Set(scope, sandboxRegistryNS, m); err != nil {
respondError(c, http.StatusInternalServerError, err.Error())
return
}
result := SandboxRegistryConfig{
RegistryURL: body.RegistryURL,
Username: body.Username,
}
if v, ok := m["password"].(string); ok && v != "" {
result.Password = cloudMaskKey(cloudDecrypt(v))
}
response.RespondWithSuccess(c, http.StatusOK, result)
}
// ---------------------------------------------------------------------------
// POST /setting/sandbox/nodes/:nodeId/images/:imageId/pull
// ---------------------------------------------------------------------------
func handleSandboxPull(c *gin.Context) {
if !guardOwner(c) {
return
}
nodeID := c.Param("nodeId")
imageID := c.Param("imageId")
imageRef, err := idToImageRef(imageID)
if err != nil || imageRef == "" {
respondError(c, http.StatusBadRequest, "invalid image ID")
return
}
res, status, errMsg := getNodeResources(nodeID)
if res == nil {
respondError(c, status, errMsg)
return
}
pullOpts := runtime.PullOptions{}
info := authorized.GetInfo(c)
if setting.Global != nil {
saved, _ := setting.Global.GetMerged(info.UserID, info.TeamID, sandboxRegistryNS)
if regURL, ok := saved["registry_url"].(string); ok && regURL != "" {
if strings.HasPrefix(imageRef, regURL) || strings.HasPrefix(imageRef, strings.TrimPrefix(regURL, "https://")) {
user, _ := saved["username"].(string)
pass, _ := saved["password"].(string)
if user != "" {
pullOpts.Auth = &runtime.RegistryAuth{
Username: user,
Password: cloudDecrypt(pass),
Server: regURL,
}
}
}
}
}
trackerKey := nodeID + ":" + imageRef
log.Info("[sandbox] pull start: trackerKey=%s imageRef=%s", trackerKey, imageRef)
pullTracker.Store(trackerKey, &pullState{
ImageRef: imageRef,
NodeID: nodeID,
Progress: 0,
})
ch, pullErr := res.Image.Pull(context.Background(), imageRef, pullOpts)
if pullErr != nil {
log.Error("[sandbox] pull initiate failed: %s err=%v", trackerKey, pullErr)
pullTracker.Delete(trackerKey)
respondError(c, http.StatusBadGateway, "pull failed: "+pullErr.Error())
return
}
if ch != nil {
log.Info("[sandbox] pull channel received, starting goroutine: %s", trackerKey)
go consumePullProgress(trackerKey, ch)
} else {
log.Info("[sandbox] pull channel is nil, marking as done: %s", trackerKey)
pullTracker.Store(trackerKey, &pullState{
ImageRef: imageRef,
NodeID: nodeID,
Progress: 100,
Done: true,
})
}
imgName, tag := splitImageRef(imageRef)
p := 0
response.RespondWithSuccess(c, http.StatusOK, SandboxImage{
ID: imageRefToID(imageRef),
ImageName: imgName,
Tag: tag,
Status: "downloading",
Progress: &p,
})
}
func consumePullProgress(trackerKey string, ch <-chan runtime.PullProgress) {
log.Info("[sandbox] consumePullProgress started: %s", trackerKey)
var totalBytes int64
var currentBytes int64
var eventCount int
layerProgress := make(map[string]int64)
layerTotal := make(map[string]int64)
for p := range ch {
eventCount++
if p.Error != "" {
log.Error("[sandbox] pull error: %s err=%s", trackerKey, p.Error)
pullTracker.Store(trackerKey, &pullState{
Done: true,
Error: p.Error,
})
go func() {
time.Sleep(60 * time.Second)
pullTracker.Delete(trackerKey)
}()
return
}
if p.Layer != "" && p.Total > 0 {
layerTotal[p.Layer] = p.Total
layerProgress[p.Layer] = p.Current
}
totalBytes = 0
currentBytes = 0
for layer, t := range layerTotal {
totalBytes += t
currentBytes += layerProgress[layer]
}
pct := 0
if totalBytes > 0 {
pct = int(currentBytes * 100 / totalBytes)
if pct > 99 {
pct = 99
}
}
pullTracker.Store(trackerKey, &pullState{
Progress: pct,
})
}
log.Info("[sandbox] pull complete (channel closed): %s events=%d", trackerKey, eventCount)
pullTracker.Store(trackerKey, &pullState{
Progress: 100,
Done: true,
})
go func() {
time.Sleep(60 * time.Second)
pullTracker.Delete(trackerKey)
}()
}
// ---------------------------------------------------------------------------
// POST /setting/sandbox/nodes/:nodeId/images/pull-all
// ---------------------------------------------------------------------------
func handleSandboxPullAll(c *gin.Context) {
if !guardOwner(c) {
return
}
nodeID := c.Param("nodeId")
locale := strings.ToLower(c.DefaultQuery("locale", "en-us"))
res, status, errMsg := getNodeResources(nodeID)
if res == nil {
respondError(c, status, errMsg)
return
}
assistantImages := collectAssistantImages(locale)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
localImages, _ := res.Image.List(ctx)
tagIndex := make(map[string]bool)
for _, img := range localImages {
for _, tag := range img.Tags {
tagIndex[tag] = true
}
}
// Build pull options
pullOpts := runtime.PullOptions{}
info := authorized.GetInfo(c)
if setting.Global != nil {
saved, _ := setting.Global.GetMerged(info.UserID, info.TeamID, sandboxRegistryNS)
if regURL, ok := saved["registry_url"].(string); ok && regURL != "" {
user, _ := saved["username"].(string)
pass, _ := saved["password"].(string)
if user != "" {
pullOpts.Auth = &runtime.RegistryAuth{
Username: user,
Password: cloudDecrypt(pass),
Server: regURL,
}
}
}
}
var result []SandboxImage
for imageRef, names := range assistantImages {
if tagIndex[imageRef] {
continue
}
trackerKey := nodeID + ":" + imageRef
// Skip if already pulling
if v, ok := pullTracker.Load(trackerKey); ok {
ps := v.(*pullState)
if !ps.Done {
imgName, tag := splitImageRef(imageRef)
p := ps.Progress
result = append(result, SandboxImage{
ID: imageRefToID(imageRef),
AssistantNames: names,
ImageName: imgName,
Tag: tag,
Status: "downloading",
Progress: &p,
})
continue
}
}
pullTracker.Store(trackerKey, &pullState{
ImageRef: imageRef,
NodeID: nodeID,
Progress: 0,
})
ch, pullErr := res.Image.Pull(context.Background(), imageRef, pullOpts)
if pullErr != nil {
pullTracker.Delete(trackerKey)
continue
}
if ch != nil {
go consumePullProgress(trackerKey, ch)
}
imgName, tag := splitImageRef(imageRef)
p := 0
result = append(result, SandboxImage{
ID: imageRefToID(imageRef),
AssistantNames: names,
ImageName: imgName,
Tag: tag,
Status: "downloading",
Progress: &p,
})
}
if result == nil {
result = []SandboxImage{}
}
response.RespondWithSuccess(c, http.StatusOK, result)
}
// ---------------------------------------------------------------------------
// DELETE /setting/sandbox/nodes/:nodeId/images/:imageId
// ---------------------------------------------------------------------------
func handleSandboxImageDelete(c *gin.Context) {
if !guardOwner(c) {
return
}
nodeID := c.Param("nodeId")
imageID := c.Param("imageId")
imageRef, err := idToImageRef(imageID)
if err != nil || imageRef == "" {
respondError(c, http.StatusBadRequest, "invalid image ID")
return
}
res, status, errMsg := getNodeResources(nodeID)
if res == nil {
respondError(c, status, errMsg)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if res.Runtime != nil {
containers, _ := res.Runtime.List(ctx, runtime.ListOptions{All: true})
for _, ctr := range containers {
if ctr.Image == imageRef {
_ = res.Runtime.Remove(ctx, ctr.ID, true)
}
}
}
if err := res.Image.Remove(ctx, imageRef, true); err != nil {
locale := strings.ToLower(c.DefaultQuery("locale", "en-us"))
respondError(c, http.StatusBadRequest, friendlyImageError(locale, err.Error()))
return
}
response.RespondWithSuccess(c, http.StatusOK, gin.H{"success": true})
}
// ---------------------------------------------------------------------------
// POST /setting/sandbox/nodes/:nodeId/check-docker
// ---------------------------------------------------------------------------
func handleSandboxCheckDocker(c *gin.Context) {
nodeID := c.Param("nodeId")
reg := registry.Global()
if reg == nil {
response.RespondWithSuccess(c, http.StatusOK, gin.H{"docker_version": nil, "message": "tai registry not initialized"})
return
}
meta, ok := reg.Get(nodeID)
if !ok {
respondError(c, http.StatusNotFound, "node not found: "+nodeID)
return
}
if meta.Status != "online" {
response.RespondWithSuccess(c, http.StatusOK, gin.H{"docker_version": nil, "message": "node is offline"})
return
}
res, ok := tai.GetResources(nodeID)
if !ok || res.Runtime == nil {
response.RespondWithSuccess(c, http.StatusOK, gin.H{"docker_version": nil, "message": "Docker not available"})
return
}
cli := runtime.DockerCli(res.Runtime)
if cli == nil {
response.RespondWithSuccess(c, http.StatusOK, gin.H{"docker_version": nil, "message": "Docker not available"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ver, err := cli.ServerVersion(ctx)
if err != nil {
response.RespondWithSuccess(c, http.StatusOK, gin.H{"docker_version": nil, "message": "Docker check failed: " + err.Error()})
return
}
response.RespondWithSuccess(c, http.StatusOK, gin.H{"docker_version": ver.Version})
}