- 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.
796 lines
20 KiB
Go
796 lines
20 KiB
Go
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})
|
||
}
|