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}) }