yao/openapi/workspace/workspace.go
Max 6f1cc27baf feat(sandbox): enhance workspace and node handling in lifecycle management
- Updated the BuildIdentifier function to use the ownerID as a fallback for workspaceID, improving identifier generation consistency.
- Enhanced ResolveNodeID to auto-select nodes based on filters and added detailed logging for better traceability.
- Modified GetComputer to include improved logging and streamlined node resolution logic.
- Introduced StringOrArray type for flexible handling of computer filter parameters in the SandboxConfig.
- Enriched workspace response structure to include detailed node information, enhancing API response clarity.
- Implemented default workspace ID generation based on owner and node, ensuring consistent workspace identification.
- Added locale support in CreateOptions for better internationalization.
2026-03-21 20:10:08 +08:00

530 lines
13 KiB
Go

package workspace
import (
"context"
"encoding/base64"
"fmt"
"io"
"mime"
"net/http"
"path/filepath"
"sort"
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth/authorized"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
"github.com/yaoapp/yao/tai/registry"
ws "github.com/yaoapp/yao/workspace"
)
// Attach registers workspace management routes on the given group.
// - GET / — list workspaces (filtered by owner from token)
// - POST / — create workspace (owner from token)
// - GET /:id — get workspace (owner check)
// - PUT /:id — update workspace (owner check)
// - DELETE /:id — delete workspace (owner check)
// - GET /:id/files — list files
// - GET /:id/files/*path — read file
// - PUT /:id/files/*path — write file
// - DELETE /:id/files/*path — delete file
// - POST /:id/mkdir — create directory
// - POST /:id/rename — rename file/directory
// - GET /:id/rootdir — get workspace root directory absolute path
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
group.Use(oauth.Guard)
group.GET("", handleList)
group.GET("/options", handleOptions)
group.POST("", handleCreate)
group.GET("/:id", handleGet)
group.PUT("/:id", handleUpdate)
group.DELETE("/:id", handleDelete)
group.GET("/:id/rootdir", handleRootDir)
group.GET("/:id/files", handleListFiles)
group.GET("/:id/files/*path", handleReadFile)
group.PUT("/:id/files/*path", handleWriteFile)
group.DELETE("/:id/files/*path", handleDeleteFile)
group.POST("/:id/mkdir", handleMkdir)
group.POST("/:id/rename", handleRename)
}
// resolveOwner returns TeamID if present, otherwise UserID.
func resolveOwner(authInfo *types.AuthorizedInfo) string {
if authInfo != nil && authInfo.TeamID != "" {
return authInfo.TeamID
}
if authInfo != nil {
return authInfo.UserID
}
return ""
}
// checkWSOwner verifies the caller owns the workspace.
func checkWSOwner(c *gin.Context, w *ws.Workspace, owner string) bool {
if owner == "" {
return true
}
if w.Owner != "" && w.Owner != owner {
c.JSON(http.StatusForbidden, gin.H{"error": "no permission to access this workspace"})
return false
}
return true
}
// --- request / response types ---
type createRequest struct {
ID string `json:"id,omitempty"`
Name string `json:"name" binding:"required"`
Node string `json:"node" binding:"required"`
Labels map[string]string `json:"labels,omitempty"`
}
type updateRequest struct {
Name *string `json:"name,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
type mkdirRequest struct {
Path string `json:"path" binding:"required"`
}
type renameRequest struct {
OldPath string `json:"old_path" binding:"required"`
NewPath string `json:"new_path" binding:"required"`
}
type workspaceResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
Node string `json:"node"`
NodeName string `json:"node_name,omitempty"`
NodeOS string `json:"node_os,omitempty"`
NodeArch string `json:"node_arch,omitempty"`
NodeKind string `json:"node_kind,omitempty"`
NodeOnline bool `json:"node_online"`
NodeCapabilities map[string]bool `json:"node_capabilities,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type optionsResponse struct {
Data []workspaceResponse `json:"data"`
HasOnlineNodes bool `json:"has_online_nodes"`
}
func toResponse(w *ws.Workspace) workspaceResponse {
return workspaceResponse{
ID: w.ID,
Name: w.Name,
Owner: w.Owner,
Node: w.Node,
Labels: w.Labels,
CreatedAt: w.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: w.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func mgr() *ws.Manager {
return ws.M()
}
// resolveAndCheckWS fetches the workspace and verifies owner permission.
func resolveAndCheckWS(c *gin.Context) (*ws.Workspace, bool) {
m := mgr()
if m == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "workspace service not available"})
return nil, false
}
w, err := m.Get(context.Background(), c.Param("id"))
if err != nil {
if err == ws.ErrNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return nil, false
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return nil, false
}
authInfo := authorized.GetInfo(c)
if !checkWSOwner(c, w, resolveOwner(authInfo)) {
return nil, false
}
return w, true
}
// --- handlers ---
func handleList(c *gin.Context) {
m := mgr()
if m == nil {
response.RespondWithSuccess(c, http.StatusOK, []workspaceResponse{})
return
}
authInfo := authorized.GetInfo(c)
owner := resolveOwner(authInfo)
list, err := m.List(context.Background(), ws.ListOptions{
Owner: owner,
Node: c.Query("node"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
result := make([]workspaceResponse, 0, len(list))
for _, w := range list {
result = append(result, toResponse(w))
}
sort.Slice(result, func(i, j int) bool {
return result[i].CreatedAt > result[j].CreatedAt
})
response.RespondWithSuccess(c, http.StatusOK, result)
}
// handleOptions returns workspace options for the InputArea selector.
// Each workspace is enriched with its node's display info (name, OS, arch, kind, online).
// The response also includes has_online_nodes so the frontend can determine sendBlocked
// even when the workspace list is empty.
func handleOptions(c *gin.Context) {
m := mgr()
if m == nil {
response.RespondWithSuccess(c, http.StatusOK, optionsResponse{Data: []workspaceResponse{}})
return
}
authInfo := authorized.GetInfo(c)
owner := resolveOwner(authInfo)
list, err := m.List(context.Background(), ws.ListOptions{
Owner: owner,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
nodeMap := buildNodeMap()
hasOnline := false
for _, n := range nodeMap {
if n.online {
hasOnline = true
break
}
}
result := make([]workspaceResponse, 0, len(list))
for _, w := range list {
r := toResponse(w)
if info, ok := nodeMap[w.Node]; ok {
r.NodeName = info.displayName
r.NodeOS = info.os
r.NodeArch = info.arch
r.NodeKind = info.kind
r.NodeOnline = info.online
r.NodeCapabilities = info.capabilities
}
result = append(result, r)
}
sort.Slice(result, func(i, j int) bool {
return result[i].CreatedAt > result[j].CreatedAt
})
response.RespondWithSuccess(c, http.StatusOK, optionsResponse{
Data: result,
HasOnlineNodes: hasOnline,
})
}
type nodeInfo struct {
displayName string
os string
arch string
kind string
online bool
capabilities map[string]bool
}
func buildNodeMap() map[string]nodeInfo {
reg := registry.Global()
if reg == nil {
return nil
}
nodes := reg.List()
m := make(map[string]nodeInfo, len(nodes))
for _, n := range nodes {
kind := "node"
if n.Mode == "local" {
kind = "host"
}
name := n.DisplayName
if name == "" {
name = n.System.Hostname
}
if name == "" {
name = n.TaiID
}
caps := map[string]bool{}
if n.Capabilities.HostExec {
caps["host_exec"] = true
}
if n.Capabilities.Docker {
caps["docker"] = true
}
if n.Capabilities.K8s {
caps["k8s"] = true
}
if n.Capabilities.VNC {
caps["vnc"] = true
}
m[n.TaiID] = nodeInfo{
displayName: name,
os: strings.ToLower(n.System.OS),
arch: strings.ToLower(n.System.Arch),
kind: kind,
online: n.Status == "online" || n.Status == "",
capabilities: caps,
}
}
return m
}
func handleCreate(c *gin.Context) {
m := mgr()
if m == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "workspace service not available"})
return
}
var req createRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
authInfo := authorized.GetInfo(c)
owner := resolveOwner(authInfo)
w, err := m.Create(context.Background(), ws.CreateOptions{
ID: req.ID,
Name: req.Name,
Owner: owner,
Node: req.Node,
Labels: req.Labels,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
response.RespondWithSuccess(c, http.StatusCreated, toResponse(w))
}
func handleGet(c *gin.Context) {
w, ok := resolveAndCheckWS(c)
if !ok {
return
}
response.RespondWithSuccess(c, http.StatusOK, toResponse(w))
}
func handleUpdate(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
var req updateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
w, err := mgr().Update(context.Background(), c.Param("id"), ws.UpdateOptions{
Name: req.Name,
Labels: req.Labels,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
response.RespondWithSuccess(c, http.StatusOK, toResponse(w))
}
func handleDelete(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
force := c.Query("force") == "true"
if err := mgr().Delete(context.Background(), c.Param("id"), force); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func handleRootDir(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
rootDir, err := mgr().MountPath(context.Background(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
response.RespondWithSuccess(c, http.StatusOK, gin.H{"root_dir": rootDir})
}
func handleListFiles(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
dir := c.DefaultQuery("path", ".")
entries, err := mgr().ListDir(context.Background(), c.Param("id"), dir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
response.RespondWithSuccess(c, http.StatusOK, entries)
}
func handleReadFile(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
path := c.Param("path")
if len(path) > 0 && path[0] == '/' {
path = path[1:]
}
fmt.Printf("[workspace] handleReadFile id=%s path=%q\n", c.Param("id"), path)
data, err := mgr().ReadFile(context.Background(), c.Param("id"), path)
if err != nil {
fmt.Printf("[workspace] ReadFile error: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
fmt.Printf("[workspace] ReadFile ok, size=%d, encoding=%q\n", len(data), c.Query("encoding"))
if c.Query("encoding") == "base64" {
response.RespondWithSuccess(c, http.StatusOK, gin.H{
"content": base64.StdEncoding.EncodeToString(data),
"encoding": "base64",
})
return
}
ext := filepath.Ext(path)
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = "application/octet-stream"
}
fmt.Printf("[workspace] serving ext=%q mime=%q size=%d\n", ext, mimeType, len(data))
c.Data(http.StatusOK, mimeType, data)
}
func handleWriteFile(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
path := c.Param("path")
if len(path) > 0 && path[0] == '/' {
path = path[1:]
}
data, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
return
}
if err := mgr().WriteFile(context.Background(), c.Param("id"), path, data, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func handleDeleteFile(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
path := c.Param("path")
if len(path) > 0 && path[0] == '/' {
path = path[1:]
}
if err := mgr().Remove(context.Background(), c.Param("id"), path); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func handleMkdir(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
var req mkdirRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := mgr().MkdirAll(context.Background(), c.Param("id"), req.Path); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func handleRename(c *gin.Context) {
_, ok := resolveAndCheckWS(c)
if !ok {
return
}
var req renameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := mgr().Rename(context.Background(), c.Param("id"), req.OldPath, req.NewPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}