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:
parent
3b642fea78
commit
bde4442ff6
6 changed files with 1162 additions and 3 deletions
5
Makefile
5
Makefile
|
|
@ -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
796
openapi/setting/sandbox.go
Normal 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})
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
|
||||||
315
openapi/tests/setting/sandbox_test.go
Normal file
315
openapi/tests/setting/sandbox_test.go
Normal 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(®Data)
|
||||||
|
|
||||||
|
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(®Data)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue