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.
This commit is contained in:
Max 2026-04-29 18:47:38 +08:00
parent 3b642fea78
commit bde4442ff6
6 changed files with 1162 additions and 3 deletions

View file

@ -11,6 +11,7 @@ OS := $(shell uname)
# ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) # ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|registry|agent/sandbox/v2' | awk '!/\/tests\// || /openapi\/tests/' | grep -vE 'openapi/tests/(nodes|sandbox|workspace)') TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|registry|agent/sandbox/v2' | awk '!/\/tests\// || /openapi\/tests/' | grep -vE 'openapi/tests/(nodes|sandbox|workspace)')
# Sandbox setting tests (openapi/tests/setting/sandbox_test.go) require Docker + Tai — skipped in CI, run locally only
# Core tests (exclude AI-related: agent, aigc, openai, KB, sandbox, registry, grpc, and integrations which require external services) # Core tests (exclude AI-related: agent, aigc, openai, KB, sandbox, registry, grpc, and integrations which require external services)
TESTFOLDER_CORE := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|agent|kb|sandbox|integrations|registry|tai|grpc' | awk '!/\/tests\// || /openapi\/tests/' | grep -vE 'openapi/tests/(nodes|sandbox|workspace)') TESTFOLDER_CORE := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|agent|kb|sandbox|integrations|registry|tai|grpc' | awk '!/\/tests\// || /openapi\/tests/' | grep -vE 'openapi/tests/(nodes|sandbox|workspace)')
# Agent tests (agent, aigc) - exclude agent/search/handlers/web (requires external API keys), robot packages (tested in robot job), and agent/sandbox/v2 (WIP, has its own job) # Agent tests (agent, aigc) - exclude agent/search/handlers/web (requires external API keys), robot packages (tested in robot job), and agent/sandbox/v2 (WIP, has its own job)
@ -36,7 +37,7 @@ TESTTAGS ?= ""
unit-test: unit-test:
echo "mode: count" > coverage.out echo "mode: count" > coverage.out
for d in $(TESTFOLDER); do \ for d in $(TESTFOLDER); do \
$(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_' $$d > tmp.out; \ $(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_|TestSandbox' $$d > tmp.out; \
cat tmp.out; \ cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \ if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \ rm tmp.out; \
@ -68,7 +69,7 @@ unit-test:
unit-test-core: unit-test-core:
echo "mode: count" > coverage.out echo "mode: count" > coverage.out
for d in $(TESTFOLDER_CORE); do \ for d in $(TESTFOLDER_CORE); do \
$(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_' $$d > tmp.out; \ $(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_|TestSandbox' $$d > tmp.out; \
cat tmp.out; \ cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \ if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \ rm tmp.out; \

796
openapi/setting/sandbox.go Normal file
View file

@ -0,0 +1,796 @@
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})
}

View file

@ -65,6 +65,14 @@ func Attach(group *gin.RouterGroup, oauth oauthTypes.OAuth) {
mcpG.PUT("/servers/:id", handleMCPUpdate) mcpG.PUT("/servers/:id", handleMCPUpdate)
mcpG.DELETE("/servers/:id", handleMCPDelete) mcpG.DELETE("/servers/:id", handleMCPDelete)
mcpG.POST("/test", handleMCPTest) mcpG.POST("/test", handleMCPTest)
sb := group.Group("/sandbox")
sb.GET("", handleSandboxGet)
sb.PUT("/registry", handleSandboxRegistry)
sb.POST("/nodes/:nodeId/images/:imageId/pull", handleSandboxPull)
sb.POST("/nodes/:nodeId/images/pull-all", handleSandboxPullAll)
sb.DELETE("/nodes/:nodeId/images/:imageId", handleSandboxImageDelete)
sb.POST("/nodes/:nodeId/check-docker", handleSandboxCheckDocker)
} }
// requireOwner checks that the current user is the team owner. // requireOwner checks that the current user is the team owner.

View file

@ -179,3 +179,43 @@ type SmtpTestResult struct {
Success bool `json:"success"` Success bool `json:"success"`
Message string `json:"message"` Message string `json:"message"`
} }
// ---------------------------------------------------------------------------
// Sandbox
// ---------------------------------------------------------------------------
type ComputerNode struct {
NodeID string `json:"node_id"`
DisplayName string `json:"display_name"`
Kind string `json:"kind"`
OS string `json:"os"`
Arch string `json:"arch"`
CPU int `json:"cpu"`
MemoryGB int `json:"memory_gb"`
DockerVersion string `json:"docker_version,omitempty"`
RunningSandboxes int `json:"running_sandboxes"`
Online bool `json:"online"`
}
type SandboxRegistryConfig struct {
RegistryURL string `json:"registry_url"`
Username string `json:"username"`
Password string `json:"password"`
}
type SandboxImage struct {
ID string `json:"id"`
AssistantNames []string `json:"assistant_names"`
ImageName string `json:"image_name"`
Tag string `json:"tag"`
SizeMB int `json:"size_mb"`
Status string `json:"status"`
Progress *int `json:"progress,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
type SandboxPageData struct {
Nodes []ComputerNode `json:"nodes"`
Registry SandboxRegistryConfig `json:"registry"`
Images map[string][]SandboxImage `json:"images"`
}

View file

@ -0,0 +1,315 @@
package setting_test
import (
"bytes"
"encoding/json"
"net/http"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi/tests/testutils"
"github.com/yaoapp/yao/setting"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
)
func initTaiForTest(t *testing.T) {
t.Helper()
if registry.Global() == nil {
tai.InitLocal(os.Stderr, "error", "")
}
}
func TestSandboxGet(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
initSettingRegistry(t)
initTaiForTest(t)
token := obtainToken(t, serverURL)
req, err := http.NewRequest("GET", serverURL+baseURL()+"/setting/sandbox", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if !assert.NoError(t, err) || !assert.NotNil(t, resp) {
return
}
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var data map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&data)
assert.NoError(t, err)
nodes, ok := data["nodes"].([]interface{})
assert.True(t, ok, "should have nodes array")
assert.NotNil(t, nodes)
regConfig, ok := data["registry"].(map[string]interface{})
assert.True(t, ok, "should have registry object")
assert.NotNil(t, regConfig)
images, ok := data["images"].(map[string]interface{})
assert.True(t, ok, "should have images object")
assert.NotNil(t, images)
if len(nodes) > 0 {
node := nodes[0].(map[string]interface{})
assert.NotEmpty(t, node["node_id"])
assert.NotEmpty(t, node["os"])
t.Logf("Node: %s (%s, %s)", node["node_id"], node["os"], node["arch"])
}
}
func TestSandboxGetUnauthenticated(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
req, err := http.NewRequest("GET", serverURL+baseURL()+"/setting/sandbox", nil)
assert.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
if !assert.NoError(t, err) || !assert.NotNil(t, resp) {
return
}
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
func TestSandboxRegistry(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
initSettingRegistry(t)
token := obtainToken(t, serverURL)
body := map[string]string{
"registry_url": "https://registry.example.com",
"username": "testuser",
"password": "testpass123",
}
data, _ := json.Marshal(body)
req, err := http.NewRequest("PUT", serverURL+baseURL()+"/setting/sandbox/registry", bytes.NewReader(data))
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if !assert.NoError(t, err) || !assert.NotNil(t, resp) {
return
}
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var regData map[string]interface{}
json.NewDecoder(resp.Body).Decode(&regData)
assert.Equal(t, "https://registry.example.com", regData["registry_url"])
assert.Equal(t, "testuser", regData["username"])
pw, _ := regData["password"].(string)
assert.NotEqual(t, "testpass123", pw, "password should be masked")
assert.Contains(t, pw, "...", "password should contain mask")
// Verify GET returns masked password
initTaiForTest(t)
req2, _ := http.NewRequest("GET", serverURL+baseURL()+"/setting/sandbox", nil)
req2.Header.Set("Authorization", "Bearer "+token)
resp2, err := http.DefaultClient.Do(req2)
if !assert.NoError(t, err) || !assert.NotNil(t, resp2) {
return
}
defer resp2.Body.Close()
var getResult map[string]interface{}
json.NewDecoder(resp2.Body).Decode(&getResult)
regConfig, ok := getResult["registry"].(map[string]interface{})
if assert.True(t, ok) {
assert.Equal(t, "https://registry.example.com", regConfig["registry_url"])
pw2, _ := regConfig["password"].(string)
assert.NotEqual(t, "testpass123", pw2)
assert.Contains(t, pw2, "...")
}
}
func TestSandboxCheckDocker(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
initTaiForTest(t)
token := obtainToken(t, serverURL)
req, err := http.NewRequest("POST", serverURL+baseURL()+"/setting/sandbox/nodes/local/check-docker", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if !assert.NoError(t, err) || !assert.NotNil(t, resp) {
return
}
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var data map[string]interface{}
json.NewDecoder(resp.Body).Decode(&data)
if data["docker_version"] != nil {
ver := data["docker_version"].(string)
assert.NotEmpty(t, ver, "docker_version should be a non-empty string when Docker is running")
t.Logf("Docker version: %s", ver)
} else {
t.Log("Docker not available on local node (this is OK)")
}
}
func TestSandboxCheckDockerNotFound(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
initTaiForTest(t)
token := obtainToken(t, serverURL)
req, err := http.NewRequest("POST", serverURL+baseURL()+"/setting/sandbox/nodes/nonexistent-node-id/check-docker", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if !assert.NoError(t, err) || !assert.NotNil(t, resp) {
return
}
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestSandboxImagePull(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
initTaiForTest(t)
initSettingRegistry(t)
reg := registry.Global()
if reg == nil {
t.Skip("tai registry not initialized")
}
meta, ok := reg.Get("local")
if !ok || !meta.Capabilities.Docker {
t.Skip("local node has no Docker capability")
}
token := obtainToken(t, serverURL)
imageID := "YWxwaW5lOmxhdGVzdA" // base64url("alpine:latest")
req, err := http.NewRequest("POST",
serverURL+baseURL()+"/setting/sandbox/nodes/local/images/"+imageID+"/pull", nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if !assert.NoError(t, err) || !assert.NotNil(t, resp) {
return
}
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var data map[string]interface{}
json.NewDecoder(resp.Body).Decode(&data)
assert.Equal(t, "downloading", data["status"])
t.Logf("Pull started for alpine:latest")
}
func TestSandboxImageDelete(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
initTaiForTest(t)
initSettingRegistry(t)
reg := registry.Global()
if reg == nil {
t.Skip("tai registry not initialized")
}
meta, ok := reg.Get("local")
if !ok || !meta.Capabilities.Docker {
t.Skip("local node has no Docker capability")
}
token := obtainToken(t, serverURL)
imageID := "bm9uZXhpc3RlbnQ6bGF0ZXN0" // base64url("nonexistent:latest")
req, err := http.NewRequest("DELETE",
serverURL+baseURL()+"/setting/sandbox/nodes/local/images/"+imageID, nil)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if !assert.NoError(t, err) || !assert.NotNil(t, resp) {
return
}
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "deleting non-existent image should return 400")
}
func TestSandboxRegistryKeepPassword(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
initSettingRegistry(t)
token := obtainToken(t, serverURL)
body := map[string]string{
"registry_url": "https://registry.example.com",
"username": "user1",
"password": "secret123",
}
data, _ := json.Marshal(body)
req, _ := http.NewRequest("PUT", serverURL+baseURL()+"/setting/sandbox/registry", bytes.NewReader(data))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if !assert.NoError(t, err) {
return
}
resp.Body.Close()
body2 := map[string]string{
"registry_url": "https://registry2.example.com",
"username": "user2",
"password": "",
}
data2, _ := json.Marshal(body2)
req2, _ := http.NewRequest("PUT", serverURL+baseURL()+"/setting/sandbox/registry", bytes.NewReader(data2))
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("Content-Type", "application/json")
resp2, err := http.DefaultClient.Do(req2)
if !assert.NoError(t, err) || !assert.NotNil(t, resp2) {
return
}
defer resp2.Body.Close()
assert.Equal(t, http.StatusOK, resp2.StatusCode)
var regData map[string]interface{}
json.NewDecoder(resp2.Body).Decode(&regData)
assert.Equal(t, "https://registry2.example.com", regData["registry_url"])
assert.Equal(t, "user2", regData["username"])
pw, _ := regData["password"].(string)
assert.NotEmpty(t, pw, "password should still be present from previous save")
assert.Contains(t, pw, "...")
}
func TestSandboxRegistryRequiresAuth(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
body := map[string]string{"registry_url": "https://example.com"}
data, _ := json.Marshal(body)
req, _ := http.NewRequest("PUT", serverURL+baseURL()+"/setting/sandbox/registry", bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if !assert.NoError(t, err) {
return
}
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
var _ = setting.Global

View file

@ -83,7 +83,6 @@ func (d *dockerImage) Pull(ctx context.Context, ref string, opts PullOptions) (<
} }
pullOpts.RegistryAuth = encoded pullOpts.RegistryAuth = encoded
} }
reader, err := d.cli.ImagePull(ctx, ref, pullOpts) reader, err := d.cli.ImagePull(ctx, ref, pullOpts)
if err != nil { if err != nil {
return nil, fmt.Errorf("image pull %q: %w", ref, err) return nil, fmt.Errorf("image pull %q: %w", ref, err)