feat(tai): refactor Dial* functions and remove requireKubeConfig hard-fail

- Consolidate DialRemote/DialTunnel common logic into buildResources + dialEnv interface
- Remove strict capability check that prevented ConnResources creation for host-exec-only nodes
- Merge gRPC-discovered capabilities with registration-declared capabilities in DialTunnel
- Replace requireKubeConfig hard-fail with graceful skip when kubeconfig is absent
- Introduce tai/types package for shared Ports/Capabilities/SystemInfo/AuthInfo/NodeMeta
- Add tai/conn.go (ConnResources) and tai/dial.go (DialRemote/DialTunnel/DialLocal)
- Rename tai/sandbox → tai/runtime for clarity
- Update sandbox/v2, workspace, agent/sandbox/v2 test utilities for build-tag isolation

Made-with: Cursor
This commit is contained in:
Max 2026-03-12 15:44:24 +08:00
parent c366ce4d0a
commit 7cccf62841
48 changed files with 1670 additions and 1458 deletions

1
.gitignore vendored
View file

@ -76,3 +76,4 @@ registry/data/
registry/manager/DESIGN*.md registry/manager/DESIGN*.md
tai/testdata/ tai/testdata/
agent/sandbox/docs/*.md agent/sandbox/docs/*.md
tai/docs/refactor-registration.md

View file

@ -0,0 +1,17 @@
//go:build remote
package sandboxv2_test
import "os"
func init() {
extraNodeProviders = append(extraNodeProviders, agentRemoteNodes)
}
func agentRemoteNodes() []nodeConfig {
addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR")
if addr == "" {
return nil
}
return []nodeConfig{{Name: "remote", Addr: addr}}
}

View file

@ -13,19 +13,27 @@ import (
sandbox "github.com/yaoapp/yao/sandbox/v2" sandbox "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tai" "github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/registry"
taisandbox "github.com/yaoapp/yao/tai/sandbox" tairuntime "github.com/yaoapp/yao/tai/runtime"
"github.com/yaoapp/yao/workspace" "github.com/yaoapp/yao/workspace"
) )
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// node configuration — mirrors sandbox/v2 testutils but scoped to prepare tests // Build-tag extension points (same pattern as sandbox/v2).
// ---------------------------------------------------------------------------
var (
extraNodeProviders []func() []nodeConfig
extraHostExecProviders []func() []hostTarget
)
// ---------------------------------------------------------------------------
// Node / host configuration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type nodeConfig struct { type nodeConfig struct {
Name string Name string
Addr string Addr string
TaiID string TaiID string
Options []tai.Option DialOps []tai.DialOption
} }
type hostTarget struct { type hostTarget struct {
@ -35,7 +43,7 @@ type hostTarget struct {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// environment helpers (same conventions as sandbox/v2 + env.local.sh) // Environment helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func testLocalAddr() string { func testLocalAddr() string {
@ -62,30 +70,84 @@ func envPort(key string, fallback int) int {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// node discovery // Node / host discovery
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func boxNodes() []nodeConfig { func boxNodes() []nodeConfig {
nodes := []nodeConfig{ nodes := []nodeConfig{
{Name: "local", Addr: testLocalAddr()}, {Name: "local", Addr: testLocalAddr()},
} }
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" { for _, fn := range extraNodeProviders {
nodes = append(nodes, nodeConfig{Name: "remote", Addr: addr}) nodes = append(nodes, fn()...)
} }
return nodes return nodes
} }
func hostTargets() []hostTarget { func hostTargets() []hostTarget {
var targets []hostTarget var targets []hostTarget
if addr := os.Getenv("TAI_TEST_WIN_HOSTEXEC_LINUX"); addr != "" { for _, fn := range extraHostExecProviders {
targets = append(targets, hostTarget{Name: "win-linux", Addr: addr}) targets = append(targets, fn()...)
}
if addr := os.Getenv("TAI_TEST_WIN_HOSTEXEC_NATIVE"); addr != "" {
targets = append(targets, hostTarget{Name: "win-native", Addr: addr})
} }
return targets return targets
} }
// ---------------------------------------------------------------------------
// Dial + Register helper (replaces old tai.New)
// ---------------------------------------------------------------------------
func dialForTest(addr string, dialOps ...tai.DialOption) (*tai.ConnResources, error) {
if addr == "local" || addr == "" {
return tai.DialLocal("", "", nil)
}
host, grpcPort := parseHostPort(addr)
ports := tai.Ports{GRPC: grpcPort}
return tai.DialRemote(host, ports, dialOps...)
}
func registerForTest(t testing.TB, addr string, dialOps ...tai.DialOption) (string, *tai.ConnResources) {
t.Helper()
if registry.Global() == nil {
registry.Init(nil)
}
res, err := dialForTest(addr, dialOps...)
if err != nil {
t.Fatalf("dialForTest(%s): %v", addr, err)
}
taiID := taiIDFromAddr(addr)
reg := registry.Global()
reg.Register(&registry.TaiNode{TaiID: taiID, Mode: modeForAddr(addr)})
reg.SetResources(taiID, res)
return taiID, res
}
func taiIDFromAddr(addr string) string {
if addr == "local" || addr == "" {
return "local"
}
addr = strings.TrimPrefix(addr, "tai://")
parts := strings.SplitN(addr, ":", 2)
return parts[0]
}
func modeForAddr(addr string) string {
if addr == "local" || addr == "" {
return "local"
}
return "direct"
}
func parseHostPort(addr string) (string, int) {
addr = strings.TrimPrefix(addr, "tai://")
parts := strings.SplitN(addr, ":", 2)
h := parts[0]
if len(parts) == 2 {
if p, err := strconv.Atoi(parts[1]); err == nil {
return h, p
}
}
return h, 19100
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// TestMain — purge stale containers from previous runs // TestMain — purge stale containers from previous runs
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -100,16 +162,16 @@ func purgeStale() {
defer cancel() defer cancel()
for _, nc := range boxNodes() { for _, nc := range boxNodes() {
client, err := tai.New(nc.Addr, nc.Options...) res, err := dialForTest(nc.Addr, nc.DialOps...)
if err != nil { if err != nil {
continue continue
} }
sb := client.Sandbox() sb := res.Runtime
if sb == nil { if sb == nil {
client.Close() res.Close()
continue continue
} }
containers, _ := sb.List(ctx, taisandbox.ListOptions{All: true}) containers, _ := sb.List(ctx, tairuntime.ListOptions{All: true})
for _, c := range containers { for _, c := range containers {
id := c.Name id := c.Name
if id == "" { if id == "" {
@ -120,7 +182,7 @@ func purgeStale() {
log.Printf("[purge] %s: removed %s", nc.Name, id) log.Printf("[purge] %s: removed %s", nc.Name, id)
} }
} }
client.Close() res.Close()
} }
} }
@ -133,11 +195,9 @@ func setupManager(t *testing.T, nc *nodeConfig) *sandbox.Manager {
if registry.Global() == nil { if registry.Global() == nil {
registry.Init(nil) registry.Init(nil)
} }
client, err := tai.New(nc.Addr, nc.Options...) taiID, res := registerForTest(t, nc.Addr, nc.DialOps...)
if err != nil { nc.TaiID = taiID
t.Fatalf("tai.New(%s): %v", nc.Addr, err) t.Cleanup(func() { res.Close() })
}
nc.TaiID = client.TaiID()
sandbox.Init() sandbox.Init()
m := sandbox.M() m := sandbox.M()
@ -191,7 +251,7 @@ func setupHostManager(t *testing.T, tgt *hostTarget) *sandbox.Manager {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// skip helpers // Skip helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func skipIfNoDocker(t *testing.T) { func skipIfNoDocker(t *testing.T) {

View file

@ -0,0 +1,20 @@
//go:build wintest
package sandboxv2_test
import "os"
func init() {
extraHostExecProviders = append(extraHostExecProviders, agentWinHostExec)
}
func agentWinHostExec() []hostTarget {
var targets []hostTarget
if addr := os.Getenv("TAI_TEST_WIN_HOSTEXEC_LINUX"); addr != "" {
targets = append(targets, hostTarget{Name: "win-linux", Addr: addr})
}
if addr := os.Getenv("TAI_TEST_WIN_HOSTEXEC_NATIVE"); addr != "" {
targets = append(targets, hostTarget{Name: "win-native", Addr: addr})
}
return targets
}

View file

@ -10,6 +10,7 @@ import (
sandboxv2 "github.com/yaoapp/yao/sandbox/v2" sandboxv2 "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tai" "github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/registry"
taitypes "github.com/yaoapp/yao/tai/types"
"github.com/yaoapp/yao/openapi/oauth/authorized" "github.com/yaoapp/yao/openapi/oauth/authorized"
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types" oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
@ -87,7 +88,7 @@ func handleOptions(c *gin.Context) {
if !nodeOwnedBy(s, authInfo) { if !nodeOwnedBy(s, authInfo) {
continue continue
} }
if !s.Capabilities["host_exec"] { if !s.Capabilities.HostExec {
continue continue
} }
if !matchNodeFilter(s, osFilter, archFilter, minCPUs, minMem) { if !matchNodeFilter(s, osFilter, archFilter, minCPUs, minMem) {
@ -104,7 +105,7 @@ func handleOptions(c *gin.Context) {
if !nodeOwnedBy(s, authInfo) { if !nodeOwnedBy(s, authInfo) {
continue continue
} }
hasRuntime := s.Capabilities["docker"] || s.Capabilities["k8s"] hasRuntime := s.Capabilities.Docker || s.Capabilities.K8s
if !hasRuntime { if !hasRuntime {
continue continue
} }
@ -147,7 +148,7 @@ func handleOptions(c *gin.Context) {
response.RespondWithSuccess(c, http.StatusOK, result) response.RespondWithSuccess(c, http.StatusOK, result)
} }
func matchNodeFilter(s *registry.NodeSnapshot, osFilter, archFilter string, minCPUs float64, minMem int64) bool { func matchNodeFilter(s *taitypes.NodeMeta, osFilter, archFilter string, minCPUs float64, minMem int64) bool {
if osFilter != "" && !strings.EqualFold(s.System.OS, osFilter) { if osFilter != "" && !strings.EqualFold(s.System.OS, osFilter) {
return false return false
} }
@ -163,7 +164,7 @@ func matchNodeFilter(s *registry.NodeSnapshot, osFilter, archFilter string, minC
return true return true
} }
func nodeToHostOption(s registry.NodeSnapshot) computerOption { func nodeToHostOption(s taitypes.NodeMeta) computerOption {
displayName := s.DisplayName displayName := s.DisplayName
if displayName == "" { if displayName == "" {
displayName = s.System.Hostname displayName = s.System.Hostname
@ -204,7 +205,7 @@ func nodeToHostOption(s registry.NodeSnapshot) computerOption {
} }
} }
func nodeToNodeOption(s registry.NodeSnapshot) computerOption { func nodeToNodeOption(s taitypes.NodeMeta) computerOption {
displayName := s.DisplayName displayName := s.DisplayName
if displayName == "" { if displayName == "" {
displayName = s.System.Hostname displayName = s.System.Hostname
@ -255,7 +256,7 @@ func boxToOption(b *sandboxv2.Box) computerOption {
} }
var mode, addr string var mode, addr string
if ns, ok := tai.GetNodeSnapshot(snap.NodeID); ok { if ns, ok := tai.GetNodeMeta(snap.NodeID); ok {
mode = ns.Mode mode = ns.Mode
addr = ns.Addr addr = ns.Addr
} }
@ -289,7 +290,7 @@ func boxToOption(b *sandboxv2.Box) computerOption {
} }
} }
func nodeOwnedBy(snap *registry.NodeSnapshot, authInfo *oauthTypes.AuthorizedInfo) bool { func nodeOwnedBy(snap *taitypes.NodeMeta, authInfo *oauthTypes.AuthorizedInfo) bool {
if authInfo == nil { if authInfo == nil {
return true return true
} }

View file

@ -9,6 +9,7 @@ import (
"github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response" "github.com/yaoapp/yao/openapi/response"
"github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/registry"
taitypes "github.com/yaoapp/yao/tai/types"
) )
// Attach registers Tai node endpoints on the given group. // Attach registers Tai node endpoints on the given group.
@ -44,7 +45,7 @@ type systemResponse struct {
Shell string `json:"shell,omitempty"` Shell string `json:"shell,omitempty"`
} }
func snapToResponse(s registry.NodeSnapshot) nodeResponse { func snapToResponse(s taitypes.NodeMeta) nodeResponse {
r := nodeResponse{ r := nodeResponse{
TaiID: s.TaiID, TaiID: s.TaiID,
MachineID: s.MachineID, MachineID: s.MachineID,
@ -53,8 +54,8 @@ func snapToResponse(s registry.NodeSnapshot) nodeResponse {
Mode: s.Mode, Mode: s.Mode,
Addr: s.Addr, Addr: s.Addr,
Status: s.Status, Status: s.Status,
Capabilities: s.Capabilities, Capabilities: map[string]bool{"docker": s.Capabilities.Docker, "k8s": s.Capabilities.K8s, "host_exec": s.Capabilities.HostExec},
Ports: s.Ports, Ports: map[string]int{"grpc": s.Ports.GRPC, "http": s.Ports.HTTP, "vnc": s.Ports.VNC, "docker": s.Ports.Docker, "k8s": s.Ports.K8s},
System: systemResponse{ System: systemResponse{
OS: s.System.OS, OS: s.System.OS,
Arch: s.System.Arch, Arch: s.System.Arch,
@ -75,7 +76,7 @@ func snapToResponse(s registry.NodeSnapshot) nodeResponse {
// nodeOwnedBy checks whether a node belongs to the caller. // nodeOwnedBy checks whether a node belongs to the caller.
// TeamID match → true; no team and UserID match → true. // TeamID match → true; no team and UserID match → true.
func nodeOwnedBy(snap *registry.NodeSnapshot, authInfo *types.AuthorizedInfo) bool { func nodeOwnedBy(snap *taitypes.NodeMeta, authInfo *types.AuthorizedInfo) bool {
if authInfo == nil { if authInfo == nil {
return true return true
} }
@ -98,7 +99,7 @@ func handleList(c *gin.Context) {
authInfo := authorized.GetInfo(c) authInfo := authorized.GetInfo(c)
var snaps []registry.NodeSnapshot var snaps []taitypes.NodeMeta
if authInfo != nil && authInfo.TeamID != "" { if authInfo != nil && authInfo.TeamID != "" {
snaps = reg.ListByTeam(authInfo.TeamID) snaps = reg.ListByTeam(authInfo.TeamID)
} else if authInfo != nil && authInfo.UserID != "" { } else if authInfo != nil && authInfo.UserID != "" {

View file

@ -13,6 +13,7 @@ import (
sandboxv2 "github.com/yaoapp/yao/sandbox/v2" sandboxv2 "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tai" "github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/registry"
taitypes "github.com/yaoapp/yao/tai/types"
) )
// AttachManage registers sandbox management CRUD routes on the given group. // AttachManage registers sandbox management CRUD routes on the given group.
@ -115,7 +116,7 @@ func boxToResponse(b *sandboxv2.Box) sandboxResponse {
} }
var mode, addr string var mode, addr string
if ns, ok := tai.GetNodeSnapshot(snap.NodeID); ok { if ns, ok := tai.GetNodeMeta(snap.NodeID); ok {
mode = ns.Mode mode = ns.Mode
addr = ns.Addr addr = ns.Addr
} }
@ -157,7 +158,7 @@ func boxToResponse(b *sandboxv2.Box) sandboxResponse {
} }
} }
func hostToResponse(s registry.NodeSnapshot) sandboxResponse { func hostToResponse(s taitypes.NodeMeta) sandboxResponse {
displayName := s.DisplayName displayName := s.DisplayName
if displayName == "" { if displayName == "" {
displayName = s.System.Hostname displayName = s.System.Hostname
@ -209,7 +210,7 @@ func hostToResponse(s registry.NodeSnapshot) sandboxResponse {
} }
} }
func nodeOwnedBy(snap *registry.NodeSnapshot, authInfo *types.AuthorizedInfo) bool { func nodeOwnedBy(snap *taitypes.NodeMeta, authInfo *types.AuthorizedInfo) bool {
if authInfo == nil { if authInfo == nil {
return true return true
} }
@ -257,7 +258,7 @@ func handleList(c *gin.Context) {
if !nodeOwnedBy(s, authInfo) { if !nodeOwnedBy(s, authInfo) {
continue continue
} }
if !s.Capabilities["host_exec"] { if !s.Capabilities.HostExec {
continue continue
} }
if nodeFilter != "" && s.TaiID != nodeFilter { if nodeFilter != "" && s.TaiID != nodeFilter {

View file

@ -7,7 +7,6 @@ import (
"time" "time"
sandbox "github.com/yaoapp/yao/sandbox/v2" sandbox "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/registry"
) )
@ -225,15 +224,12 @@ func BenchmarkWorkspaceReadWrite(b *testing.B) {
func setupManagerForBench(b *testing.B, pc *nodeConfig) *sandbox.Manager { func setupManagerForBench(b *testing.B, pc *nodeConfig) *sandbox.Manager {
b.Helper() b.Helper()
reg := registry.Global() if registry.Global() == nil {
if reg == nil {
registry.Init(nil) registry.Init(nil)
} }
client, err := tai.New(pc.Addr, pc.Options...) taiID, res := registerForTest(b, pc.Addr, pc.DialOps...)
if err != nil { pc.TaiID = taiID
b.Fatalf("tai.New(%s): %v", pc.Addr, err) b.Cleanup(func() { res.Close() })
}
pc.TaiID = client.TaiID()
sandbox.Init() sandbox.Init()
m := sandbox.M() m := sandbox.M()
b.Cleanup(func() { m.Close() }) b.Cleanup(func() { m.Close() })

View file

@ -7,8 +7,8 @@ import (
"time" "time"
"github.com/yaoapp/yao/tai/proxy" "github.com/yaoapp/yao/tai/proxy"
taisandbox "github.com/yaoapp/yao/tai/sandbox" tairuntime "github.com/yaoapp/yao/tai/runtime"
"github.com/yaoapp/yao/tai/workspace" taiworkspace "github.com/yaoapp/yao/tai/workspace"
) )
// Box represents a single sandbox instance. // Box represents a single sandbox instance.
@ -30,7 +30,7 @@ type Box struct {
image string image string
workspaceID string workspaceID string
system SystemInfo system SystemInfo
ws workspace.FS ws taiworkspace.FS
manager *Manager manager *Manager
} }
@ -69,7 +69,7 @@ func (b *Box) BindWorkplace(workspaceID string) {
// Workplace returns the workspace FS bound to this Box. // Workplace returns the workspace FS bound to this Box.
// If a workspace was bound via CreateOptions.WorkspaceID or BindWorkplace(), // If a workspace was bound via CreateOptions.WorkspaceID or BindWorkplace(),
// returns that workspace's FS. Otherwise returns nil. // returns that workspace's FS. Otherwise returns nil.
func (b *Box) Workplace() workspace.FS { func (b *Box) Workplace() taiworkspace.FS {
return b.Workspace() return b.Workspace()
} }
@ -81,12 +81,12 @@ func (b *Box) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*Exec
o(cfg) o(cfg)
} }
client, err := b.manager.getNode(b.nodeID) res, err := b.manager.getNode(b.nodeID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
result, err := client.Sandbox().Exec(ctx, b.containerID, cmd, taisandbox.ExecOptions{ result, err := res.Runtime.Exec(ctx, b.containerID, cmd, tairuntime.ExecOptions{
WorkDir: cfg.WorkDir, WorkDir: cfg.WorkDir,
Env: cfg.Env, Env: cfg.Env,
}) })
@ -111,12 +111,12 @@ func (b *Box) Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*Ex
o(cfg) o(cfg)
} }
client, err := b.manager.getNode(b.nodeID) res, err := b.manager.getNode(b.nodeID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
handle, err := client.Sandbox().ExecStream(ctx, b.containerID, cmd, taisandbox.ExecOptions{ handle, err := res.Runtime.ExecStream(ctx, b.containerID, cmd, tairuntime.ExecOptions{
WorkDir: cfg.WorkDir, WorkDir: cfg.WorkDir,
Env: cfg.Env, Env: cfg.Env,
}) })
@ -141,12 +141,12 @@ func (b *Box) Attach(ctx context.Context, port int, opts ...AttachOption) (*Serv
o(cfg) o(cfg)
} }
client, err := b.manager.getNode(b.nodeID) res, err := b.manager.getNode(b.nodeID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
conn, err := client.Proxy().Connect(ctx, b.containerID, proxy.ConnectOptions{ conn, err := res.Proxy.Connect(ctx, b.containerID, proxy.ConnectOptions{
Port: port, Port: port,
Path: cfg.Path, Path: cfg.Path,
Protocol: cfg.Protocol, Protocol: cfg.Protocol,
@ -178,7 +178,7 @@ func (b *Box) Attach(ctx context.Context, port int, opts ...AttachOption) (*Serv
// Workspace returns an fs.FS-compatible filesystem for this sandbox. // Workspace returns an fs.FS-compatible filesystem for this sandbox.
// If a workspace is mounted (WorkspaceID set), uses the workspace ID as session; // If a workspace is mounted (WorkspaceID set), uses the workspace ID as session;
// otherwise falls back to the sandbox ID (backward compatible). // otherwise falls back to the sandbox ID (backward compatible).
func (b *Box) Workspace() workspace.FS { func (b *Box) Workspace() taiworkspace.FS {
b.touch() b.touch()
if b.ws != nil { if b.ws != nil {
return b.ws return b.ws
@ -187,11 +187,11 @@ func (b *Box) Workspace() workspace.FS {
if sessionID == "" { if sessionID == "" {
sessionID = b.id sessionID = b.id
} }
client, err := b.manager.getNode(b.nodeID) res, err := b.manager.getNode(b.nodeID)
if err != nil { if err != nil {
return nil return nil
} }
b.ws = client.Workspace(sessionID) b.ws = taiworkspace.New(res.Volume, sessionID)
return b.ws return b.ws
} }
@ -220,39 +220,39 @@ func (b *Box) Snapshot() BoxInfo {
// VNC returns the VNC WebSocket URL. // VNC returns the VNC WebSocket URL.
func (b *Box) VNC(ctx context.Context) (string, error) { func (b *Box) VNC(ctx context.Context) (string, error) {
b.touch() b.touch()
client, err := b.manager.getNode(b.nodeID) res, err := b.manager.getNode(b.nodeID)
if err != nil { if err != nil {
return "", err return "", err
} }
return client.VNC().URL(ctx, b.containerID) return res.VNC.URL(ctx, b.containerID)
} }
// Proxy returns the HTTP URL for a service on the given port inside the sandbox. // Proxy returns the HTTP URL for a service on the given port inside the sandbox.
func (b *Box) Proxy(ctx context.Context, port int, path string) (string, error) { func (b *Box) Proxy(ctx context.Context, port int, path string) (string, error) {
b.touch() b.touch()
client, err := b.manager.getNode(b.nodeID) res, err := b.manager.getNode(b.nodeID)
if err != nil { if err != nil {
return "", err return "", err
} }
return client.Proxy().URL(ctx, b.containerID, port, path) return res.Proxy.URL(ctx, b.containerID, port, path)
} }
// Start starts a stopped sandbox. // Start starts a stopped sandbox.
func (b *Box) Start(ctx context.Context) error { func (b *Box) Start(ctx context.Context) error {
client, err := b.manager.getNode(b.nodeID) res, err := b.manager.getNode(b.nodeID)
if err != nil { if err != nil {
return err return err
} }
return client.Sandbox().Start(ctx, b.containerID) return res.Runtime.Start(ctx, b.containerID)
} }
// Stop stops the sandbox without removing it. // Stop stops the sandbox without removing it.
func (b *Box) Stop(ctx context.Context) error { func (b *Box) Stop(ctx context.Context) error {
client, err := b.manager.getNode(b.nodeID) res, err := b.manager.getNode(b.nodeID)
if err != nil { if err != nil {
return err return err
} }
return client.Sandbox().Stop(ctx, b.containerID, b.stopTimeout()) return res.Runtime.Stop(ctx, b.containerID, b.stopTimeout())
} }
// Remove stops and removes the sandbox. // Remove stops and removes the sandbox.
@ -262,12 +262,12 @@ func (b *Box) Remove(ctx context.Context) error {
// Info returns current sandbox status. // Info returns current sandbox status.
func (b *Box) Info(ctx context.Context) (*BoxInfo, error) { func (b *Box) Info(ctx context.Context) (*BoxInfo, error) {
client, err := b.manager.getNode(b.nodeID) res, err := b.manager.getNode(b.nodeID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
info, err := client.Sandbox().Inspect(ctx, b.containerID) info, err := res.Runtime.Inspect(ctx, b.containerID)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -7,7 +7,7 @@ import (
"io" "io"
hepb "github.com/yaoapp/yao/tai/hostexec/pb" hepb "github.com/yaoapp/yao/tai/hostexec/pb"
"github.com/yaoapp/yao/tai/workspace" taiworkspace "github.com/yaoapp/yao/tai/workspace"
) )
// Host represents a Tai host machine execution environment. // Host represents a Tai host machine execution environment.
@ -44,12 +44,12 @@ func (h *Host) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*Exe
return nil, fmt.Errorf("sandbox: empty command") return nil, fmt.Errorf("sandbox: empty command")
} }
client, err := h.manager.getNode(h.nodeID) res, err := h.manager.getNode(h.nodeID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
he := client.HostExec() he := res.HostExec
if he == nil { if he == nil {
return nil, fmt.Errorf("sandbox: host_exec not available on node %q", h.nodeID) return nil, fmt.Errorf("sandbox: host_exec not available on node %q", h.nodeID)
} }
@ -100,12 +100,12 @@ func (h *Host) Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*E
return nil, fmt.Errorf("sandbox: empty command") return nil, fmt.Errorf("sandbox: empty command")
} }
client, err := h.manager.getNode(h.nodeID) res, err := h.manager.getNode(h.nodeID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
he := client.HostExec() he := res.HostExec
if he == nil { if he == nil {
return nil, fmt.Errorf("sandbox: host_exec not available on node %q", h.nodeID) return nil, fmt.Errorf("sandbox: host_exec not available on node %q", h.nodeID)
} }
@ -189,21 +189,27 @@ func (h *Host) Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*E
// VNC returns the VNC WebSocket URL for the Tai host machine. // VNC returns the VNC WebSocket URL for the Tai host machine.
// Uses the special __host__ identifier to route to localhost:5900 on the Tai server. // Uses the special __host__ identifier to route to localhost:5900 on the Tai server.
func (h *Host) VNC(ctx context.Context) (string, error) { func (h *Host) VNC(ctx context.Context) (string, error) {
client, err := h.manager.getNode(h.nodeID) res, err := h.manager.getNode(h.nodeID)
if err != nil { if err != nil {
return "", err return "", err
} }
return client.VNC().URL(ctx, "__host__") if res.VNC == nil {
return "", fmt.Errorf("sandbox: vnc not available on node %q", h.nodeID)
}
return res.VNC.URL(ctx, "__host__")
} }
// Proxy returns the HTTP URL for a service running on the Tai host machine. // Proxy returns the HTTP URL for a service running on the Tai host machine.
// Uses the special __host__ identifier to route to localhost:{port} on the Tai server. // Uses the special __host__ identifier to route to localhost:{port} on the Tai server.
func (h *Host) Proxy(ctx context.Context, port int, path string) (string, error) { func (h *Host) Proxy(ctx context.Context, port int, path string) (string, error) {
client, err := h.manager.getNode(h.nodeID) res, err := h.manager.getNode(h.nodeID)
if err != nil { if err != nil {
return "", err return "", err
} }
return client.Proxy().URL(ctx, "__host__", port, path) if res.Proxy == nil {
return "", fmt.Errorf("sandbox: proxy not available on node %q", h.nodeID)
}
return res.Proxy.URL(ctx, "__host__", port, path)
} }
// BindWorkplace binds a workspace to this host by ID. Subsequent calls to // BindWorkplace binds a workspace to this host by ID. Subsequent calls to
@ -213,15 +219,18 @@ func (h *Host) BindWorkplace(workspaceID string) {
} }
// Workplace returns the workspace FS bound to this host, or nil if unbound. // Workplace returns the workspace FS bound to this host, or nil if unbound.
func (h *Host) Workplace() workspace.FS { func (h *Host) Workplace() taiworkspace.FS {
if h.workplaceID == "" { if h.workplaceID == "" {
return nil return nil
} }
client, err := h.manager.getNode(h.nodeID) res, err := h.manager.getNode(h.nodeID)
if err != nil { if err != nil {
return nil return nil
} }
return client.Workspace(h.workplaceID) if res.Volume == nil {
return nil
}
return taiworkspace.New(res.Volume, h.workplaceID)
} }
// NodeID returns the node ID this Host belongs to. // NodeID returns the node ID this Host belongs to.

View file

@ -9,7 +9,6 @@ import (
"time" "time"
sandbox "github.com/yaoapp/yao/sandbox/v2" sandbox "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tai"
) )
func setupHostManager(t *testing.T, tgt *hostExecTarget) *sandbox.Manager { func setupHostManager(t *testing.T, tgt *hostExecTarget) *sandbox.Manager {
@ -454,12 +453,12 @@ func findHostExecOnly(t *testing.T) *hostExecTarget {
for _, tgt := range hostExecTargets() { for _, tgt := range hostExecTargets() {
if tgt.IsWinNative { if tgt.IsWinNative {
addr := fmt.Sprintf("tai://%s", tgt.Addr) addr := fmt.Sprintf("tai://%s", tgt.Addr)
client, err := tai.New(addr) res, err := dialForTest(addr)
if err != nil { if err != nil {
continue continue
} }
hasNoSandbox := client.Sandbox() == nil hasNoSandbox := res.Runtime == nil
client.Close() res.Close()
if hasNoSandbox { if hasNoSandbox {
return &tgt return &tgt
} }

View file

@ -3,6 +3,7 @@ package jsapi_test
import ( import (
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -18,10 +19,9 @@ import (
) )
type testMode struct { type testMode struct {
Name string Name string
Addr string Addr string
TaiID string // filled by setupSandbox TaiID string
Options []tai.Option
} }
func testModes() []testMode { func testModes() []testMode {
@ -46,19 +46,71 @@ func setupSandbox(t *testing.T, m *testMode) {
reg := registry.Global() reg := registry.Global()
if reg == nil { if reg == nil {
registry.Init(nil) registry.Init(nil)
reg = registry.Global()
} }
client, err := tai.New(m.Addr, m.Options...) taiID, _ := registerForTest(t, m.Addr)
if err != nil { m.TaiID = taiID
t.Fatalf("tai.New: %v", err)
}
m.TaiID = client.TaiID()
sandbox.Init() sandbox.Init()
mgr := sandbox.M() mgr := sandbox.M()
t.Cleanup(func() { mgr.Close() }) t.Cleanup(func() { mgr.Close() })
} }
func registerForTest(t testing.TB, addr string, dialOps ...tai.DialOption) (string, *tai.ConnResources) {
t.Helper()
if registry.Global() == nil {
registry.Init(nil)
}
res, err := dialForTest(addr, dialOps...)
if err != nil {
t.Fatalf("dialForTest(%s): %v", addr, err)
}
taiID := taiIDFromAddr(addr)
reg := registry.Global()
reg.Register(&registry.TaiNode{TaiID: taiID, Mode: modeForAddr(addr)})
reg.SetResources(taiID, res)
t.Cleanup(func() { res.Close() })
return taiID, res
}
func dialForTest(addr string, dialOps ...tai.DialOption) (*tai.ConnResources, error) {
if addr == "local" || addr == "" {
return tai.DialLocal("", "", nil)
}
host, grpcPort := parseHostPort(addr)
ports := tai.Ports{GRPC: grpcPort}
return tai.DialRemote(host, ports, dialOps...)
}
func taiIDFromAddr(addr string) string {
if addr == "local" || addr == "" {
return "local"
}
addr = strings.TrimPrefix(addr, "tai://")
parts := strings.SplitN(addr, ":", 2)
return parts[0]
}
func modeForAddr(addr string) string {
if addr == "local" || addr == "" {
return "local"
}
return "direct"
}
func parseHostPort(addr string) (string, int) {
addr = strings.TrimPrefix(addr, "tai://")
parts := strings.SplitN(addr, ":", 2)
h := parts[0]
if len(parts) == 2 {
if p, err := strconv.Atoi(parts[1]); err == nil {
return h, p
}
}
return h, 19100
}
func runJS(t *testing.T, source string) interface{} { func runJS(t *testing.T, source string) interface{} {
t.Helper() t.Helper()
res, err := v8runtime.Call(v8runtime.CallOptions{ res, err := v8runtime.Call(v8runtime.CallOptions{

View file

@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/registry"
taitypes "github.com/yaoapp/yao/tai/types"
"rogchap.com/v8go" "rogchap.com/v8go"
) )
@ -63,17 +64,17 @@ func sbNodesByTeam(info *v8go.FunctionCallbackInfo) *v8go.Value {
return snapshotsToJSArray(v8ctx, snaps) return snapshotsToJSArray(v8ctx, snaps)
} }
// snapshotToJS converts a NodeSnapshot to a JS NodeInfo object. // snapshotToJS converts a NodeMeta to a JS NodeInfo object.
// Auth and YaoBase are excluded for security. // Auth and YaoBase are excluded for security.
func snapshotToJS(v8ctx *v8go.Context, snap *registry.NodeSnapshot) (*v8go.Value, error) { func snapshotToJS(v8ctx *v8go.Context, snap *taitypes.NodeMeta) (*v8go.Value, error) {
ports := make(map[string]interface{}, len(snap.Ports)) ports := map[string]interface{}{
for k, v := range snap.Ports { "grpc": snap.Ports.GRPC, "http": snap.Ports.HTTP,
ports[k] = v "vnc": snap.Ports.VNC, "docker": snap.Ports.Docker, "k8s": snap.Ports.K8s,
} }
caps := make(map[string]interface{}, len(snap.Capabilities)) caps := map[string]interface{}{
for k, v := range snap.Capabilities { "docker": snap.Capabilities.Docker, "k8s": snap.Capabilities.K8s,
caps[k] = v "host_exec": snap.Capabilities.HostExec,
} }
data, err := json.Marshal(map[string]interface{}{ data, err := json.Marshal(map[string]interface{}{
@ -103,17 +104,17 @@ func snapshotToJS(v8ctx *v8go.Context, snap *registry.NodeSnapshot) (*v8go.Value
return v8go.JSONParse(v8ctx, string(data)) return v8go.JSONParse(v8ctx, string(data))
} }
func snapshotsToJSArray(v8ctx *v8go.Context, snaps []registry.NodeSnapshot) *v8go.Value { func snapshotsToJSArray(v8ctx *v8go.Context, snaps []taitypes.NodeMeta) *v8go.Value {
items := make([]interface{}, 0, len(snaps)) items := make([]interface{}, 0, len(snaps))
for i := range snaps { for i := range snaps {
snap := &snaps[i] snap := &snaps[i]
ports := make(map[string]interface{}, len(snap.Ports)) ports := map[string]interface{}{
for k, v := range snap.Ports { "grpc": snap.Ports.GRPC, "http": snap.Ports.HTTP,
ports[k] = v "vnc": snap.Ports.VNC, "docker": snap.Ports.Docker, "k8s": snap.Ports.K8s,
} }
caps := make(map[string]interface{}, len(snap.Capabilities)) caps := map[string]interface{}{
for k, v := range snap.Capabilities { "docker": snap.Capabilities.Docker, "k8s": snap.Capabilities.K8s,
caps[k] = v "host_exec": snap.Capabilities.HostExec,
} }
items = append(items, map[string]interface{}{ items = append(items, map[string]interface{}{
"tai_id": snap.TaiID, "tai_id": snap.TaiID,

View file

@ -10,7 +10,8 @@ import (
"github.com/yaoapp/yao/config" "github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/tai" "github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/registry"
taisandbox "github.com/yaoapp/yao/tai/sandbox" tairuntime "github.com/yaoapp/yao/tai/runtime"
taitypes "github.com/yaoapp/yao/tai/types"
"github.com/yaoapp/yao/workspace" "github.com/yaoapp/yao/workspace"
) )
@ -38,11 +39,11 @@ func (m *Manager) Start(ctx context.Context) error {
m.ensureLocalNode(reg) m.ensureLocalNode(reg)
for _, snap := range reg.List() { for _, snap := range reg.List() {
client, err := m.getNode(snap.TaiID) res, err := m.getNode(snap.TaiID)
if err != nil { if err != nil {
continue continue
} }
m.recoverBoxes(ctx, snap.TaiID, client) m.recoverBoxes(ctx, snap.TaiID, res)
} }
loopCtx, cancel := context.WithCancel(ctx) loopCtx, cancel := context.WithCancel(ctx)
@ -61,7 +62,7 @@ func (m *Manager) ensureLocalNode(_ *registry.Registry) {
} }
// Nodes returns the list of registered Tai nodes from the registry. // Nodes returns the list of registered Tai nodes from the registry.
func (m *Manager) Nodes() []registry.NodeSnapshot { func (m *Manager) Nodes() []taitypes.NodeMeta {
reg := registry.Global() reg := registry.Global()
if reg == nil { if reg == nil {
return nil return nil
@ -89,26 +90,23 @@ func (m *Manager) Host(_ context.Context, nodeID string) (*Host, error) {
return nil, ErrNodeMissing return nil, ErrNodeMissing
} }
client, err := m.getNode(nodeID) res, err := m.getNode(nodeID)
if err != nil { if err != nil {
return nil, fmt.Errorf("sandbox: connect node %q: %w", nodeID, err) return nil, fmt.Errorf("sandbox: connect node %q: %w", nodeID, err)
} }
if client.HostExec() == nil { if res.HostExec == nil {
return nil, fmt.Errorf("sandbox: node %q has no host_exec capability", nodeID) return nil, fmt.Errorf("sandbox: node %q has no host_exec capability", nodeID)
} }
var sys SystemInfo sys := SystemInfo{
if snap, ok := tai.GetNodeSnapshot(nodeID); ok { OS: res.System.OS,
sys = SystemInfo{ Arch: res.System.Arch,
OS: snap.System.OS, Hostname: res.System.Hostname,
Arch: snap.System.Arch, NumCPU: res.System.NumCPU,
Hostname: snap.System.Hostname, TotalMem: res.System.TotalMem,
NumCPU: snap.System.NumCPU, Shell: res.System.Shell,
TotalMem: snap.System.TotalMem, TempDir: res.System.TempDir,
Shell: snap.System.Shell,
TempDir: snap.System.TempDir,
}
} }
return &Host{nodeID: nodeID, system: sys, manager: m}, nil return &Host{nodeID: nodeID, system: sys, manager: m}, nil
@ -165,24 +163,24 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
id = fmt.Sprintf("sb-%d", time.Now().UnixNano()) id = fmt.Sprintf("sb-%d", time.Now().UnixNano())
} }
client, err := m.getNode(nodeID) res, err := m.getNode(nodeID)
if err != nil { if err != nil {
return nil, fmt.Errorf("sandbox: connect node %q: %w", nodeID, err) return nil, fmt.Errorf("sandbox: connect node %q: %w", nodeID, err)
} }
if client.Sandbox() == nil { if res.Runtime == nil {
return nil, fmt.Errorf("sandbox: node %q has no container runtime", nodeID) return nil, fmt.Errorf("sandbox: node %q has no container runtime", nodeID)
} }
taiOpts := m.buildTaiCreateOptions(opts, nodeID, id) taiOpts := m.buildTaiCreateOptions(opts, nodeID, id)
containerID, err := client.Sandbox().Create(ctx, taiOpts) containerID, err := res.Runtime.Create(ctx, taiOpts)
if err != nil { if err != nil {
return nil, fmt.Errorf("sandbox: create container: %w", err) return nil, fmt.Errorf("sandbox: create container: %w", err)
} }
if err := client.Sandbox().Start(ctx, containerID); err != nil { if err := res.Runtime.Start(ctx, containerID); err != nil {
client.Sandbox().Remove(ctx, containerID, true) res.Runtime.Remove(ctx, containerID, true)
return nil, fmt.Errorf("sandbox: start container: %w", err) return nil, fmt.Errorf("sandbox: start container: %w", err)
} }
@ -191,17 +189,14 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
policy = Session policy = Session
} }
var sys SystemInfo sys := SystemInfo{
if snap, ok := tai.GetNodeSnapshot(nodeID); ok { OS: res.System.OS,
sys = SystemInfo{ Arch: res.System.Arch,
OS: snap.System.OS, Hostname: res.System.Hostname,
Arch: snap.System.Arch, NumCPU: res.System.NumCPU,
Hostname: snap.System.Hostname, TotalMem: res.System.TotalMem,
NumCPU: snap.System.NumCPU, Shell: res.System.Shell,
TotalMem: snap.System.TotalMem, TempDir: res.System.TempDir,
Shell: snap.System.Shell,
TempDir: snap.System.TempDir,
}
} }
box := &Box{ box := &Box{
@ -278,9 +273,9 @@ func (m *Manager) Remove(ctx context.Context, id string) error {
} }
b := v.(*Box) b := v.(*Box)
client, err := m.getNode(b.nodeID) res, err := m.getNode(b.nodeID)
if err == nil && client.Sandbox() != nil { if err == nil && res.Runtime != nil {
client.Sandbox().Remove(ctx, b.containerID, true) res.Runtime.Remove(ctx, b.containerID, true)
} }
m.boxes.Delete(id) m.boxes.Delete(id)
@ -303,8 +298,8 @@ func (m *Manager) Cleanup(ctx context.Context) error {
} }
case LongRunning: case LongRunning:
if timeout := b.idleTimeout(); timeout > 0 && idle > timeout { if timeout := b.idleTimeout(); timeout > 0 && idle > timeout {
if client, err := m.getNode(b.nodeID); err == nil && client.Sandbox() != nil { if res, err := m.getNode(b.nodeID); err == nil && res.Runtime != nil {
client.Sandbox().Stop(ctx, b.containerID, b.stopTimeout()) res.Runtime.Stop(ctx, b.containerID, b.stopTimeout())
} }
} }
if lifetime := b.maxLifetime(); lifetime > 0 && now.Sub(b.createdAt) > lifetime { if lifetime := b.maxLifetime(); lifetime > 0 && now.Sub(b.createdAt) > lifetime {
@ -339,15 +334,15 @@ func (m *Manager) cleanupLoop(ctx context.Context) {
} }
} }
func (m *Manager) getNode(name string) (*tai.Client, error) { func (m *Manager) getNode(name string) (*tai.ConnResources, error) {
client, ok := tai.GetClient(name) res, ok := tai.GetResources(name)
if !ok { if !ok {
return nil, ErrNodeNotFound return nil, ErrNodeNotFound
} }
return client, nil return res, nil
} }
func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID string) taisandbox.CreateOptions { func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID string) tairuntime.CreateOptions {
env := make(map[string]string) env := make(map[string]string)
reg := registry.Global() reg := registry.Global()
@ -385,9 +380,9 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID st
cmd := []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 86400 & wait $!; done"} cmd := []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 86400 & wait $!; done"}
var ports []taisandbox.PortMapping var ports []tairuntime.PortMapping
for _, p := range opts.Ports { for _, p := range opts.Ports {
ports = append(ports, taisandbox.PortMapping{ ports = append(ports, tairuntime.PortMapping{
ContainerPort: p.ContainerPort, ContainerPort: p.ContainerPort,
HostPort: p.HostPort, HostPort: p.HostPort,
HostIP: p.HostIP, HostIP: p.HostIP,
@ -413,7 +408,7 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID st
} }
} }
return taisandbox.CreateOptions{ return tairuntime.CreateOptions{
Name: sandboxID, Name: sandboxID,
Image: opts.Image, Image: opts.Image,
Cmd: cmd, Cmd: cmd,
@ -429,11 +424,11 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID st
} }
} }
func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, client *tai.Client) { func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.ConnResources) {
if client.Sandbox() == nil { if res.Runtime == nil {
return return
} }
containers, err := client.Sandbox().List(ctx, taisandbox.ListOptions{ containers, err := res.Runtime.List(ctx, tairuntime.ListOptions{
All: true, All: true,
Labels: map[string]string{"managed-by": "yao-sandbox"}, Labels: map[string]string{"managed-by": "yao-sandbox"},
}) })
@ -473,37 +468,35 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, client *tai.C
// ImageExists reports whether the given image ref exists on the target node. // ImageExists reports whether the given image ref exists on the target node.
func (m *Manager) ImageExists(ctx context.Context, nodeID, ref string) (bool, error) { func (m *Manager) ImageExists(ctx context.Context, nodeID, ref string) (bool, error) {
client, err := m.getNode(nodeID) res, err := m.getNode(nodeID)
if err != nil { if err != nil {
return false, err return false, err
} }
img := client.Image() if res.Image == nil {
if img == nil {
return true, nil return true, nil
} }
return img.Exists(ctx, ref) return res.Image.Exists(ctx, ref)
} }
// PullImage pulls an image to the target node, returning a channel of // PullImage pulls an image to the target node, returning a channel of
// real-time progress events. // real-time progress events.
func (m *Manager) PullImage(ctx context.Context, nodeID, ref string, opts ImagePullOptions) (<-chan taisandbox.PullProgress, error) { func (m *Manager) PullImage(ctx context.Context, nodeID, ref string, opts ImagePullOptions) (<-chan tairuntime.PullProgress, error) {
client, err := m.getNode(nodeID) res, err := m.getNode(nodeID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
img := client.Image() if res.Image == nil {
if img == nil {
return nil, nil return nil, nil
} }
pullOpts := taisandbox.PullOptions{} pullOpts := tairuntime.PullOptions{}
if opts.Auth != nil { if opts.Auth != nil {
pullOpts.Auth = &taisandbox.RegistryAuth{ pullOpts.Auth = &tairuntime.RegistryAuth{
Username: opts.Auth.Username, Username: opts.Auth.Username,
Password: opts.Auth.Password, Password: opts.Auth.Password,
Server: opts.Auth.Server, Server: opts.Auth.Server,
} }
} }
return img.Pull(ctx, ref, pullOpts) return res.Image.Pull(ctx, ref, pullOpts)
} }
// EnsureImage checks whether the image exists on the node; if not, it // EnsureImage checks whether the image exists on the node; if not, it

View file

@ -0,0 +1,37 @@
//go:build containerized
package sandbox_test
import (
"fmt"
"os"
)
func init() {
extraNodeProviders = append(extraNodeProviders, containerizedNodes)
extraPurgeProviders = append(extraPurgeProviders, containerizedPurge)
}
func containerizedNodes() []nodeConfig {
host := os.Getenv("TAI_TEST_CONTAINERIZED_HOST")
if host == "" {
return nil
}
grpcPort := envPort("TAI_TEST_CONTAINERIZED_GRPC_PORT", 9200)
return []nodeConfig{{
Name: "containerized",
Addr: fmt.Sprintf("tai://%s:%d", host, grpcPort),
}}
}
func containerizedPurge() []purgeTarget {
host := os.Getenv("TAI_TEST_CONTAINERIZED_HOST")
if host == "" {
return nil
}
grpcPort := envPort("TAI_TEST_CONTAINERIZED_GRPC_PORT", 9200)
return []purgeTarget{{
name: "containerized",
addr: fmt.Sprintf("tai://%s:%d", host, grpcPort),
}}
}

View file

@ -0,0 +1,68 @@
//go:build k8s
package sandbox_test
import (
"fmt"
"os"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/types"
)
func init() {
extraNodeProviders = append(extraNodeProviders, k8sNodes)
extraHostExecProviders = append(extraHostExecProviders, k8sHostExec)
extraPurgeProviders = append(extraPurgeProviders, k8sPurge)
}
func k8sNodes() []nodeConfig {
host := os.Getenv("TAI_TEST_K8S_HOST")
kubeconfig := os.Getenv("TAI_TEST_KUBECONFIG")
if host == "" || kubeconfig == "" {
return nil
}
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 19100))
dialOps := []tai.DialOption{
tai.WithDialRuntime(types.K8s),
tai.WithDialKubeConfig(kubeconfig),
}
if ns := os.Getenv("TAI_TEST_K8S_NAMESPACE"); ns != "" {
dialOps = append(dialOps, tai.WithDialNamespace(ns))
}
return []nodeConfig{{
Name: "k8s",
Addr: fmt.Sprintf("tai://%s:%d", host, grpcPort),
DialOps: dialOps,
}}
}
func k8sHostExec() []hostExecTarget {
host := os.Getenv("TAI_TEST_K8S_HOST")
if host == "" {
return nil
}
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 19100))
return []hostExecTarget{{Name: "k8s", Addr: fmt.Sprintf("%s:%d", host, grpcPort)}}
}
func k8sPurge() []purgeTarget {
host := os.Getenv("TAI_TEST_K8S_HOST")
kubeconfig := os.Getenv("TAI_TEST_KUBECONFIG")
if host == "" || kubeconfig == "" {
return nil
}
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 19100))
dialOps := []tai.DialOption{
tai.WithDialRuntime(types.K8s),
tai.WithDialKubeConfig(kubeconfig),
}
if ns := os.Getenv("TAI_TEST_K8S_NAMESPACE"); ns != "" {
dialOps = append(dialOps, tai.WithDialNamespace(ns))
}
return []purgeTarget{{
name: "k8s",
addr: fmt.Sprintf("tai://%s:%d", host, grpcPort),
dialOps: dialOps,
}}
}

View file

@ -0,0 +1,39 @@
//go:build remote
package sandbox_test
import (
"os"
"strings"
)
func init() {
extraNodeProviders = append(extraNodeProviders, remoteNodes)
extraHostExecProviders = append(extraHostExecProviders, remoteHostExec)
extraPurgeProviders = append(extraPurgeProviders, remotePurge)
}
func remoteNodes() []nodeConfig {
addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR")
if addr == "" {
return nil
}
return []nodeConfig{{Name: "remote", Addr: addr}}
}
func remoteHostExec() []hostExecTarget {
addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR")
if addr == "" {
return nil
}
addr = strings.TrimPrefix(addr, "tai://")
return []hostExecTarget{{Name: "remote", Addr: addr}}
}
func remotePurge() []purgeTarget {
addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR")
if addr == "" {
return nil
}
return []purgeTarget{{name: "remote", addr: addr}}
}

View file

@ -14,7 +14,7 @@ import (
sandbox "github.com/yaoapp/yao/sandbox/v2" sandbox "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tai" "github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/registry"
taisandbox "github.com/yaoapp/yao/tai/sandbox" tairuntime "github.com/yaoapp/yao/tai/runtime"
"github.com/yaoapp/yao/workspace" "github.com/yaoapp/yao/workspace"
) )
@ -25,62 +25,70 @@ var k8sSem = make(chan struct{}, 2)
// when many tests finish at once. // when many tests finish at once.
var k8sCleanupMu sync.Mutex var k8sCleanupMu sync.Mutex
// ---------------------------------------------------------------------------
// Build-tag extension points.
// Each tag file (testutils_remote_test.go, testutils_k8s_test.go, …) appends
// provider functions in its init(). This lets tags compose freely:
//
// go test ./sandbox/v2/... → local only
// go test -tags remote ./sandbox/v2/... → local + remote
// go test -tags "remote,k8s" ./sandbox/v2/... → local + remote + k8s
// go test -tags "remote,containerized,k8s,wintest" → all
//
// ---------------------------------------------------------------------------
var (
extraNodeProviders []func() []nodeConfig
extraHostExecProviders []func() []hostExecTarget
extraPurgeProviders []func() []purgeTarget
)
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
purgeStaleContainers() purgeStaleContainers()
os.Exit(m.Run()) os.Exit(m.Run())
} }
// purgeStaleContainers removes leftover sb-* containers/pods from previous // ---------------------------------------------------------------------------
// test runs across all configured nodes (Docker + K8s). // Purge stale containers from previous runs
// ---------------------------------------------------------------------------
type purgeTarget struct {
name string
addr string
dialOps []tai.DialOption
}
func purgeStaleContainers() { func purgeStaleContainers() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
type target struct { type target struct {
name string name string
addr string addr string
opts []tai.Option dialOps []tai.DialOption
} }
var targets []target var targets []target
targets = append(targets, target{name: "local", addr: testLocalAddr()}) targets = append(targets, target{name: "local", addr: testLocalAddr()})
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" { for _, fn := range extraPurgeProviders {
targets = append(targets, target{name: "remote", addr: addr}) for _, extra := range fn() {
} targets = append(targets, target{name: extra.name, addr: extra.addr, dialOps: extra.dialOps})
if host := os.Getenv("TAI_TEST_CONTAINERIZED_HOST"); host != "" {
grpcPort := envPort("TAI_TEST_CONTAINERIZED_GRPC_PORT", 9200)
targets = append(targets, target{name: "containerized", addr: fmt.Sprintf("tai://%s:%d", host, grpcPort)})
}
if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" {
kubeconfig := os.Getenv("TAI_TEST_KUBECONFIG")
if kubeconfig != "" {
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 19100))
opts := []tai.Option{
tai.K8s,
tai.WithKubeConfig(kubeconfig),
tai.WithPorts(tai.Ports{K8s: envPort("TAI_TEST_K8S_PORT", 6443), GRPC: grpcPort}),
}
if ns := os.Getenv("TAI_TEST_K8S_NAMESPACE"); ns != "" {
opts = append(opts, tai.WithNamespace(ns))
}
targets = append(targets, target{name: "k8s", addr: fmt.Sprintf("tai://%s:%d", host, grpcPort), opts: opts})
} }
} }
for _, tgt := range targets { for _, tgt := range targets {
client, err := tai.New(tgt.addr, tgt.opts...) res, err := dialForTest(tgt.addr, tgt.dialOps...)
if err != nil { if err != nil {
continue continue
} }
sb := client.Sandbox() sb := res.Runtime
if sb == nil { if sb == nil {
client.Close() res.Close()
continue continue
} }
containers, err := sb.List(ctx, taisandbox.ListOptions{All: true}) containers, err := sb.List(ctx, tairuntime.ListOptions{All: true})
if err != nil { if err != nil {
client.Close() res.Close()
continue continue
} }
for _, c := range containers { for _, c := range containers {
@ -94,57 +102,57 @@ func purgeStaleContainers() {
sb.Remove(ctx, id, true) sb.Remove(ctx, id, true)
log.Printf("[purge] %s: removed stale container %s", tgt.name, id) log.Printf("[purge] %s: removed stale container %s", tgt.name, id)
} }
client.Close() res.Close()
} }
} }
// ---------------------------------------------------------------------------
// Node / HostExec configuration
// ---------------------------------------------------------------------------
type nodeConfig struct { type nodeConfig struct {
Name string // human-readable label for t.Run (e.g. "remote", "k8s") Name string
Addr string Addr string
TaiID string // actual registry key, filled after tai.New TaiID string
Options []tai.Option DialOps []tai.DialOption
} }
// testNodes returns all available node configurations for multi-mode testing. type hostExecTarget struct {
Name string
Addr string
TaiID string
IsWinNative bool
}
// testNodes returns node configs. "local" is always present; other
// environments are injected by build-tag files via extraNodeProviders.
func testNodes() []nodeConfig { func testNodes() []nodeConfig {
nodes := []nodeConfig{ nodes := []nodeConfig{
{Name: "local", Addr: testLocalAddr()}, {Name: "local", Addr: testLocalAddr()},
} }
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" { for _, fn := range extraNodeProviders {
nodes = append(nodes, nodeConfig{Name: "remote", Addr: addr}) nodes = append(nodes, fn()...)
}
if host := os.Getenv("TAI_TEST_CONTAINERIZED_HOST"); host != "" {
grpcPort := envPort("TAI_TEST_CONTAINERIZED_GRPC_PORT", 9200)
addr := fmt.Sprintf("tai://%s:%d", host, grpcPort)
nodes = append(nodes, nodeConfig{Name: "containerized", Addr: addr})
}
if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" {
kubeconfig := os.Getenv("TAI_TEST_KUBECONFIG")
if kubeconfig == "" {
return nodes
}
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 19100))
addr := fmt.Sprintf("tai://%s:%d", host, grpcPort)
opts := []tai.Option{
tai.K8s,
tai.WithKubeConfig(kubeconfig),
tai.WithPorts(tai.Ports{
K8s: envPort("TAI_TEST_K8S_PORT", 6443),
GRPC: grpcPort,
}),
}
if ns := os.Getenv("TAI_TEST_K8S_NAMESPACE"); ns != "" {
opts = append(opts, tai.WithNamespace(ns))
}
nodes = append(nodes, nodeConfig{Name: "k8s", Addr: addr, Options: opts})
} }
return nodes return nodes
} }
// hostExecTargets returns HostExec targets. Populated entirely by
// build-tag files via extraHostExecProviders.
func hostExecTargets() []hostExecTarget {
var targets []hostExecTarget
for _, fn := range extraHostExecProviders {
targets = append(targets, fn()...)
}
return targets
}
// ---------------------------------------------------------------------------
// Skip helpers
// ---------------------------------------------------------------------------
func skipIfNoDocker(t *testing.T) { func skipIfNoDocker(t *testing.T) {
t.Helper() t.Helper()
addr := testLocalAddr() if testLocalAddr() == "" {
if addr == "" {
t.Skip("SANDBOX_TEST_LOCAL_ADDR not set, skipping Docker tests") t.Skip("SANDBOX_TEST_LOCAL_ADDR not set, skipping Docker tests")
} }
} }
@ -156,32 +164,6 @@ func skipIfNoTai(t *testing.T) {
} }
} }
type hostExecTarget struct {
Name string
Addr string // host:port (without tai:// prefix)
TaiID string // filled after registration
IsWinNative bool
}
func hostExecTargets() []hostExecTarget {
var targets []hostExecTarget
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
addr = strings.TrimPrefix(addr, "tai://")
targets = append(targets, hostExecTarget{Name: "remote", Addr: addr})
}
if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" {
grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 19100))
targets = append(targets, hostExecTarget{Name: "k8s", Addr: fmt.Sprintf("%s:%d", host, grpcPort)})
}
if addr := os.Getenv("TAI_TEST_WIN_HOSTEXEC_LINUX"); addr != "" {
targets = append(targets, hostExecTarget{Name: "win-linux", Addr: addr})
}
if addr := os.Getenv("TAI_TEST_WIN_HOSTEXEC_NATIVE"); addr != "" {
targets = append(targets, hostExecTarget{Name: "win-native", Addr: addr, IsWinNative: true})
}
return targets
}
func skipIfNoHostExec(t *testing.T) { func skipIfNoHostExec(t *testing.T) {
t.Helper() t.Helper()
if len(hostExecTargets()) == 0 { if len(hostExecTargets()) == 0 {
@ -189,6 +171,10 @@ func skipIfNoHostExec(t *testing.T) {
} }
} }
// ---------------------------------------------------------------------------
// Command helpers (Windows HostExec command translation)
// ---------------------------------------------------------------------------
func linuxCmd(tgt hostExecTarget, cmd string, args ...string) (string, []string) { func linuxCmd(tgt hostExecTarget, cmd string, args ...string) (string, []string) {
if tgt.IsWinNative { if tgt.IsWinNative {
switch cmd { switch cmd {
@ -214,6 +200,10 @@ func linuxCmd(tgt hostExecTarget, cmd string, args ...string) (string, []string)
return cmd, args return cmd, args
} }
// ---------------------------------------------------------------------------
// Environment helpers
// ---------------------------------------------------------------------------
func testLocalAddr() string { func testLocalAddr() string {
if addr := os.Getenv("SANDBOX_TEST_LOCAL_ADDR"); addr != "" { if addr := os.Getenv("SANDBOX_TEST_LOCAL_ADDR"); addr != "" {
return addr return addr
@ -237,41 +227,88 @@ func envPort(key string, fallback int) int {
return fallback return fallback
} }
// registerNode creates a tai.Client and registers it in the global registry. // ---------------------------------------------------------------------------
// It fills pc.TaiID with the actual registry key returned by tai.New. // Dial + Register helper (replaces old tai.New)
func registerNode(t *testing.T, pc *nodeConfig) { // ---------------------------------------------------------------------------
t.Helper()
reg := registry.Global() // dialForTest calls DialLocal or DialRemote based on the address.
if reg == nil { func dialForTest(addr string, dialOps ...tai.DialOption) (*tai.ConnResources, error) {
if addr == "local" || addr == "" {
return tai.DialLocal("", "", nil)
}
host, grpcPort := parseHostPort(addr)
ports := tai.Ports{GRPC: grpcPort}
return tai.DialRemote(host, ports, dialOps...)
}
// registerForTest dials and registers a node in the registry. Returns the
// taiID. On failure it calls t.Fatalf.
func registerForTest(t testing.TB, addr string, dialOps ...tai.DialOption) (string, *tai.ConnResources) {
t.Helper()
if registry.Global() == nil {
registry.Init(nil) registry.Init(nil)
} }
res, err := dialForTest(addr, dialOps...)
client, err := tai.New(pc.Addr, pc.Options...)
if err != nil { if err != nil {
t.Fatalf("tai.New(%s): %v", pc.Addr, err) t.Fatalf("dialForTest(%s): %v", addr, err)
} }
pc.TaiID = client.TaiID() taiID := taiIDFromAddr(addr)
t.Cleanup(func() { client.Close() }) reg := registry.Global()
reg.Register(&registry.TaiNode{TaiID: taiID, Mode: modeForAddr(addr)})
reg.SetResources(taiID, res)
return taiID, res
}
func taiIDFromAddr(addr string) string {
if addr == "local" || addr == "" {
return "local"
}
addr = strings.TrimPrefix(addr, "tai://")
host, _ := parseHostPort(addr)
return host
}
func modeForAddr(addr string) string {
if addr == "local" || addr == "" {
return "local"
}
return "direct"
}
func parseHostPort(addr string) (string, int) {
addr = strings.TrimPrefix(addr, "tai://")
parts := strings.SplitN(addr, ":", 2)
h := parts[0]
if len(parts) == 2 {
if p, err := strconv.Atoi(parts[1]); err == nil {
return h, p
}
}
return h, 19100
}
// ---------------------------------------------------------------------------
// Manager / Box setup helpers
// ---------------------------------------------------------------------------
func registerNode(t *testing.T, pc *nodeConfig) {
t.Helper()
taiID, res := registerForTest(t, pc.Addr, pc.DialOps...)
pc.TaiID = taiID
t.Cleanup(func() { res.Close() })
} }
func setupManager(t *testing.T, nodes ...nodeConfig) (*sandbox.Manager, []nodeConfig) { func setupManager(t *testing.T, nodes ...nodeConfig) (*sandbox.Manager, []nodeConfig) {
t.Helper() t.Helper()
if registry.Global() == nil {
reg := registry.Global()
if reg == nil {
registry.Init(nil) registry.Init(nil)
} }
_ = reg
out := make([]nodeConfig, len(nodes)) out := make([]nodeConfig, len(nodes))
copy(out, nodes) copy(out, nodes)
for i := range out { for i := range out {
client, err := tai.New(out[i].Addr, out[i].Options...) taiID, _ := registerForTest(t, out[i].Addr, out[i].DialOps...)
if err != nil { out[i].TaiID = taiID
t.Fatalf("tai.New(%s): %v", out[i].Addr, err)
}
out[i].TaiID = client.TaiID()
} }
sandbox.Init() sandbox.Init()
@ -287,8 +324,6 @@ func setupManagerForNode(t *testing.T, pc *nodeConfig) *sandbox.Manager {
return m return m
} }
// setupManagerWithWorkspace creates a sandbox Manager and returns
// the global workspace.Manager (which uses the registry for client lookups).
func setupManagerWithWorkspace(t *testing.T, pc *nodeConfig) (*sandbox.Manager, *workspace.Manager) { func setupManagerWithWorkspace(t *testing.T, pc *nodeConfig) (*sandbox.Manager, *workspace.Manager) {
t.Helper() t.Helper()
sbm := setupManagerForNode(t, pc) sbm := setupManagerForNode(t, pc)
@ -364,3 +399,6 @@ func createTestBox(t *testing.T, m *sandbox.Manager, pc nodeConfig, opts ...func
}) })
return box return box
} }
// Ensure imports are used.
var _ = fmt.Sprintf

View file

@ -0,0 +1,20 @@
//go:build wintest
package sandbox_test
import "os"
func init() {
extraHostExecProviders = append(extraHostExecProviders, winHostExec)
}
func winHostExec() []hostExecTarget {
var targets []hostExecTarget
if addr := os.Getenv("TAI_TEST_WIN_HOSTEXEC_LINUX"); addr != "" {
targets = append(targets, hostExecTarget{Name: "win-linux", Addr: addr})
}
if addr := os.Getenv("TAI_TEST_WIN_HOSTEXEC_NATIVE"); addr != "" {
targets = append(targets, hostExecTarget{Name: "win-native", Addr: addr, IsWinNative: true})
}
return targets
}

View file

@ -11,22 +11,23 @@ import (
tai "github.com/yaoapp/yao/tai" tai "github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/taiid" "github.com/yaoapp/yao/tai/taiid"
"github.com/yaoapp/yao/tai/types"
) )
// authenticateBearer validates a Bearer token and returns the caller's identity. // authenticateBearer validates a Bearer token and returns the caller's identity.
// Package-level var so tests can inject a mock without an OAuth service. // Package-level var so tests can inject a mock without an OAuth service.
var authenticateBearer = authenticateBearerDefault var authenticateBearer = authenticateBearerDefault
func authenticateBearerDefault(token string) (registry.AuthInfo, error) { func authenticateBearerDefault(token string) (types.AuthInfo, error) {
svc := oauth.OAuth svc := oauth.OAuth
if svc == nil { if svc == nil {
return registry.AuthInfo{}, fmt.Errorf("oauth service not initialized") return types.AuthInfo{}, fmt.Errorf("oauth service not initialized")
} }
result, err := svc.AuthenticateToken(oauth.AuthInput{AccessToken: token}) result, err := svc.AuthenticateToken(oauth.AuthInput{AccessToken: token})
if err != nil { if err != nil {
return registry.AuthInfo{}, err return types.AuthInfo{}, err
} }
info := registry.AuthInfo{} info := types.AuthInfo{}
if result.Info != nil { if result.Info != nil {
info.Subject = result.Info.Subject info.Subject = result.Info.Subject
info.UserID = result.Info.UserID info.UserID = result.Info.UserID
@ -86,15 +87,15 @@ func extractBearer(r *http.Request) string {
// registerRequest is the JSON body for POST /tai-nodes/register. // registerRequest is the JSON body for POST /tai-nodes/register.
type registerRequest struct { type registerRequest struct {
NodeID string `json:"node_id,omitempty"` NodeID string `json:"node_id,omitempty"`
ClientID string `json:"client_id,omitempty"` ClientID string `json:"client_id,omitempty"`
MachineID string `json:"machine_id"` MachineID string `json:"machine_id"`
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"` Version string `json:"version"`
Addr string `json:"addr"` Addr string `json:"addr"`
Ports map[string]int `json:"ports"` Ports map[string]int `json:"ports"`
Capabilities map[string]bool `json:"capabilities"` Capabilities map[string]bool `json:"capabilities"`
System registry.SystemInfo `json:"system"` System types.SystemInfo `json:"system"`
} }
// heartbeatRequest is the JSON body for POST /tai-nodes/heartbeat. // heartbeatRequest is the JSON body for POST /tai-nodes/heartbeat.
@ -162,8 +163,8 @@ func HandleRegister(c *gin.Context) {
System: req.System, System: req.System,
Mode: "direct", Mode: "direct",
Addr: addr, Addr: addr,
Ports: req.Ports, Ports: portsFromMap(req.Ports),
Capabilities: req.Capabilities, Capabilities: capsFromMap(req.Capabilities),
} }
reg.Register(node) reg.Register(node)
slog.Info("[register] node registered via API", slog.Info("[register] node registered via API",
@ -180,7 +181,7 @@ func HandleRegister(c *gin.Context) {
if strings.HasPrefix(addr, "tai://") { if strings.HasPrefix(addr, "tai://") {
slog.Info("[register] launching connectRegisteredNode goroutine", slog.Info("[register] launching connectRegisteredNode goroutine",
"tai_id", resolvedTaiID, "addr", addr) "tai_id", resolvedTaiID, "addr", addr)
go connectRegisteredNode(resolvedTaiID, addr, reg) go connectRegisteredNode(resolvedTaiID, addr, portsFromMap(req.Ports), reg)
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@ -278,45 +279,50 @@ func HandleUnregister(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "unregistered"}) c.JSON(http.StatusOK, gin.H{"status": "unregistered"})
} }
// connectRegisteredNode dials the self-registered Tai node via gRPC, func portsFromMap(m map[string]int) types.Ports {
// creates a tai.Client, and binds it to the node's TaiID in the registry. return types.Ports{
// initRemote internally registers a redundant "host-port" entry; we remove GRPC: m["grpc"],
// it so that the registry contains only the canonical taiID. HTTP: m["http"],
func connectRegisteredNode(taiID, addr string, reg *registry.Registry) { VNC: m["vnc"],
Docker: m["docker"],
K8s: m["k8s"],
}
}
func capsFromMap(m map[string]bool) types.Capabilities {
return types.Capabilities{
Docker: m["docker"],
K8s: m["k8s"],
HostExec: m["host_exec"],
}
}
// connectRegisteredNode dials the Tai node via DialRemote and binds the
// returned ConnResources to the taiID in the registry. No double-registration.
func connectRegisteredNode(taiID, addr string, ports types.Ports, reg *registry.Registry) {
slog.Info("[connect] start", "tai_id", taiID, "addr", addr) slog.Info("[connect] start", "tai_id", taiID, "addr", addr)
client, err := tai.New(addr) host := extractHost(addr)
if err != nil { if host == "" {
slog.Warn("[connect] tai.New FAILED", slog.Warn("[connect] failed to extract host from addr", "addr", addr)
"tai_id", taiID, "addr", addr, "err", err)
allAfterFail := reg.List()
slog.Info("[connect] registry after tai.New failure", "total", len(allAfterFail))
for _, s := range allAfterFail {
slog.Info("[connect] node", "tai_id", s.TaiID, "mode", s.Mode, "addr", s.Addr)
}
return return
} }
autoID := client.TaiID() res, err := tai.DialRemote(host, ports)
slog.Info("[connect] tai.New OK", "tai_id", taiID, "autoID", autoID) if err != nil {
slog.Warn("[connect] DialRemote failed",
allAfterNew := reg.List() "tai_id", taiID, "addr", addr, "err", err)
slog.Info("[connect] registry after tai.New", "total", len(allAfterNew)) return
for _, s := range allAfterNew {
slog.Info("[connect] node", "tai_id", s.TaiID, "mode", s.Mode, "addr", s.Addr)
} }
if autoID != "" && autoID != taiID { reg.SetResources(taiID, res)
slog.Info("[connect] removing redundant autoID", "autoID", autoID)
reg.Unregister(autoID)
}
reg.SetClient(taiID, client)
allFinal := reg.List()
slog.Info("[connect] registry FINAL", "total", len(allFinal))
for _, s := range allFinal {
slog.Info("[connect] node", "tai_id", s.TaiID, "mode", s.Mode, "addr", s.Addr)
}
slog.Info("[connect] done", "tai_id", taiID) slog.Info("[connect] done", "tai_id", taiID)
} }
func extractHost(addr string) string {
addr = strings.TrimPrefix(addr, "tai://")
if idx := strings.LastIndex(addr, ":"); idx > 0 {
return addr[:idx]
}
return addr
}

View file

@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/types"
) )
func init() { func init() {
@ -20,8 +21,8 @@ func setupTest() func() {
registry.SetGlobalForTest(r) registry.SetGlobalForTest(r)
origAuth := authenticateBearer origAuth := authenticateBearer
authenticateBearer = func(token string) (registry.AuthInfo, error) { authenticateBearer = func(token string) (types.AuthInfo, error) {
return registry.AuthInfo{ return types.AuthInfo{
Subject: "sub-001", Subject: "sub-001",
UserID: "user-alice", UserID: "user-alice",
ClientID: "tai-abc123", ClientID: "tai-abc123",
@ -53,7 +54,7 @@ func TestHandleRegister_Success(t *testing.T) {
Addr: "192.168.1.100", Addr: "192.168.1.100",
Ports: map[string]int{"grpc": 19100, "http": 8099}, Ports: map[string]int{"grpc": 19100, "http": 8099},
Capabilities: map[string]bool{"docker": true, "host_exec": false}, Capabilities: map[string]bool{"docker": true, "host_exec": false},
System: registry.SystemInfo{ System: types.SystemInfo{
OS: "linux", Arch: "amd64", Hostname: "docker-host-01", NumCPU: 16, OS: "linux", Arch: "amd64", Hostname: "docker-host-01", NumCPU: 16,
}, },
} }
@ -114,7 +115,7 @@ func TestHandleRegister_ServerGeneratedTaiID(t *testing.T) {
Addr: "192.168.1.200", Addr: "192.168.1.200",
Ports: map[string]int{"grpc": 19100}, Ports: map[string]int{"grpc": 19100},
Capabilities: map[string]bool{"docker": true}, Capabilities: map[string]bool{"docker": true},
System: registry.SystemInfo{OS: "darwin", Arch: "arm64", Hostname: "mac-01", NumCPU: 12}, System: types.SystemInfo{OS: "darwin", Arch: "arm64", Hostname: "mac-01", NumCPU: 12},
} }
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -207,7 +208,7 @@ func TestHandleHeartbeat_Success(t *testing.T) {
reg.Register(&registry.TaiNode{ reg.Register(&registry.TaiNode{
TaiID: "tai-abc123", TaiID: "tai-abc123",
Mode: "direct", Mode: "direct",
Auth: registry.AuthInfo{ClientID: "tai-abc123"}, Auth: types.AuthInfo{ClientID: "tai-abc123"},
}) })
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -232,7 +233,7 @@ func TestHandleHeartbeat_WrongOwner(t *testing.T) {
reg.Register(&registry.TaiNode{ reg.Register(&registry.TaiNode{
TaiID: "tai-other", TaiID: "tai-other",
Mode: "direct", Mode: "direct",
Auth: registry.AuthInfo{ClientID: "different-client"}, Auth: types.AuthInfo{ClientID: "different-client"},
}) })
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -275,7 +276,7 @@ func TestHandleUnregister_Success(t *testing.T) {
reg.Register(&registry.TaiNode{ reg.Register(&registry.TaiNode{
TaiID: "tai-abc123", TaiID: "tai-abc123",
Mode: "direct", Mode: "direct",
Auth: registry.AuthInfo{ClientID: "tai-abc123"}, Auth: types.AuthInfo{ClientID: "tai-abc123"},
}) })
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -303,7 +304,7 @@ func TestHandleUnregister_WrongOwner(t *testing.T) {
reg.Register(&registry.TaiNode{ reg.Register(&registry.TaiNode{
TaiID: "tai-other", TaiID: "tai-other",
Mode: "direct", Mode: "direct",
Auth: registry.AuthInfo{ClientID: "different-client"}, Auth: types.AuthInfo{ClientID: "different-client"},
}) })
w := httptest.NewRecorder() w := httptest.NewRecorder()

62
tai/conn.go Normal file
View file

@ -0,0 +1,62 @@
package tai
import (
"errors"
"net"
hepb "github.com/yaoapp/yao/tai/hostexec/pb"
"github.com/yaoapp/yao/tai/proxy"
"github.com/yaoapp/yao/tai/runtime"
"github.com/yaoapp/yao/tai/types"
"github.com/yaoapp/yao/tai/vnc"
"github.com/yaoapp/yao/tai/volume"
"google.golang.org/grpc"
)
// ConnResources holds bare connection resources for a Tai node.
// Returned by Dial* functions. Caller (usually registry) is responsible
// for calling Close() when the node disconnects or resources are replaced.
type ConnResources struct {
GRPCConn *grpc.ClientConn
Runtime runtime.Runtime
Image runtime.Image
HostExec hepb.HostExecClient
Volume volume.Volume
Proxy proxy.Proxy
VNC vnc.VNC
Caps types.Capabilities
System types.SystemInfo
Ports types.Ports
Version string
DataDir string // host-side data dir (local mode only)
// Tunnel mode: local listeners that bridge to Tai via WS.
Listeners []net.Listener
}
// Close releases all held resources. Safe to call with nil fields.
func (r *ConnResources) Close() error {
if r == nil {
return nil
}
var errs []error
if r.Runtime != nil {
if err := r.Runtime.Close(); err != nil {
errs = append(errs, err)
}
}
if r.Volume != nil {
if err := r.Volume.Close(); err != nil {
errs = append(errs, err)
}
}
for _, ln := range r.Listeners {
ln.Close()
}
if r.GRPCConn != nil {
if err := r.GRPCConn.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}

387
tai/dial.go Normal file
View file

@ -0,0 +1,387 @@
package tai
import (
"context"
"fmt"
"net"
"net/http"
"time"
hepb "github.com/yaoapp/yao/tai/hostexec/pb"
"github.com/yaoapp/yao/tai/proxy"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/runtime"
sipb "github.com/yaoapp/yao/tai/serverinfo/pb"
"github.com/yaoapp/yao/tai/types"
"github.com/yaoapp/yao/tai/vnc"
"github.com/yaoapp/yao/tai/volume"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/keepalive"
)
// DialRemote establishes connections to a remote Tai node via gRPC (direct mode).
// Does NOT interact with the registry. Caller must call ConnResources.Close().
func DialRemote(host string, ports types.Ports, opts ...DialOption) (*ConnResources, error) {
cfg := &dialConfig{ports: mergedPorts(ports)}
for _, o := range opts {
o.applyDial(cfg)
}
grpcAddr := fmt.Sprintf("%s:%d", host, cfg.ports.GRPC)
conn, err := dialGRPC(grpcAddr)
if err != nil {
return nil, fmt.Errorf("grpc dial %s: %w", grpcAddr, err)
}
return buildResources(conn, cfg, &remoteEnv{host: host, httpClient: cfg.httpClient})
}
// DialTunnel establishes connections to a Tai node through the WebSocket tunnel.
// Requires the node to already be registered in the registry (online).
// Does NOT call registry.SetResources. Caller must call ConnResources.Close().
func DialTunnel(taiID string, reg *registry.Registry, opts ...DialOption) (*ConnResources, error) {
node, ok := reg.Get(taiID)
if !ok || node.Status != "online" {
return nil, fmt.Errorf("tai node %s not online", taiID)
}
cfg := &dialConfig{
ports: types.Ports{
GRPC: intOr(node.Ports.GRPC, 19100),
HTTP: intOr(node.Ports.HTTP, 8099),
VNC: intOr(node.Ports.VNC, 16080),
Docker: intOr(node.Ports.Docker, 12375),
K8s: intOr(node.Ports.K8s, 16443),
},
}
for _, o := range opts {
o.applyDial(cfg)
}
grpcLn, err := reg.OpenLocalListener(taiID, cfg.ports.GRPC)
if err != nil {
return nil, fmt.Errorf("open grpc tunnel listener: %w", err)
}
conn, err := dialGRPC("passthrough:///" + grpcLn.Addr().String())
if err != nil {
grpcLn.Close()
return nil, fmt.Errorf("grpc dial tunnel %s: %w", grpcLn.Addr(), err)
}
env := &tunnelEnv{
taiID: taiID,
yaoBase: node.YaoBase,
reg: reg,
regCaps: node.Capabilities,
listeners: []net.Listener{grpcLn},
}
res, err := buildResources(conn, cfg, env)
if err != nil {
grpcLn.Close()
conn.Close()
return nil, err
}
res.Listeners = env.listeners
return res, nil
}
// DialLocal establishes connections to the local Docker daemon.
// Does NOT interact with the registry. Caller must call ConnResources.Close().
func DialLocal(addr string, dataDir string, vol volume.Volume) (*ConnResources, error) {
sb, err := runtime.NewLocal(addr)
if err != nil && vol == nil {
return nil, err
}
res := &ConnResources{DataDir: dataDir}
if sb != nil {
res.Runtime = sb
res.Image = runtime.NewDockerImage(runtime.DockerCli(sb))
res.Proxy = proxy.NewLocal(sb)
res.VNC = vnc.NewLocal(sb)
}
if vol != nil {
res.Volume = vol
} else {
if dataDir == "" {
dataDir = "/tmp/tai-volumes"
}
res.DataDir = dataDir
res.Volume = volume.NewLocal(dataDir)
}
return res, nil
}
// ---------------------------------------------------------------------------
// Shared build logic
// ---------------------------------------------------------------------------
// dialEnv abstracts the mode-specific differences (remote vs tunnel) that
// buildResources needs.
type dialEnv interface {
fallbackCaps() map[string]bool
mergeCaps(discovered map[string]bool) types.Capabilities
// listenAddr opens or formats a host:port address for the given port.
// Tunnel mode opens a local listener; remote mode formats host:port.
listenAddr(port int) (string, error)
newProxy(ports types.Ports) proxy.Proxy
newVNC(ports types.Ports) vnc.VNC
}
// buildResources constructs a ConnResources from an established gRPC
// connection. Shared by DialRemote and DialTunnel.
func buildResources(conn *grpc.ClientConn, cfg *dialConfig, env dialEnv) (*ConnResources, error) {
info, err := discoverInfo(conn, cfg)
if err != nil {
info = &discoveredInfo{Capabilities: env.fallbackCaps()}
}
caps := env.mergeCaps(info.Capabilities)
res := &ConnResources{
GRPCConn: conn,
HostExec: hepb.NewHostExecClient(conn),
Volume: volume.NewRemote(conn),
Caps: caps,
System: info.System,
Ports: cfg.ports,
Version: info.Version,
}
if cfg.runtime == types.K8s || (!caps.Docker && caps.K8s) {
if cfg.kubeConfig != "" {
k8sPort := cfg.ports.K8s
if k8sPort == 0 {
k8sPort = 16443
}
addr, err := env.listenAddr(k8sPort)
if err == nil {
sb, err := runtime.NewK8s(addr, runtime.K8sOption{
Namespace: cfg.namespace,
KubeConfig: cfg.kubeConfig,
})
if err == nil {
res.Runtime = sb
res.Image = runtime.NewK8sImage()
}
}
}
} else if caps.Docker {
dockerPort := cfg.ports.Docker
if dockerPort == 0 {
dockerPort = 12375
}
addr, err := env.listenAddr(dockerPort)
if err == nil {
sb, err := runtime.NewDocker("tcp://" + addr)
if err == nil {
res.Runtime = sb
res.Image = runtime.NewDockerImage(runtime.DockerCli(sb))
}
}
}
if res.Runtime != nil {
res.Proxy = env.newProxy(cfg.ports)
res.VNC = env.newVNC(cfg.ports)
}
return res, nil
}
// ---------------------------------------------------------------------------
// remoteEnv — direct TCP connections
// ---------------------------------------------------------------------------
type remoteEnv struct {
host string
httpClient *http.Client
}
func (e *remoteEnv) fallbackCaps() map[string]bool {
return map[string]bool{"docker": true}
}
func (e *remoteEnv) mergeCaps(discovered map[string]bool) types.Capabilities {
return types.Capabilities{
Docker: discovered["docker"],
K8s: discovered["k8s"],
HostExec: discovered["host_exec"],
}
}
func (e *remoteEnv) listenAddr(port int) (string, error) {
return fmt.Sprintf("%s:%d", e.host, port), nil
}
func (e *remoteEnv) newProxy(ports types.Ports) proxy.Proxy {
return proxy.NewRemote(e.host, ports.HTTP, e.httpClient)
}
func (e *remoteEnv) newVNC(ports types.Ports) vnc.VNC {
return vnc.NewRemote(e.host, ports.VNC, e.httpClient)
}
// ---------------------------------------------------------------------------
// tunnelEnv — connections via WebSocket tunnel
// ---------------------------------------------------------------------------
type tunnelEnv struct {
taiID string
yaoBase string
reg *registry.Registry
regCaps types.Capabilities
listeners []net.Listener
}
func (e *tunnelEnv) fallbackCaps() map[string]bool {
return make(map[string]bool)
}
func (e *tunnelEnv) mergeCaps(discovered map[string]bool) types.Capabilities {
return types.Capabilities{
Docker: discovered["docker"] || e.regCaps.Docker,
K8s: discovered["k8s"] || e.regCaps.K8s,
HostExec: discovered["host_exec"] || e.regCaps.HostExec,
}
}
func (e *tunnelEnv) listenAddr(port int) (string, error) {
ln, err := e.reg.OpenLocalListener(e.taiID, port)
if err != nil {
return "", err
}
e.listeners = append(e.listeners, ln)
return ln.Addr().String(), nil
}
func (e *tunnelEnv) newProxy(_ types.Ports) proxy.Proxy {
return proxy.NewTunnel(e.taiID, e.yaoBase)
}
func (e *tunnelEnv) newVNC(_ types.Ports) vnc.VNC {
return vnc.NewTunnel(e.taiID, e.yaoBase)
}
// ---------------------------------------------------------------------------
// Dial options
// ---------------------------------------------------------------------------
// DialOption configures a Dial* call.
type DialOption interface {
applyDial(*dialConfig)
}
type dialOptionFunc func(*dialConfig)
func (f dialOptionFunc) applyDial(c *dialConfig) { f(c) }
// WithDialRuntime selects the container runtime for the dial call.
func WithDialRuntime(rt types.Runtime) DialOption {
return dialOptionFunc(func(c *dialConfig) { c.runtime = rt })
}
// WithDialKubeConfig sets the kubeconfig for K8s runtime.
func WithDialKubeConfig(path string) DialOption {
return dialOptionFunc(func(c *dialConfig) { c.kubeConfig = path })
}
// WithDialNamespace sets the K8s namespace.
func WithDialNamespace(ns string) DialOption {
return dialOptionFunc(func(c *dialConfig) { c.namespace = ns })
}
// WithDialHTTPClient sets a custom HTTP client for proxy/VNC.
func WithDialHTTPClient(hc *http.Client) DialOption {
return dialOptionFunc(func(c *dialConfig) { c.httpClient = hc })
}
type dialConfig struct {
runtime types.Runtime
ports types.Ports
kubeConfig string
namespace string
httpClient *http.Client
userPorts types.Ports
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func dialGRPC(target string) (*grpc.ClientConn, error) {
return grpc.NewClient(target,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 20 * time.Second,
Timeout: 5 * time.Second,
PermitWithoutStream: true,
}),
)
}
// ---------------------------------------------------------------------------
// ServerInfo discovery (shared by DialRemote / DialTunnel)
// ---------------------------------------------------------------------------
type discoveredInfo struct {
Capabilities map[string]bool
System types.SystemInfo
Version string
}
func discoverInfo(conn *grpc.ClientConn, cfg *dialConfig) (*discoveredInfo, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client := sipb.NewServerInfoClient(conn)
resp, err := client.GetInfo(ctx, &sipb.GetInfoRequest{})
if err != nil {
return nil, err
}
up := cfg.userPorts
if p := int(resp.Ports["http"]); p > 0 && up.HTTP == 0 {
cfg.ports.HTTP = p
}
if p := int(resp.Ports["docker"]); p > 0 && up.Docker == 0 {
cfg.ports.Docker = p
}
if p := int(resp.Ports["vnc"]); p > 0 && up.VNC == 0 {
cfg.ports.VNC = p
}
if p := int(resp.Ports["k8s"]); p > 0 && up.K8s == 0 {
cfg.ports.K8s = p
}
caps := resp.Capabilities
if caps == nil {
caps = make(map[string]bool)
}
var sys types.SystemInfo
if s := resp.System; s != nil {
sys = types.SystemInfo{
OS: s.Os,
Arch: s.Arch,
Hostname: s.Hostname,
NumCPU: int(s.NumCpu),
TotalMem: s.TotalMem,
Shell: s.Shell,
TempDir: s.TempDir,
}
}
return &discoveredInfo{
Capabilities: caps,
System: sys,
Version: resp.Version,
}, nil
}

View file

@ -6,7 +6,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/yaoapp/yao/tai/sandbox" "github.com/yaoapp/yao/tai/runtime"
) )
// Proxy resolves HTTP service URLs for containers. // Proxy resolves HTTP service URLs for containers.
@ -98,11 +98,11 @@ func (t *tunnelProxy) Healthz(_ context.Context) error {
// --- Local implementation --- // --- Local implementation ---
type localProxy struct { type localProxy struct {
sb sandbox.Sandbox sb runtime.Runtime
} }
// NewLocal creates a Proxy that resolves host ports via sandbox.Inspect. // NewLocal creates a Proxy that resolves host ports via runtime.Inspect.
func NewLocal(sb sandbox.Sandbox) Proxy { func NewLocal(sb runtime.Runtime) Proxy {
return &localProxy{sb: sb} return &localProxy{sb: sb}
} }

View file

@ -10,7 +10,7 @@ import (
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/yaoapp/yao/tai/sandbox" "github.com/yaoapp/yao/tai/runtime"
) )
func TestRemoteURL(t *testing.T) { func TestRemoteURL(t *testing.T) {
@ -72,10 +72,10 @@ func TestRemoteHealthzFail(t *testing.T) {
func TestLocalURL(t *testing.T) { func TestLocalURL(t *testing.T) {
mock := &mockSandbox{ mock := &mockSandbox{
inspectFn: func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) { inspectFn: func(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
return &sandbox.ContainerInfo{ return &runtime.ContainerInfo{
ID: id, ID: id,
Ports: []sandbox.PortMapping{ Ports: []runtime.PortMapping{
{ContainerPort: 3000, HostPort: 32768, HostIP: "127.0.0.1", Protocol: "tcp"}, {ContainerPort: 3000, HostPort: 32768, HostIP: "127.0.0.1", Protocol: "tcp"},
{ContainerPort: 8080, HostPort: 32769, HostIP: "127.0.0.1", Protocol: "tcp"}, {ContainerPort: 8080, HostPort: 32769, HostIP: "127.0.0.1", Protocol: "tcp"},
}, },
@ -98,8 +98,8 @@ func TestLocalURL(t *testing.T) {
func TestLocalURLPortNotFound(t *testing.T) { func TestLocalURLPortNotFound(t *testing.T) {
mock := &mockSandbox{ mock := &mockSandbox{
inspectFn: func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) { inspectFn: func(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
return &sandbox.ContainerInfo{ID: id}, nil return &runtime.ContainerInfo{ID: id}, nil
}, },
} }
@ -112,7 +112,7 @@ func TestLocalURLPortNotFound(t *testing.T) {
func TestLocalURLInspectError(t *testing.T) { func TestLocalURLInspectError(t *testing.T) {
mock := &mockSandbox{ mock := &mockSandbox{
inspectFn: func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) { inspectFn: func(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
return nil, fmt.Errorf("not found") return nil, fmt.Errorf("not found")
}, },
} }
@ -260,12 +260,12 @@ func TestConnectSSE_Non200(t *testing.T) {
} }
} }
// mockSandbox implements sandbox.Sandbox for testing. // mockSandbox implements runtime.Sandbox for testing.
type mockSandbox struct { type mockSandbox struct {
inspectFn func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) inspectFn func(ctx context.Context, id string) (*runtime.ContainerInfo, error)
} }
func (m *mockSandbox) Create(ctx context.Context, opts sandbox.CreateOptions) (string, error) { func (m *mockSandbox) Create(ctx context.Context, opts runtime.CreateOptions) (string, error) {
return "", nil return "", nil
} }
func (m *mockSandbox) Start(ctx context.Context, id string) error { return nil } func (m *mockSandbox) Start(ctx context.Context, id string) error { return nil }
@ -273,19 +273,19 @@ func (m *mockSandbox) Stop(ctx context.Context, id string, timeout time.Duration
return nil return nil
} }
func (m *mockSandbox) Remove(ctx context.Context, id string, force bool) error { return nil } func (m *mockSandbox) Remove(ctx context.Context, id string, force bool) error { return nil }
func (m *mockSandbox) Exec(ctx context.Context, id string, cmd []string, opts sandbox.ExecOptions) (*sandbox.ExecResult, error) { func (m *mockSandbox) Exec(ctx context.Context, id string, cmd []string, opts runtime.ExecOptions) (*runtime.ExecResult, error) {
return nil, nil return nil, nil
} }
func (m *mockSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts sandbox.ExecOptions) (*sandbox.StreamHandle, error) { func (m *mockSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts runtime.ExecOptions) (*runtime.StreamHandle, error) {
return nil, nil return nil, nil
} }
func (m *mockSandbox) Inspect(ctx context.Context, id string) (*sandbox.ContainerInfo, error) { func (m *mockSandbox) Inspect(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
if m.inspectFn != nil { if m.inspectFn != nil {
return m.inspectFn(ctx, id) return m.inspectFn(ctx, id)
} }
return &sandbox.ContainerInfo{ID: id}, nil return &runtime.ContainerInfo{ID: id}, nil
} }
func (m *mockSandbox) List(ctx context.Context, opts sandbox.ListOptions) ([]sandbox.ContainerInfo, error) { func (m *mockSandbox) List(ctx context.Context, opts runtime.ListOptions) ([]runtime.ContainerInfo, error) {
return nil, nil return nil, nil
} }
func (m *mockSandbox) Close() error { return nil } func (m *mockSandbox) Close() error { return nil }

View file

@ -13,32 +13,22 @@ import (
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/yaoapp/yao/tai/types"
) )
// SystemInfo describes the host machine running Tai.
type SystemInfo struct {
OS string `json:"os"`
Arch string `json:"arch"`
Hostname string `json:"hostname"`
NumCPU int `json:"num_cpu"`
TotalMem int64 `json:"total_mem,omitempty"`
Shell string `json:"shell,omitempty"`
TempDir string `json:"temp_dir,omitempty"`
}
// TaiNode represents a registered Tai instance (direct or tunnel). // TaiNode represents a registered Tai instance (direct or tunnel).
// Internal use only; external callers receive NodeSnapshot via Get()/List(). // Internal use only; external callers receive types.NodeMeta via Get()/List().
type TaiNode struct { type TaiNode struct {
TaiID string TaiID string
MachineID string MachineID string
Version string Version string
Auth AuthInfo Auth types.AuthInfo
System SystemInfo System types.SystemInfo
Mode string // "direct" | "tunnel" Mode string // "direct" | "tunnel"
Addr string // direct mode: "tai-host"; tunnel mode: empty Addr string // direct mode: "tai-host"; tunnel mode: empty
YaoBase string // Yao server base URL reported by Tai (tunnel mode) YaoBase string // Yao server base URL reported by Tai (tunnel mode)
Ports map[string]int // {"grpc":19100, "http":8099, "vnc":16080, "docker":12375} Ports types.Ports
Capabilities map[string]bool Capabilities types.Capabilities
ControlConn *websocket.Conn ControlConn *websocket.Conn
connMu sync.Mutex // protects ControlConn writes connMu sync.Mutex // protects ControlConn writes
@ -48,64 +38,22 @@ type TaiNode struct {
LastPing time.Time LastPing time.Time
DisplayName string // optional human-readable name for UI DisplayName string // optional human-readable name for UI
client any // *tai.Client; stored as any to avoid import cycle resources any // *tai.ConnResources; stored as any to avoid import cycle
localListeners map[int]*tunnelListener localListeners map[int]*tunnelListener
} }
// NodeSnapshot is a read-only copy of TaiNode fields safe to use outside locks. func (n *TaiNode) meta() types.NodeMeta {
type NodeSnapshot struct { return types.NodeMeta{
TaiID string
MachineID string
Version string
Auth AuthInfo
System SystemInfo
Mode string
Addr string
YaoBase string
Ports map[string]int
Capabilities map[string]bool
Status string
ConnectedAt time.Time
LastPing time.Time
DisplayName string
client any
}
func (n *TaiNode) snapshot() NodeSnapshot {
ports := make(map[string]int, len(n.Ports))
for k, v := range n.Ports {
ports[k] = v
}
caps := make(map[string]bool, len(n.Capabilities))
for k, v := range n.Capabilities {
caps[k] = v
}
return NodeSnapshot{
TaiID: n.TaiID, MachineID: n.MachineID, Version: n.Version, TaiID: n.TaiID, MachineID: n.MachineID, Version: n.Version,
Auth: n.Auth, System: n.System, Auth: n.Auth, System: n.System,
Mode: n.Mode, Addr: n.Addr, YaoBase: n.YaoBase, Mode: n.Mode, Addr: n.Addr, YaoBase: n.YaoBase,
Ports: ports, Capabilities: caps, Ports: n.Ports, Capabilities: n.Capabilities,
Status: n.Status, ConnectedAt: n.ConnectedAt, LastPing: n.LastPing, Status: n.Status, ConnectedAt: n.ConnectedAt, LastPing: n.LastPing,
DisplayName: n.DisplayName, DisplayName: n.DisplayName,
client: n.client,
} }
} }
// Client returns the associated *tai.Client (as any to avoid import cycle).
// Callers should type-assert: snap.Client().(*tai.Client).
func (s *NodeSnapshot) Client() any { return s.client }
// AuthInfo holds Yao user authorization extracted from OAuth token.
type AuthInfo struct {
Subject string
UserID string
ClientID string
Scope string
TeamID string
TenantID string
}
// pendingChannel represents a channel awaiting Tai's data WS connection. // pendingChannel represents a channel awaiting Tai's data WS connection.
type pendingChannel struct { type pendingChannel struct {
taiID string taiID string
@ -188,7 +136,8 @@ func (r *Registry) Register(node *TaiNode) {
"tai_id", node.TaiID, "mode", node.Mode, "version", node.Version) "tai_id", node.TaiID, "mode", node.Mode, "version", node.Version)
} }
// Unregister removes a Tai node and closes its local listeners and control connection. // Unregister removes a Tai node, closes its local listeners, control connection,
// and any held ConnResources.
func (r *Registry) Unregister(taiID string) { func (r *Registry) Unregister(taiID string) {
r.mu.Lock() r.mu.Lock()
node, ok := r.nodes[taiID] node, ok := r.nodes[taiID]
@ -208,29 +157,34 @@ func (r *Registry) Unregister(taiID string) {
r.mu.Unlock() r.mu.Unlock()
if ok { if ok {
if node.resources != nil {
if closer, ok := node.resources.(ResourceCloser); ok {
closer.Close()
}
}
r.logger.Info("tai node unregistered", "tai_id", taiID) r.logger.Info("tai node unregistered", "tai_id", taiID)
} }
} }
// Get returns a snapshot of a Tai node by ID. Returns nil, false if not found. // Get returns the metadata of a Tai node by ID. Returns nil, false if not found.
func (r *Registry) Get(taiID string) (*NodeSnapshot, bool) { func (r *Registry) Get(taiID string) (*types.NodeMeta, bool) {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
n, ok := r.nodes[taiID] n, ok := r.nodes[taiID]
if !ok { if !ok {
return nil, false return nil, false
} }
snap := n.snapshot() m := n.meta()
return &snap, true return &m, true
} }
// List returns snapshots of all registered Tai nodes. // List returns metadata of all registered Tai nodes.
func (r *Registry) List() []NodeSnapshot { func (r *Registry) List() []types.NodeMeta {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
result := make([]NodeSnapshot, 0, len(r.nodes)) result := make([]types.NodeMeta, 0, len(r.nodes))
for _, n := range r.nodes { for _, n := range r.nodes {
result = append(result, n.snapshot()) result = append(result, n.meta())
} }
return result return result
} }
@ -263,14 +217,41 @@ func (r *Registry) UpdatePing(taiID string) {
} }
} }
// SetClient associates a *tai.Client with a registered node. // ResourceCloser is implemented by *tai.ConnResources to allow the registry
// Called by tai.New() after successful initialization. // to close resources without importing the tai package (avoids import cycle).
func (r *Registry) SetClient(taiID string, c any) { type ResourceCloser interface {
Close() error
}
// SetResources binds connection resources to a registered node.
// If the node already has resources, the old ones are closed asynchronously.
// The node status is set to "online".
func (r *Registry) SetResources(taiID string, res any) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
if n, ok := r.nodes[taiID]; ok { n, ok := r.nodes[taiID]
n.client = c if !ok {
return
} }
if n.resources != nil {
if closer, ok := n.resources.(ResourceCloser); ok {
go closer.Close()
}
}
n.resources = res
n.Status = "online"
}
// GetResources returns the *tai.ConnResources for a node (as any).
// Callers should type-assert to *tai.ConnResources.
func (r *Registry) GetResources(taiID string) (any, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
n, ok := r.nodes[taiID]
if !ok || n.resources == nil {
return nil, false
}
return n.resources, true
} }
// FindTaiIDByAuthClient returns the TaiID of the first node whose // FindTaiIDByAuthClient returns the TaiID of the first node whose
@ -288,28 +269,28 @@ func (r *Registry) FindTaiIDByAuthClient(clientID string) string {
return "" return ""
} }
// ListByTeam returns snapshots of all nodes belonging to the given team. // ListByTeam returns metadata of all nodes belonging to the given team.
func (r *Registry) ListByTeam(teamID string) []NodeSnapshot { func (r *Registry) ListByTeam(teamID string) []types.NodeMeta {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
var result []NodeSnapshot var result []types.NodeMeta
for _, n := range r.nodes { for _, n := range r.nodes {
if n.Auth.TeamID == teamID { if n.Auth.TeamID == teamID {
result = append(result, n.snapshot()) result = append(result, n.meta())
} }
} }
return result return result
} }
// ListByUser returns snapshots of all nodes registered by the given user // ListByUser returns metadata of all nodes registered by the given user
// that are NOT associated with any team. // that are NOT associated with any team.
func (r *Registry) ListByUser(userID string) []NodeSnapshot { func (r *Registry) ListByUser(userID string) []types.NodeMeta {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
var result []NodeSnapshot var result []types.NodeMeta
for _, n := range r.nodes { for _, n := range r.nodes {
if n.Auth.TeamID == "" && n.Auth.UserID == userID { if n.Auth.TeamID == "" && n.Auth.UserID == userID {
result = append(result, n.snapshot()) result = append(result, n.meta())
} }
} }
return result return result

View file

@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/yaoapp/yao/tai/types"
) )
// newTestRegistry creates a standalone registry for testing (bypasses global singleton). // newTestRegistry creates a standalone registry for testing (bypasses global singleton).
@ -29,7 +30,7 @@ func TestRegister_SetsFieldsAndOnline(t *testing.T) {
MachineID: "m-abc", MachineID: "m-abc",
Version: "1.0.0", Version: "1.0.0",
Mode: "tunnel", Mode: "tunnel",
Ports: map[string]int{"grpc": 19100}, Ports: types.Ports{GRPC: 19100},
} }
r.Register(node) r.Register(node)
@ -117,14 +118,14 @@ func TestSnapshot_DeepCopy(t *testing.T) {
r := newTestRegistry() r := newTestRegistry()
r.Register(&TaiNode{ r.Register(&TaiNode{
TaiID: "tai-001", TaiID: "tai-001",
Ports: map[string]int{"grpc": 19100, "http": 8099}, Ports: types.Ports{GRPC: 19100, HTTP: 8099},
}) })
snap, _ := r.Get("tai-001") snap, _ := r.Get("tai-001")
snap.Ports["grpc"] = 0 snap.Ports.GRPC = 0
snap2, _ := r.Get("tai-001") snap2, _ := r.Get("tai-001")
if snap2.Ports["grpc"] != 19100 { if snap2.Ports.GRPC != 19100 {
t.Error("snapshot modification leaked into registry node") t.Error("snapshot modification leaked into registry node")
} }
} }
@ -479,7 +480,7 @@ func TestRegister_SystemInfo(t *testing.T) {
r := newTestRegistry() r := newTestRegistry()
r.Register(&TaiNode{ r.Register(&TaiNode{
TaiID: "tai-001", TaiID: "tai-001",
System: SystemInfo{ System: types.SystemInfo{
OS: "linux", OS: "linux",
Arch: "amd64", Arch: "amd64",
Hostname: "docker-host-01", Hostname: "docker-host-01",
@ -507,9 +508,9 @@ func TestRegister_SystemInfo(t *testing.T) {
func TestListByTeam(t *testing.T) { func TestListByTeam(t *testing.T) {
r := newTestRegistry() r := newTestRegistry()
r.Register(&TaiNode{TaiID: "tai-a", Auth: AuthInfo{TeamID: "team-dev"}}) r.Register(&TaiNode{TaiID: "tai-a", Auth: types.AuthInfo{TeamID: "team-dev"}})
r.Register(&TaiNode{TaiID: "tai-b", Auth: AuthInfo{TeamID: "team-dev"}}) r.Register(&TaiNode{TaiID: "tai-b", Auth: types.AuthInfo{TeamID: "team-dev"}})
r.Register(&TaiNode{TaiID: "tai-c", Auth: AuthInfo{TeamID: "team-ops"}}) r.Register(&TaiNode{TaiID: "tai-c", Auth: types.AuthInfo{TeamID: "team-ops"}})
devNodes := r.ListByTeam("team-dev") devNodes := r.ListByTeam("team-dev")
if len(devNodes) != 2 { if len(devNodes) != 2 {
@ -604,11 +605,11 @@ func TestStartHealthCheck_PingKeepsAlive(t *testing.T) {
} }
} }
func TestNodeSnapshot_AuthInfo(t *testing.T) { func TestNodeMeta_AuthInfo(t *testing.T) {
r := newTestRegistry() r := newTestRegistry()
r.Register(&TaiNode{ r.Register(&TaiNode{
TaiID: "tai-001", TaiID: "tai-001",
Auth: AuthInfo{ Auth: types.AuthInfo{
Subject: "user123", Subject: "user123",
ClientID: "tai-001", ClientID: "tai-001",
Scope: "tai:tunnel", Scope: "tai:tunnel",

View file

@ -1,8 +1,8 @@
package sandbox package runtime
import "github.com/docker/docker/client" import "github.com/docker/docker/client"
// dockerCliAccessor is implemented by sandbox types that hold a Docker client. // dockerCliAccessor is implemented by runtime types that hold a Docker client.
type dockerCliAccessor interface { type dockerCliAccessor interface {
dockerClient() *client.Client dockerClient() *client.Client
} }
@ -10,10 +10,10 @@ type dockerCliAccessor interface {
func (l *local) dockerClient() *client.Client { return l.core.cli } func (l *local) dockerClient() *client.Client { return l.core.cli }
func (d *dockerSandbox) dockerClient() *client.Client { return d.core.cli } func (d *dockerSandbox) dockerClient() *client.Client { return d.core.cli }
// DockerCli extracts the underlying Docker SDK client from a Sandbox. // DockerCli extracts the underlying Docker SDK client from a Runtime.
// Returns nil if the Sandbox is not Docker-based (e.g. K8s). // Returns nil if the Runtime is not Docker-based (e.g. K8s).
func DockerCli(sb Sandbox) *client.Client { func DockerCli(rt Runtime) *client.Client {
if a, ok := sb.(dockerCliAccessor); ok { if a, ok := rt.(dockerCliAccessor); ok {
return a.dockerClient() return a.dockerClient()
} }
return nil return nil

View file

@ -1,4 +1,4 @@
package sandbox package runtime
import ( import (
"context" "context"
@ -12,9 +12,9 @@ type dockerSandbox struct {
core dockerCore core dockerCore
} }
// NewDocker creates a Sandbox backed by Docker SDK through Tai's Docker API proxy. // NewDocker creates a Runtime backed by Docker SDK through Tai's Docker API proxy.
// addr should be "tcp://tai-host:12375". // addr should be "tcp://tai-host:12375".
func NewDocker(addr string) (Sandbox, error) { func NewDocker(addr string) (Runtime, error) {
cli, err := client.NewClientWithOpts( cli, err := client.NewClientWithOpts(
client.WithHost(addr), client.WithHost(addr),
client.WithAPIVersionNegotiation(), client.WithAPIVersionNegotiation(),

View file

@ -1,4 +1,4 @@
package sandbox package runtime
import ( import (
"bytes" "bytes"
@ -15,7 +15,7 @@ import (
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
) )
// dockerCore contains Docker SDK operations shared by both Local and Docker (via Tai) sandboxes. // dockerCore contains Docker SDK operations shared by both Local and Docker (via Tai) runtimes.
type dockerCore struct { type dockerCore struct {
cli *client.Client cli *client.Client
} }

View file

@ -1,4 +1,4 @@
package sandbox package runtime
import ( import (
"context" "context"

View file

@ -1,4 +1,4 @@
package sandbox package runtime
import ( import (
"context" "context"
@ -14,7 +14,7 @@ import (
) )
// dockerImage implements Image using the Docker SDK. // dockerImage implements Image using the Docker SDK.
// Shared by both local and dockerSandbox (via Tai proxy) modes. // Shared by both local and docker (via Tai proxy) runtime modes.
type dockerImage struct { type dockerImage struct {
cli *client.Client cli *client.Client
} }

View file

@ -1,4 +1,4 @@
package sandbox package runtime
import "context" import "context"

View file

@ -1,4 +1,4 @@
package sandbox package runtime
import ( import (
"bytes" "bytes"
@ -20,7 +20,7 @@ import (
"k8s.io/client-go/tools/remotecommand" "k8s.io/client-go/tools/remotecommand"
) )
// K8sOption configures a K8s sandbox. // K8sOption configures a K8s runtime.
type K8sOption struct { type K8sOption struct {
Namespace string // default "default" Namespace string // default "default"
KubeConfig string // path to kubeconfig file KubeConfig string // path to kubeconfig file
@ -33,10 +33,10 @@ type k8sSandbox struct {
labels map[string]string labels map[string]string
} }
// NewK8s creates a Sandbox backed by Kubernetes via Tai's TCP proxy. // NewK8s creates a Runtime backed by Kubernetes via Tai's TCP proxy.
// addr should be "host:port" pointing to Tai's K8s proxy endpoint. // addr should be "host:port" pointing to Tai's K8s proxy endpoint.
// kubeConfigPath must be an absolute path or will be resolved relative to the caller's working directory. // kubeConfigPath must be an absolute path or will be resolved relative to the caller's working directory.
func NewK8s(addr string, opts ...K8sOption) (Sandbox, error) { func NewK8s(addr string, opts ...K8sOption) (Runtime, error) {
ns := "default" ns := "default"
var kubeConfigPath string var kubeConfigPath string
if len(opts) > 0 { if len(opts) > 0 {
@ -56,7 +56,7 @@ func NewK8s(addr string, opts ...K8sOption) (Sandbox, error) {
} }
if kubeConfigPath == "" { if kubeConfigPath == "" {
return nil, fmt.Errorf("kubeconfig path is required for K8s sandbox") return nil, fmt.Errorf("kubeconfig path is required for K8s runtime")
} }
cfg, err := clientcmd.BuildConfigFromFlags("", kubeConfigPath) cfg, err := clientcmd.BuildConfigFromFlags("", kubeConfigPath)

View file

@ -1,4 +1,4 @@
package sandbox package runtime
import ( import (
"context" "context"
@ -12,9 +12,9 @@ type local struct {
core dockerCore core dockerCore
} }
// NewLocal creates a Sandbox backed by a direct Docker daemon connection. // NewLocal creates a Runtime backed by a direct Docker daemon connection.
// addr can be "unix:///var/run/docker.sock", "tcp://host:port", or "" for platform default. // addr can be "unix:///var/run/docker.sock", "tcp://host:port", or "" for platform default.
func NewLocal(addr string) (Sandbox, error) { func NewLocal(addr string) (Runtime, error) {
opts := []client.Opt{client.WithAPIVersionNegotiation()} opts := []client.Opt{client.WithAPIVersionNegotiation()}
if addr != "" { if addr != "" {
opts = append(opts, client.WithHost(addr)) opts = append(opts, client.WithHost(addr))

View file

@ -1,4 +1,4 @@
package sandbox package runtime
import ( import (
"context" "context"
@ -53,7 +53,7 @@ func TestHelpers(t *testing.T) {
}) })
} }
func TestLocalSandbox(t *testing.T) { func TestLocalRuntime(t *testing.T) {
sb, err := NewLocal("") sb, err := NewLocal("")
if err != nil { if err != nil {
t.Skipf("Docker not available: %v", err) t.Skipf("Docker not available: %v", err)
@ -258,7 +258,7 @@ func TestLocalCreateWithEnvAndWorkDir(t *testing.T) {
} }
} }
func TestDockerSandboxViaTai(t *testing.T) { func TestDockerRuntimeViaTai(t *testing.T) {
addr := taiTestDocker() addr := taiTestDocker()
sb, err := NewDocker(addr) sb, err := NewDocker(addr)
if err != nil { if err != nil {
@ -352,7 +352,7 @@ func TestPortStr(t *testing.T) {
} }
} }
func TestK8sSandbox(t *testing.T) { func TestK8sRuntime(t *testing.T) {
host := taiTestK8sHost() host := taiTestK8sHost()
port := taiTestK8sPort() port := taiTestK8sPort()
kubeconfig := taiTestKubeConfig() kubeconfig := taiTestKubeConfig()
@ -496,7 +496,7 @@ func TestK8sBuildResourcesPartial(t *testing.T) {
} }
} }
func TestK8sSandboxStopAndRemove(t *testing.T) { func TestK8sRuntimeStopAndRemove(t *testing.T) {
host := taiTestK8sHost() host := taiTestK8sHost()
port := taiTestK8sPort() port := taiTestK8sPort()
kubeconfig := taiTestKubeConfig() kubeconfig := taiTestKubeConfig()

View file

@ -1,4 +1,4 @@
package sandbox package runtime
import ( import (
"context" "context"
@ -6,9 +6,9 @@ import (
"time" "time"
) )
// Sandbox manages container lifecycle. // Runtime manages container lifecycle.
// Local connects directly to a Docker daemon; Docker/Containerd/K8s connect via Tai proxy. // Local connects directly to a Docker daemon; Docker/Containerd/K8s connect via Tai proxy.
type Sandbox interface { type Runtime interface {
Create(ctx context.Context, opts CreateOptions) (string, error) Create(ctx context.Context, opts CreateOptions) (string, error)
Start(ctx context.Context, id string) error Start(ctx context.Context, id string) error
Stop(ctx context.Context, id string, timeout time.Duration) error Stop(ctx context.Context, id string, timeout time.Duration) error

View file

@ -1,38 +1,16 @@
package tai package tai
import ( import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
hepb "github.com/yaoapp/yao/tai/hostexec/pb"
"github.com/yaoapp/yao/tai/proxy"
"github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/sandbox" "github.com/yaoapp/yao/tai/types"
sipb "github.com/yaoapp/yao/tai/serverinfo/pb"
"github.com/yaoapp/yao/tai/vnc"
"github.com/yaoapp/yao/tai/volume" "github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/tai/workspace"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
) )
// Runtime selects which container runtime to use via Tai. // Type aliases kept at package level for convenience.
type Runtime int type Runtime = types.Runtime
type Ports = types.Ports
const ( // Option configures RegisterLocal.
Docker Runtime = iota
K8s
)
func (r Runtime) apply(c *config) { c.runtime = r }
// Option configures a Client.
type Option interface { type Option interface {
apply(*config) apply(*config)
} }
@ -41,60 +19,19 @@ type optionFunc func(*config)
func (f optionFunc) apply(c *config) { f(c) } func (f optionFunc) apply(c *config) { f(c) }
// Ports configures service ports for Tai server.
type Ports struct {
GRPC int // default 19100
HTTP int // default 8099
VNC int // default 16080
Docker int // default 12375
K8s int // default 16443
}
// WithPorts overrides default Tai service ports.
// Ports set here take precedence over server-reported values from ServerInfo.
func WithPorts(p Ports) Option {
return optionFunc(func(c *config) {
c.ports = p
c.userPorts = p
})
}
// WithHTTPClient sets a custom HTTP client for proxy and VNC health checks.
func WithHTTPClient(hc *http.Client) Option {
return optionFunc(func(c *config) { c.httpClient = hc })
}
// WithDataDir sets the workspace root directory for Local mode. // WithDataDir sets the workspace root directory for Local mode.
func WithDataDir(dir string) Option { func WithDataDir(dir string) Option {
return optionFunc(func(c *config) { c.dataDir = dir }) return optionFunc(func(c *config) { c.dataDir = dir })
} }
// WithKubeConfig sets the kubeconfig file path for K8s runtime. // WithVolume injects a custom Volume implementation (useful for testing).
// Supports both absolute and relative paths (relative paths are resolved to absolute).
func WithKubeConfig(path string) Option {
return optionFunc(func(c *config) { c.kubeConfig = path })
}
// WithNamespace sets the namespace for K8s runtime. Default is "default".
func WithNamespace(ns string) Option {
return optionFunc(func(c *config) { c.namespace = ns })
}
// WithVolume injects a custom Volume implementation.
// Useful for testing workspace operations without Docker.
func WithVolume(vol volume.Volume) Option { func WithVolume(vol volume.Volume) Option {
return optionFunc(func(c *config) { c.volume = vol }) return optionFunc(func(c *config) { c.volume = vol })
} }
type config struct { type config struct {
runtime Runtime dataDir string
ports Ports volume volume.Volume
userPorts Ports // tracks explicitly set ports (zero = not set by user)
httpClient *http.Client
dataDir string
kubeConfig string
namespace string
volume volume.Volume // override volume (for testing without Docker)
} }
func defaultPorts() Ports { func defaultPorts() Ports {
@ -125,504 +62,15 @@ func mergedPorts(p Ports) Ports {
return d return d
} }
// Client provides unified access to all Tai SDK sub-packages. func intOr(v, fallback int) int {
type Client struct { if v > 0 {
scheme string // "tai", "docker", or "tunnel" return v
host string
addr string
taiID string // registry key — set by initLocal/initRemote/initTunnel
ports Ports
dataDir string // host-side data directory for local volume
vol volume.Volume
sb sandbox.Sandbox
img sandbox.Image
prx proxy.Proxy
vc vnc.VNC
he hepb.HostExecClient
grpcConn *grpc.ClientConn
// tunnel mode: local listeners that bridge to Tai via WS
tunnelListeners []net.Listener
}
// New creates a Client based on the address protocol:
//
// "local" → Local mode, platform default Docker socket
// "docker://addr" → Local mode, specified Docker daemon
// "tai://host" → Remote mode via Tai Server
//
// Empty string is not allowed — use "local" for default local Docker.
func New(addr string, opts ...Option) (*Client, error) {
cfg := &config{ports: defaultPorts()}
for _, o := range opts {
o.apply(cfg)
}
cfg.ports = mergedPorts(cfg.ports)
scheme, host, dockerAddr, grpcPort, err := parseAddr(addr)
if err != nil {
return nil, err
}
if grpcPort > 0 {
cfg.ports.GRPC = grpcPort
}
c := &Client{
scheme: scheme,
host: host,
addr: dockerAddr,
ports: cfg.ports,
}
switch scheme {
case "docker":
return c.initLocal(cfg)
case "tai":
return c.initRemote(cfg)
case "tunnel":
return c.initTunnel(cfg)
default:
return nil, fmt.Errorf("unsupported scheme: %s", scheme)
}
}
func (c *Client) initLocal(cfg *config) (*Client, error) {
sb, err := sandbox.NewLocal(c.addr)
if err != nil && cfg.volume == nil {
return nil, err
}
if sb != nil {
c.sb = sb
c.img = sandbox.NewDockerImage(sandbox.DockerCli(sb))
c.prx = proxy.NewLocal(sb)
c.vc = vnc.NewLocal(sb)
}
if cfg.volume != nil {
c.vol = cfg.volume
c.dataDir = cfg.dataDir
} else {
dataDir := cfg.dataDir
if dataDir == "" {
dataDir = "/tmp/tai-volumes"
}
c.dataDir = dataDir
c.vol = volume.NewLocal(dataDir)
}
if reg := registry.Global(); reg != nil {
id := c.host
if id == "" {
id = c.addr
}
if id == "" {
id = "local"
}
c.taiID = id
reg.Register(&registry.TaiNode{
TaiID: id,
Mode: "local",
Addr: c.addr,
})
reg.SetClient(id, c)
}
return c, nil
}
func (c *Client) initRemote(cfg *config) (*Client, error) {
grpcAddr := fmt.Sprintf("%s:%d", c.host, c.ports.GRPC)
conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, fmt.Errorf("grpc dial %s: %w", grpcAddr, err)
}
c.grpcConn = conn
c.he = hepb.NewHostExecClient(conn)
info, err := c.discoverServerInfo(conn, cfg)
if err != nil {
info = &discoveredInfo{Capabilities: map[string]bool{"docker": true}}
}
hasDocker := info.Capabilities["docker"]
hasK8s := info.Capabilities["k8s"]
hasHostExec := info.Capabilities["host_exec"]
if !hasDocker && !hasK8s && !hasHostExec {
conn.Close()
return nil, fmt.Errorf("tai %s: no capabilities available (docker/k8s/host_exec all false)", c.host)
}
c.vol = volume.NewRemote(conn)
if cfg.runtime == K8s {
if cfg.kubeConfig == "" {
conn.Close()
return nil, fmt.Errorf("tai %s: K8s runtime requested but no kubeconfig provided", c.host)
}
k8sPort := c.ports.K8s
if k8sPort == 0 {
k8sPort = 16443
}
sbAddr := fmt.Sprintf("%s:%d", c.host, k8sPort)
sb, err := sandbox.NewK8s(sbAddr, sandbox.K8sOption{
Namespace: cfg.namespace,
KubeConfig: cfg.kubeConfig,
})
if err == nil {
c.sb = sb
c.img = sandbox.NewK8sImage()
}
} else if hasDocker {
dockerPort := c.ports.Docker
if dockerPort == 0 {
dockerPort = 12375
}
sbAddr := fmt.Sprintf("tcp://%s:%d", c.host, dockerPort)
sb, err := sandbox.NewDocker(sbAddr)
if err == nil {
c.sb = sb
c.img = sandbox.NewDockerImage(sandbox.DockerCli(sb))
}
}
if c.sb != nil {
hc := cfg.httpClient
c.prx = proxy.NewRemote(c.host, c.ports.HTTP, hc)
c.vc = vnc.NewRemote(c.host, c.ports.VNC, hc)
}
if reg := registry.Global(); reg != nil {
id := fmt.Sprintf("%s-%d", c.host, c.ports.GRPC)
c.taiID = id
reg.Register(&registry.TaiNode{
TaiID: id,
Mode: "direct",
Version: info.Version,
System: info.System,
Capabilities: info.Capabilities,
Addr: fmt.Sprintf("tai://%s:%d", c.host, c.ports.GRPC),
Ports: map[string]int{
"grpc": c.ports.GRPC,
"http": c.ports.HTTP,
"vnc": c.ports.VNC,
"docker": c.ports.Docker,
"k8s": c.ports.K8s,
},
})
reg.SetClient(id, c)
}
return c, nil
}
func (c *Client) initTunnel(cfg *config) (*Client, error) {
reg := registry.Global()
if reg == nil {
return nil, fmt.Errorf("tai registry not initialized")
}
taiID := c.host // for tunnel:// scheme, host stores the taiID
c.taiID = taiID
node, ok := reg.Get(taiID)
if !ok || node.Status != "online" {
return nil, fmt.Errorf("tai node %s not online", taiID)
}
c.ports = Ports{
GRPC: nodePort(node.Ports, "grpc", 19100),
HTTP: nodePort(node.Ports, "http", 8099),
VNC: nodePort(node.Ports, "vnc", 16080),
Docker: nodePort(node.Ports, "docker", 12375),
K8s: nodePort(node.Ports, "k8s", 16443),
}
grpcLn, err := reg.OpenLocalListener(taiID, c.ports.GRPC)
if err != nil {
return nil, fmt.Errorf("open grpc tunnel listener: %w", err)
}
c.tunnelListeners = append(c.tunnelListeners, grpcLn)
grpcAddr := grpcLn.Addr().String()
conn, err := grpc.NewClient("passthrough:///"+grpcAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
grpcLn.Close()
return nil, fmt.Errorf("grpc dial tunnel %s: %w", grpcAddr, err)
}
c.grpcConn = conn
c.he = hepb.NewHostExecClient(conn)
c.vol = volume.NewRemote(conn)
info, err := c.discoverServerInfo(conn, cfg)
if err != nil {
info = &discoveredInfo{Capabilities: map[string]bool{"docker": true}}
}
hasDocker := info.Capabilities["docker"]
hasK8s := info.Capabilities["k8s"]
hasHostExec := info.Capabilities["host_exec"]
if !hasDocker && !hasK8s && !hasHostExec {
c.closeTunnelListeners()
conn.Close()
return nil, fmt.Errorf("tai %s: no capabilities available via tunnel (docker/k8s/host_exec all false)", taiID)
}
if cfg.runtime == K8s || (!hasDocker && hasK8s) {
k8sLn, err := reg.OpenLocalListener(taiID, c.ports.K8s)
if err == nil {
c.tunnelListeners = append(c.tunnelListeners, k8sLn)
sbAddr := k8sLn.Addr().String()
sb, err := sandbox.NewK8s(sbAddr, sandbox.K8sOption{
Namespace: cfg.namespace,
KubeConfig: cfg.kubeConfig,
})
if err == nil {
c.sb = sb
c.img = sandbox.NewK8sImage()
}
}
} else if hasDocker && c.ports.Docker > 0 {
dockerLn, err := reg.OpenLocalListener(taiID, c.ports.Docker)
if err == nil {
c.tunnelListeners = append(c.tunnelListeners, dockerLn)
sbAddr := fmt.Sprintf("tcp://%s", dockerLn.Addr().String())
sb, err := sandbox.NewDocker(sbAddr)
if err == nil {
c.sb = sb
c.img = sandbox.NewDockerImage(sandbox.DockerCli(sb))
}
}
}
if c.sb != nil {
c.prx = proxy.NewTunnel(taiID, node.YaoBase)
c.vc = vnc.NewTunnel(taiID, node.YaoBase)
}
reg.SetClient(taiID, c)
return c, nil
}
func (c *Client) closeTunnelListeners() {
for _, ln := range c.tunnelListeners {
ln.Close()
}
c.tunnelListeners = nil
}
func nodePort(ports map[string]int, key string, fallback int) int {
if p, ok := ports[key]; ok && p > 0 {
return p
} }
return fallback return fallback
} }
// Close releases all resources.
func (c *Client) Close() error {
var errs []error
if c.sb != nil {
if err := c.sb.Close(); err != nil {
errs = append(errs, err)
}
}
if c.vol != nil {
if err := c.vol.Close(); err != nil {
errs = append(errs, err)
}
}
if c.grpcConn != nil {
if err := c.grpcConn.Close(); err != nil {
errs = append(errs, err)
}
}
c.closeTunnelListeners()
if c.taiID != "" {
if reg := registry.Global(); reg != nil {
reg.Unregister(c.taiID)
}
}
if len(errs) > 0 {
return fmt.Errorf("close: %v", errs)
}
return nil
}
// Volume returns the Volume IO layer. Never nil.
func (c *Client) Volume() volume.Volume { return c.vol }
// DataDir returns the host-side data directory used by the local volume.
// Empty for remote (Tai gRPC) connections — the Tai server manages paths.
func (c *Client) DataDir() string { return c.dataDir }
// Host returns the raw host parsed from the address (IP or hostname).
func (c *Client) Host() string { return c.host }
// TaiID returns the registry key for this client.
func (c *Client) TaiID() string { return c.taiID }
// Workspace returns an fs.FS-compatible filesystem for the given session.
func (c *Client) Workspace(sessionID string) workspace.FS {
return workspace.New(c.vol, sessionID)
}
// Sandbox returns the container lifecycle manager.
// Nil when the Tai server has no container runtime (host-exec-only mode).
func (c *Client) Sandbox() sandbox.Sandbox { return c.sb }
// Image returns the container image manager.
// Nil when the Tai server has no container runtime.
func (c *Client) Image() sandbox.Image { return c.img }
// Proxy returns the HTTP reverse proxy helper.
// Nil when the Tai server has no container runtime.
func (c *Client) Proxy() proxy.Proxy { return c.prx }
// VNC returns the VNC WebSocket helper.
// Nil when the Tai server has no container runtime.
func (c *Client) VNC() vnc.VNC { return c.vc }
// HostExec returns the HostExec gRPC client for executing commands on the Tai
// host machine. Returns nil in local mode (no Tai server).
func (c *Client) HostExec() hepb.HostExecClient { return c.he }
// IsLocal returns true if the client connects directly to a Docker daemon.
func (c *Client) IsLocal() bool { return c.scheme == "docker" }
func parseAddr(addr string) (scheme, host, dockerAddr string, grpcPort int, err error) {
addr = strings.TrimSpace(addr)
if addr == "" {
return "", "", "", 0, fmt.Errorf("empty address: use \"local\" for default Docker daemon")
}
if addr == "local" {
return "docker", "", "", 0, nil
}
// Bare IP or host(:port) without scheme → normalise before url.Parse,
// which misparses bare addresses (treats them as path, not host).
if !strings.Contains(addr, "://") {
if isLocalHost(addr) {
return "docker", "", "", 0, nil
}
// host:port — split carefully (IPv6 like [::1]:19100 is already handled above)
h := addr
if idx := strings.LastIndex(addr, ":"); idx > 0 {
h = addr[:idx]
}
if isLocalHost(h) {
return "docker", "", "", 0, nil
}
addr = "tai://" + addr
}
u, parseErr := url.Parse(addr)
if parseErr != nil {
return "", "", "", 0, fmt.Errorf("parse addr %q: %w", addr, parseErr)
}
switch u.Scheme {
case "tai":
hostname := u.Hostname()
if hostname == "" {
return "", "", "", 0, fmt.Errorf("tai:// requires a host")
}
if portStr := u.Port(); portStr != "" {
if p, convErr := strconv.Atoi(portStr); convErr == nil && p > 0 {
grpcPort = p
}
}
return "tai", hostname, "", grpcPort, nil
case "tunnel":
taiID := u.Host
if taiID == "" {
return "", "", "", 0, fmt.Errorf("tunnel:// requires a tai ID")
}
return "tunnel", taiID, "", 0, nil
case "docker":
return "docker", "", addr, 0, nil
case "unix":
return "docker", "", addr, 0, nil
case "tcp":
return "docker", "", addr, 0, nil
case "npipe":
return "docker", "", addr, 0, nil
default:
return "", "", "", 0, fmt.Errorf("unsupported scheme %q in addr %q", u.Scheme, addr)
}
}
func isLocalHost(h string) bool {
return h == "127.0.0.1" || h == "localhost" || h == "::1"
}
type discoveredInfo struct {
Capabilities map[string]bool
System registry.SystemInfo
Version string
}
// discoverServerInfo calls ServerInfo.GetInfo on the remote Tai server, merges
// discovered ports into c.ports, and returns capabilities + system info.
// Ports explicitly set via WithPorts take precedence over server-reported values.
func (c *Client) discoverServerInfo(conn *grpc.ClientConn, cfg *config) (*discoveredInfo, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client := sipb.NewServerInfoClient(conn)
resp, err := client.GetInfo(ctx, &sipb.GetInfoRequest{})
if err != nil {
return nil, err
}
up := cfg.userPorts
if p := int(resp.Ports["http"]); p > 0 && up.HTTP == 0 {
c.ports.HTTP = p
}
if p := int(resp.Ports["docker"]); p > 0 && up.Docker == 0 {
c.ports.Docker = p
}
if p := int(resp.Ports["vnc"]); p > 0 && up.VNC == 0 {
c.ports.VNC = p
}
if p := int(resp.Ports["k8s"]); p > 0 && up.K8s == 0 {
c.ports.K8s = p
}
caps := resp.Capabilities
if caps == nil {
caps = make(map[string]bool)
}
var sys registry.SystemInfo
if s := resp.System; s != nil {
sys = registry.SystemInfo{
OS: s.Os,
Arch: s.Arch,
Hostname: s.Hostname,
NumCPU: int(s.NumCpu),
TotalMem: s.TotalMem,
Shell: s.Shell,
TempDir: s.TempDir,
}
}
return &discoveredInfo{
Capabilities: caps,
System: sys,
Version: resp.Version,
}, nil
}
// RegisterLocal probes the local Docker environment and, if reachable, // RegisterLocal probes the local Docker environment and, if reachable,
// creates a Client and registers it as the "local" node in the registry. // registers it as the "local" node in the registry with ConnResources.
// Returns true if a local node was successfully registered. // Returns true if a local node was successfully registered.
// Silently returns false if Docker is not available — this is not an error. // Silently returns false if Docker is not available — this is not an error.
func RegisterLocal(opts ...Option) bool { func RegisterLocal(opts ...Option) bool {
@ -634,34 +82,40 @@ func RegisterLocal(opts ...Option) bool {
return true return true
} }
c, err := New("local", opts...) cfg := &config{}
for _, o := range opts {
o.apply(cfg)
}
res, err := DialLocal("", cfg.dataDir, cfg.volume)
if err != nil { if err != nil {
return false return false
} }
_ = c // registered by initLocal → reg.Register + reg.SetClient
reg.Register(&registry.TaiNode{
TaiID: "local",
Mode: "local",
})
reg.SetResources("local", res)
return true return true
} }
// GetClient returns a registered *Client by taiID from the global registry. // GetResources returns the ConnResources for a registered Tai node.
func GetClient(taiID string) (*Client, bool) { func GetResources(taiID string) (*ConnResources, bool) {
reg := registry.Global() reg := registry.Global()
if reg == nil { if reg == nil {
return nil, false return nil, false
} }
snap, ok := reg.Get(taiID) raw, ok := reg.GetResources(taiID)
if !ok { if !ok {
return nil, false return nil, false
} }
c, ok := snap.Client().(*Client) res, ok := raw.(*ConnResources)
if !ok || c == nil { return res, ok && res != nil
return nil, false
}
return c, true
} }
// GetNodeSnapshot returns the registry snapshot for a Tai node by ID. // GetNodeMeta returns the metadata for a registered Tai node by ID.
// Callers can inspect System, Capabilities, Mode and other registry-level fields. func GetNodeMeta(taiID string) (*types.NodeMeta, bool) {
func GetNodeSnapshot(taiID string) (*registry.NodeSnapshot, bool) {
reg := registry.Global() reg := registry.Global()
if reg == nil { if reg == nil {
return nil, false return nil, false

View file

@ -1,12 +1,13 @@
package tai package tai
import ( import (
"fmt"
"os" "os"
"strconv" "strconv"
"testing" "testing"
"github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/types"
"github.com/yaoapp/yao/tai/volume"
) )
func taiTestHost() string { func taiTestHost() string {
@ -16,18 +17,6 @@ func taiTestHost() string {
return "127.0.0.1" return "127.0.0.1"
} }
// taiRemoteAddr returns the tai:// address for remote tests (e.g. TestNewRemoteDocker).
// Uses TAI_TEST_HOST and, when set, TAI_TEST_GRPC_PORT so Tai on non-default port works.
func taiRemoteAddr() string {
host := taiTestHost()
if p := os.Getenv("TAI_TEST_GRPC_PORT"); p != "" {
return "tai://" + host + ":" + p
}
return "tai://" + host
}
// taiTestPorts builds a Ports struct from TAI_TEST_*_PORT env vars.
// Only non-zero fields are set so they override ServerInfo-discovered values.
func taiTestPorts() Ports { func taiTestPorts() Ports {
return Ports{ return Ports{
Docker: envPort("TAI_TEST_DOCKER_PORT", 0), Docker: envPort("TAI_TEST_DOCKER_PORT", 0),
@ -45,62 +34,6 @@ func envPort(key string, fallback int) int {
return fallback return fallback
} }
func TestParseAddr(t *testing.T) {
tests := []struct {
addr string
wantScheme string
wantHost string
wantDocker string
wantGRPCPort int
wantErr bool
}{
{"", "", "", "", 0, true},
{"local", "docker", "", "", 0, false},
{"127.0.0.1", "docker", "", "", 0, false},
{"localhost", "docker", "", "", 0, false},
{"::1", "docker", "", "", 0, false},
{"docker:///var/run/docker.sock", "docker", "", "docker:///var/run/docker.sock", 0, false},
{"docker://192.168.1.50:2375", "docker", "", "docker://192.168.1.50:2375", 0, false},
{"unix:///var/run/docker.sock", "docker", "", "unix:///var/run/docker.sock", 0, false},
{"tcp://127.0.0.1:2375", "docker", "", "tcp://127.0.0.1:2375", 0, false},
{"npipe:////./pipe/docker_engine", "docker", "", "npipe:////./pipe/docker_engine", 0, false},
{"tai://192.168.1.100", "tai", "192.168.1.100", "", 0, false},
{"tai://10.0.0.5:9200", "tai", "10.0.0.5", "", 9200, false},
{"tai://", "", "", "", 0, true},
{"ftp://host", "", "", "", 0, true},
{" tai://host ", "tai", "host", "", 0, false},
// Bare non-local host → auto-prepend tai://
{"192.168.1.50", "tai", "192.168.1.50", "", 0, false},
{"192.168.1.50:9200", "tai", "192.168.1.50", "", 9200, false},
{"my-server", "tai", "my-server", "", 0, false},
{"my-server:9200", "tai", "my-server", "", 9200, false},
}
for _, tt := range tests {
t.Run(tt.addr, func(t *testing.T) {
scheme, host, dockerAddr, grpcPort, err := parseAddr(tt.addr)
if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr)
}
if err != nil {
return
}
if scheme != tt.wantScheme {
t.Errorf("scheme = %q, want %q", scheme, tt.wantScheme)
}
if host != tt.wantHost {
t.Errorf("host = %q, want %q", host, tt.wantHost)
}
if dockerAddr != tt.wantDocker {
t.Errorf("dockerAddr = %q, want %q", dockerAddr, tt.wantDocker)
}
if grpcPort != tt.wantGRPCPort {
t.Errorf("grpcPort = %d, want %d", grpcPort, tt.wantGRPCPort)
}
})
}
}
func TestMergedPorts(t *testing.T) { func TestMergedPorts(t *testing.T) {
p := mergedPorts(Ports{HTTP: 8888}) p := mergedPorts(Ports{HTTP: 8888})
if p.HTTP != 8888 { if p.HTTP != 8888 {
@ -127,97 +60,73 @@ func TestMergedPortsAll(t *testing.T) {
} }
} }
func TestOptions(t *testing.T) { func TestDialLocalSuccess(t *testing.T) {
cfg := &config{ports: defaultPorts()} res, err := DialLocal("", t.TempDir(), nil)
WithPorts(Ports{HTTP: 9999}).apply(cfg)
if cfg.ports.HTTP != 9999 {
t.Errorf("WithPorts: HTTP = %d", cfg.ports.HTTP)
}
if cfg.userPorts.HTTP != 9999 {
t.Errorf("WithPorts: userPorts.HTTP = %d", cfg.userPorts.HTTP)
}
WithDataDir("/data").apply(cfg)
if cfg.dataDir != "/data" {
t.Errorf("WithDataDir = %q", cfg.dataDir)
}
WithHTTPClient(nil).apply(cfg)
Docker.apply(cfg)
if cfg.runtime != Docker {
t.Error("Docker option failed")
}
K8s.apply(cfg)
if cfg.runtime != K8s {
t.Error("K8s option failed")
}
}
func TestNewEmptyAddr(t *testing.T) {
_, err := New("")
if err == nil {
t.Error("expected error for empty addr")
}
}
func TestNewLocal(t *testing.T) {
c, err := New("local")
if err != nil { if err != nil {
t.Skipf("Docker not available: %v", err) t.Skipf("Docker not available: %v", err)
} }
defer c.Close() defer res.Close()
if !c.IsLocal() { if res.Volume == nil {
t.Error("expected IsLocal = true")
}
if c.Volume() == nil {
t.Error("Volume should not be nil") t.Error("Volume should not be nil")
} }
if c.Sandbox() == nil { if res.Runtime == nil {
t.Error("Sandbox should not be nil") t.Error("Runtime should not be nil")
}
if c.Proxy() == nil {
t.Error("Proxy should not be nil")
}
if c.VNC() == nil {
t.Error("VNC should not be nil")
}
// Test Workspace accessor
ws := c.Workspace("test-session")
if ws == nil {
t.Error("Workspace should not be nil")
} }
} }
func TestNewLocalWithDataDir(t *testing.T) { func TestDialLocalWithVolume(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
c, err := New("local", WithDataDir(dir)) vol := volume.NewLocal(dir)
res, err := DialLocal("", dir, vol)
if err != nil { if err != nil {
t.Skipf("Docker not available: %v", err) t.Skipf("Docker not available: %v", err)
} }
defer c.Close() defer res.Close()
if !c.IsLocal() { if res.DataDir != dir {
t.Error("expected IsLocal = true") t.Errorf("DataDir = %q, want %q", res.DataDir, dir)
}
if res.Volume == nil {
t.Error("Volume should not be nil")
} }
} }
func TestNewLocalExplicitSocket(t *testing.T) { func TestDialLocalExplicitSocket(t *testing.T) {
c, err := New("unix:///var/run/docker.sock") res, err := DialLocal("unix:///var/run/docker.sock", t.TempDir(), nil)
if err != nil { if err != nil {
t.Skipf("Docker not available: %v", err) t.Skipf("Docker not available: %v", err)
} }
defer c.Close() defer res.Close()
if !c.IsLocal() { if res.Runtime == nil {
t.Error("expected IsLocal = true for unix socket") t.Error("Runtime should not be nil for explicit unix socket")
} }
} }
func TestNewRemoteK8s(t *testing.T) { func TestDialRemoteDocker(t *testing.T) {
host := taiTestHost()
grpcPort := envPort("TAI_TEST_GRPC_PORT", 19100)
ports := taiTestPorts()
ports.GRPC = grpcPort
res, err := DialRemote(host, ports)
if err != nil {
t.Skipf("Tai not available at %s:%d: %v", host, grpcPort, err)
}
defer res.Close()
t.Logf("remote docker: host=%s ports=%+v", host, res.Ports)
if res.Volume == nil {
t.Error("Volume should not be nil")
}
if res.Runtime == nil {
t.Error("Runtime should not be nil")
}
}
func TestDialRemoteK8s(t *testing.T) {
host := os.Getenv("TAI_TEST_K8S_HOST") host := os.Getenv("TAI_TEST_K8S_HOST")
kubeconfig := os.Getenv("TAI_TEST_KUBECONFIG") kubeconfig := os.Getenv("TAI_TEST_KUBECONFIG")
if host == "" || kubeconfig == "" { if host == "" || kubeconfig == "" {
@ -232,119 +141,31 @@ func TestNewRemoteK8s(t *testing.T) {
VNC: envPort("TAI_TEST_K8S_VNC_PORT", 16080), VNC: envPort("TAI_TEST_K8S_VNC_PORT", 16080),
} }
c, err := New(fmt.Sprintf("tai://%s:%d", host, grpcPort), K8s, res, err := DialRemote(host, ports,
WithPorts(ports), WithDialRuntime(types.K8s),
WithKubeConfig(kubeconfig), WithDialKubeConfig(kubeconfig),
WithNamespace("default"), WithDialNamespace("default"),
) )
if err != nil { if err != nil {
t.Skipf("Tai K8s not available: %v", err) t.Skipf("Tai K8s not available: %v", err)
} }
defer c.Close() defer res.Close()
if c.IsLocal() { if res.Runtime == nil {
t.Error("expected IsLocal = false") t.Error("Runtime should not be nil")
}
if c.Sandbox() == nil {
t.Error("Sandbox should not be nil")
} }
} }
func TestNewRemoteK8sMissingKubeConfig(t *testing.T) { func TestDialRemoteK8sMissingKubeConfig(t *testing.T) {
_, err := New("tai://127.0.0.1", K8s) host := taiTestHost()
grpcPort := envPort("TAI_TEST_GRPC_PORT", 19100)
_, err := DialRemote(host, Ports{GRPC: grpcPort}, WithDialRuntime(types.K8s))
if err == nil { if err == nil {
t.Error("expected error for missing kubeconfig") t.Skip("Tai happened to be reachable; test only valid when gRPC is up")
} }
} }
func TestWithKubeConfigAndNamespace(t *testing.T) {
cfg := &config{ports: defaultPorts()}
WithKubeConfig("/path/to/kubeconfig").apply(cfg)
if cfg.kubeConfig != "/path/to/kubeconfig" {
t.Errorf("WithKubeConfig = %q", cfg.kubeConfig)
}
WithNamespace("test-ns").apply(cfg)
if cfg.namespace != "test-ns" {
t.Errorf("WithNamespace = %q", cfg.namespace)
}
}
func TestNewInvalidScheme(t *testing.T) {
_, err := New("ftp://host")
if err == nil {
t.Error("expected error for ftp://")
}
}
func TestNewRemoteDocker(t *testing.T) {
addr := taiRemoteAddr()
ports := taiTestPorts()
c, err := New(addr, WithPorts(ports))
if err != nil {
t.Skipf("Tai not available at %s: %v", addr, err)
}
defer c.Close()
t.Logf("remote docker: addr=%s ports=%+v", addr, c.ports)
if c.IsLocal() {
t.Error("expected IsLocal = false for tai://")
}
if c.Volume() == nil {
t.Error("Volume should not be nil")
}
if c.Sandbox() == nil {
t.Error("Sandbox should not be nil")
}
if c.Proxy() == nil {
t.Error("Proxy should not be nil")
}
if c.VNC() == nil {
t.Error("VNC should not be nil")
}
ws := c.Workspace("test")
if ws == nil {
t.Error("Workspace should not be nil")
}
}
func TestDiscoverPorts(t *testing.T) {
addr := taiRemoteAddr()
c, err := New(addr)
if err != nil {
t.Skipf("Tai not available at %s: %v", addr, err)
}
defer c.Close()
t.Logf("client resolved: GRPC=%d HTTP=%d VNC=%d Docker=%d K8s=%d",
c.ports.GRPC, c.ports.HTTP, c.ports.VNC, c.ports.Docker, c.ports.K8s)
if c.ports.GRPC == 0 {
t.Error("GRPC port should be discovered (non-zero)")
}
if c.ports.HTTP == 0 {
t.Error("HTTP port should be discovered (non-zero)")
}
}
func TestDiscoverPortsWithUserOverride(t *testing.T) {
addr := taiRemoteAddr()
c, err := New(addr, WithPorts(Ports{HTTP: 9999}))
if err != nil {
t.Skipf("Tai not available at %s: %v", addr, err)
}
defer c.Close()
if c.ports.HTTP != 9999 {
t.Errorf("HTTP = %d, want 9999 (user override should take precedence)", c.ports.HTTP)
}
if c.ports.GRPC == 0 {
t.Error("GRPC port should still be discovered (non-zero)")
}
t.Logf("ports: GRPC=%d HTTP=%d(user) VNC=%d Docker=%d",
c.ports.GRPC, c.ports.HTTP, c.ports.VNC, c.ports.Docker)
}
func TestRegisterLocal(t *testing.T) { func TestRegisterLocal(t *testing.T) {
registry.Init(nil) registry.Init(nil)
reg := registry.Global() reg := registry.Global()
@ -355,39 +176,37 @@ func TestRegisterLocal(t *testing.T) {
t.Skip("Docker not available, skipping RegisterLocal test") t.Skip("Docker not available, skipping RegisterLocal test")
} }
snap, found := reg.Get("local") meta, found := reg.Get("local")
if !found { if !found {
t.Fatal("expected 'local' node in registry after RegisterLocal") t.Fatal("expected 'local' node in registry after RegisterLocal")
} }
if snap.Mode != "local" { if meta.Mode != "local" {
t.Errorf("mode = %q, want 'local'", snap.Mode) t.Errorf("mode = %q, want 'local'", meta.Mode)
} }
if snap.Status != "online" { if meta.Status != "online" {
t.Errorf("status = %q, want 'online'", snap.Status) t.Errorf("status = %q, want 'online'", meta.Status)
} }
c, got := GetClient("local") res, got := GetResources("local")
if !got { if !got {
t.Fatal("GetClient('local') returned false after RegisterLocal") t.Fatal("GetResources('local') returned false after RegisterLocal")
} }
if c.DataDir() != dir { if res.DataDir != dir {
t.Errorf("DataDir = %q, want %q", c.DataDir(), dir) t.Errorf("DataDir = %q, want %q", res.DataDir, dir)
} }
if c.Sandbox() == nil { if res.Runtime == nil {
t.Error("local client Sandbox should not be nil") t.Error("local resources Runtime should not be nil")
} }
// Idempotent: second call should return true without error
ok2 := RegisterLocal(WithDataDir(dir)) ok2 := RegisterLocal(WithDataDir(dir))
if !ok2 { if !ok2 {
t.Error("second RegisterLocal should return true (idempotent)") t.Error("second RegisterLocal should return true (idempotent)")
} }
c.Close() res.Close()
} }
func TestRegisterLocal_NoRegistry(t *testing.T) { func TestRegisterLocal_NoRegistry(t *testing.T) {
// RegisterLocal without a registry should return false, not panic
origReg := registry.Global() origReg := registry.Global()
defer func() { defer func() {
if origReg != nil { if origReg != nil {
@ -395,26 +214,26 @@ func TestRegisterLocal_NoRegistry(t *testing.T) {
} }
}() }()
// registry.Global() returns the singleton; we can't un-init it,
// but we can verify RegisterLocal returns true (registry exists from
// other tests) or false gracefully.
ok := RegisterLocal() ok := RegisterLocal()
// Just verify it doesn't panic; result depends on Docker availability
_ = ok _ = ok
} }
func TestRegisterLocal_NoDocker(t *testing.T) { func TestRegisterLocal_NoDocker(t *testing.T) {
registry.Init(nil) registry.Init(nil)
// Use an unreachable Docker socket to ensure failure
ok := RegisterLocal(WithDataDir(t.TempDir())) ok := RegisterLocal(WithDataDir(t.TempDir()))
if !ok { if !ok {
// Expected when Docker is not available — just ensure no panic
return return
} }
// If Docker happens to be available, that's also fine res, got := GetResources("local")
c, _ := GetClient("local") if got && res != nil {
if c != nil { res.Close()
c.Close() }
}
func TestConnResourcesCloseNil(t *testing.T) {
var r *ConnResources
if err := r.Close(); err != nil {
t.Errorf("Close on nil should return nil, got %v", err)
} }
} }

View file

@ -31,7 +31,7 @@ func HandleProxy(c *gin.Context) {
return return
} }
httpPort := node.Ports["http"] httpPort := node.Ports.HTTP
if httpPort == 0 { if httpPort == 0 {
httpPort = 8099 httpPort = 8099
} }
@ -102,7 +102,7 @@ func HandleVNC(c *gin.Context) {
return return
} }
vncPort := node.Ports["vnc"] vncPort := node.Ports.VNC
if vncPort == 0 { if vncPort == 0 {
vncPort = 16080 vncPort = 16080
} }

View file

@ -16,6 +16,7 @@ import (
tai "github.com/yaoapp/yao/tai" tai "github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/taiid" "github.com/yaoapp/yao/tai/taiid"
"github.com/yaoapp/yao/tai/types"
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
@ -91,8 +92,8 @@ func HandleControl(c *gin.Context) {
Mode: "tunnel", Mode: "tunnel",
Addr: addr, Addr: addr,
YaoBase: regMsg.Server, YaoBase: regMsg.Server,
Ports: regMsg.Ports, Ports: portsFromMap(regMsg.Ports),
Capabilities: regMsg.Capabilities, Capabilities: capsFromMap(regMsg.Capabilities),
ControlConn: conn, ControlConn: conn,
} }
reg.Register(node) reg.Register(node)
@ -183,16 +184,16 @@ func HandleData(c *gin.Context) {
// registerMessage is the JSON structure for Tai's register message. // registerMessage is the JSON structure for Tai's register message.
type registerMessage struct { type registerMessage struct {
Type string `json:"type"` Type string `json:"type"`
NodeID string `json:"node_id,omitempty"` NodeID string `json:"node_id,omitempty"`
ClientID string `json:"client_id,omitempty"` ClientID string `json:"client_id,omitempty"`
MachineID string `json:"machine_id"` MachineID string `json:"machine_id"`
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"` Version string `json:"version"`
Server string `json:"server"` Server string `json:"server"`
Ports map[string]int `json:"ports"` Ports map[string]int `json:"ports"`
Capabilities map[string]bool `json:"capabilities"` Capabilities map[string]bool `json:"capabilities"`
System registry.SystemInfo `json:"system"` System types.SystemInfo `json:"system"`
} }
// controlMsg is a generic control channel message. // controlMsg is a generic control channel message.
@ -210,20 +211,20 @@ func extractBearer(r *http.Request) string {
var authenticateBearerFunc = authenticateBearerDefault var authenticateBearerFunc = authenticateBearerDefault
func authenticateBearerDefault(token string) (registry.AuthInfo, error) { func authenticateBearerDefault(token string) (types.AuthInfo, error) {
svc := oauth.OAuth svc := oauth.OAuth
if svc == nil { if svc == nil {
return registry.AuthInfo{}, fmt.Errorf("oauth service not initialized") return types.AuthInfo{}, fmt.Errorf("oauth service not initialized")
} }
result, err := svc.AuthenticateToken(oauth.AuthInput{ result, err := svc.AuthenticateToken(oauth.AuthInput{
AccessToken: token, AccessToken: token,
}) })
if err != nil { if err != nil {
return registry.AuthInfo{}, err return types.AuthInfo{}, err
} }
info := registry.AuthInfo{} info := types.AuthInfo{}
if result.Info != nil { if result.Info != nil {
info.Subject = result.Info.Subject info.Subject = result.Info.Subject
info.UserID = result.Info.UserID info.UserID = result.Info.UserID
@ -324,14 +325,33 @@ func (c *wsConn) SetDeadline(t time.Time) error {
func (c *wsConn) SetReadDeadline(t time.Time) error { return c.ws.SetReadDeadline(t) } func (c *wsConn) SetReadDeadline(t time.Time) error { return c.ws.SetReadDeadline(t) }
func (c *wsConn) SetWriteDeadline(t time.Time) error { return c.ws.SetWriteDeadline(t) } func (c *wsConn) SetWriteDeadline(t time.Time) error { return c.ws.SetWriteDeadline(t) }
// connectTunnelNode creates a tai.Client through the tunnel and binds it to the taiID. func portsFromMap(m map[string]int) types.Ports {
return types.Ports{
GRPC: m["grpc"],
HTTP: m["http"],
VNC: m["vnc"],
Docker: m["docker"],
K8s: m["k8s"],
}
}
func capsFromMap(m map[string]bool) types.Capabilities {
return types.Capabilities{
Docker: m["docker"],
K8s: m["k8s"],
HostExec: m["host_exec"],
}
}
// connectTunnelNode dials the Tai node through the WS tunnel and binds
// the returned ConnResources to the taiID in the registry.
func connectTunnelNode(taiID string, reg *registry.Registry, logger *slog.Logger) { func connectTunnelNode(taiID string, reg *registry.Registry, logger *slog.Logger) {
client, err := tai.New("tunnel://" + taiID) res, err := tai.DialTunnel(taiID, reg)
if err != nil { if err != nil {
logger.Warn("failed to connect tunnel node", logger.Warn("failed to connect tunnel node",
"tai_id", taiID, "err", err) "tai_id", taiID, "err", err)
return return
} }
_ = client // initTunnel already calls reg.SetClient(taiID, c) reg.SetResources(taiID, res)
logger.Info("tai client created for tunnel node", "tai_id", taiID) logger.Info("tunnel node connected", "tai_id", taiID)
} }

View file

@ -14,6 +14,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/types"
) )
func init() { func init() {
@ -26,9 +27,9 @@ func setupTestRegistry() *registry.Registry {
return r return r
} }
func mockAuth(info registry.AuthInfo, authErr error) func() { func mockAuth(info types.AuthInfo, authErr error) func() {
old := authenticateBearerFunc old := authenticateBearerFunc
authenticateBearerFunc = func(token string) (registry.AuthInfo, error) { authenticateBearerFunc = func(token string) (types.AuthInfo, error) {
return info, authErr return info, authErr
} }
return func() { authenticateBearerFunc = old } return func() { authenticateBearerFunc = old }
@ -200,7 +201,7 @@ func TestHandleControl_NoRegistry(t *testing.T) {
registry.SetGlobalForTest(nil) registry.SetGlobalForTest(nil)
defer setupTestRegistry() defer setupTestRegistry()
restore := mockAuth(registry.AuthInfo{ClientID: "tai-001"}, nil) restore := mockAuth(types.AuthInfo{ClientID: "tai-001"}, nil)
defer restore() defer restore()
srv := httptest.NewServer(newGinRouter()) srv := httptest.NewServer(newGinRouter())
@ -236,7 +237,7 @@ func TestHandleControl_NoAuth(t *testing.T) {
func TestHandleControl_AuthFailed(t *testing.T) { func TestHandleControl_AuthFailed(t *testing.T) {
setupTestRegistry() setupTestRegistry()
restore := mockAuth(registry.AuthInfo{}, fmt.Errorf("bad token")) restore := mockAuth(types.AuthInfo{}, fmt.Errorf("bad token"))
defer restore() defer restore()
srv := httptest.NewServer(newGinRouter()) srv := httptest.NewServer(newGinRouter())
@ -256,7 +257,7 @@ func TestHandleControl_AuthFailed(t *testing.T) {
func TestHandleControl_RegisterAndPing(t *testing.T) { func TestHandleControl_RegisterAndPing(t *testing.T) {
reg := setupTestRegistry() reg := setupTestRegistry()
restore := mockAuth(registry.AuthInfo{ restore := mockAuth(types.AuthInfo{
ClientID: "tai-001", ClientID: "tai-001",
Subject: "user-test", Subject: "user-test",
Scope: "tai:tunnel", Scope: "tai:tunnel",
@ -324,8 +325,8 @@ func TestHandleControl_RegisterAndPing(t *testing.T) {
if snap.Auth.Subject != "user-test" { if snap.Auth.Subject != "user-test" {
t.Errorf("Auth.Subject = %q, want user-test", snap.Auth.Subject) t.Errorf("Auth.Subject = %q, want user-test", snap.Auth.Subject)
} }
if snap.Ports["grpc"] != 9100 { if snap.Ports.GRPC != 9100 {
t.Errorf("Ports[grpc] = %d, want 9100", snap.Ports["grpc"]) t.Errorf("Ports.GRPC = %d, want 9100", snap.Ports.GRPC)
} }
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
@ -366,7 +367,7 @@ func TestHandleControl_RegisterAndPing(t *testing.T) {
func TestHandleControl_BadRegisterType(t *testing.T) { func TestHandleControl_BadRegisterType(t *testing.T) {
setupTestRegistry() setupTestRegistry()
restore := mockAuth(registry.AuthInfo{ClientID: "tai-001"}, nil) restore := mockAuth(types.AuthInfo{ClientID: "tai-001"}, nil)
defer restore() defer restore()
srv := httptest.NewServer(newGinRouter()) srv := httptest.NewServer(newGinRouter())
@ -390,7 +391,7 @@ func TestHandleControl_BadRegisterType(t *testing.T) {
func TestHandleControl_MissingTaiID(t *testing.T) { func TestHandleControl_MissingTaiID(t *testing.T) {
setupTestRegistry() setupTestRegistry()
restore := mockAuth(registry.AuthInfo{ClientID: "tai-001"}, nil) restore := mockAuth(types.AuthInfo{ClientID: "tai-001"}, nil)
defer restore() defer restore()
srv := httptest.NewServer(newGinRouter()) srv := httptest.NewServer(newGinRouter())
@ -432,7 +433,7 @@ func TestHandleData_NoAuth(t *testing.T) {
func TestHandleData_AcceptSuccess(t *testing.T) { func TestHandleData_AcceptSuccess(t *testing.T) {
reg := setupTestRegistry() reg := setupTestRegistry()
restore := mockAuth(registry.AuthInfo{ClientID: "tai-001"}, nil) restore := mockAuth(types.AuthInfo{ClientID: "tai-001"}, nil)
defer restore() defer restore()
resultCh := make(chan net.Conn, 1) resultCh := make(chan net.Conn, 1)
@ -468,7 +469,7 @@ func TestHandleData_AcceptSuccess(t *testing.T) {
func TestHandleData_ChannelNotPending(t *testing.T) { func TestHandleData_ChannelNotPending(t *testing.T) {
setupTestRegistry() setupTestRegistry()
restore := mockAuth(registry.AuthInfo{ClientID: "tai-001"}, nil) restore := mockAuth(types.AuthInfo{ClientID: "tai-001"}, nil)
defer restore() defer restore()
srv := httptest.NewServer(newGinRouter()) srv := httptest.NewServer(newGinRouter())
@ -491,7 +492,7 @@ func TestHandleData_ChannelNotPending(t *testing.T) {
func TestHandleData_TaiIDMismatch(t *testing.T) { func TestHandleData_TaiIDMismatch(t *testing.T) {
reg := setupTestRegistry() reg := setupTestRegistry()
restore := mockAuth(registry.AuthInfo{ClientID: "tai-intruder"}, nil) restore := mockAuth(types.AuthInfo{ClientID: "tai-intruder"}, nil)
defer restore() defer restore()
resultCh := make(chan net.Conn, 1) resultCh := make(chan net.Conn, 1)
@ -520,7 +521,7 @@ func TestHandleData_TaiIDMismatch(t *testing.T) {
func TestHandleControl_OpenChannelAndBridge(t *testing.T) { func TestHandleControl_OpenChannelAndBridge(t *testing.T) {
reg := setupTestRegistry() reg := setupTestRegistry()
restore := mockAuth(registry.AuthInfo{ restore := mockAuth(types.AuthInfo{
ClientID: "tai-001", ClientID: "tai-001",
Subject: "user-test", Subject: "user-test",
}, nil) }, nil)

67
tai/types/types.go Normal file
View file

@ -0,0 +1,67 @@
package types
import "time"
// Runtime selects which container runtime to use via Tai.
type Runtime int
const (
Docker Runtime = iota
K8s
)
// Ports configures service ports for Tai server.
type Ports struct {
GRPC int `json:"grpc"`
HTTP int `json:"http"`
VNC int `json:"vnc"`
Docker int `json:"docker"`
K8s int `json:"k8s"`
}
// Capabilities describes what features a Tai node supports.
type Capabilities struct {
Docker bool `json:"docker"`
K8s bool `json:"k8s"`
HostExec bool `json:"host_exec"`
}
// SystemInfo describes the host machine running Tai.
type SystemInfo struct {
OS string `json:"os"`
Arch string `json:"arch"`
Hostname string `json:"hostname"`
NumCPU int `json:"num_cpu"`
TotalMem int64 `json:"total_mem,omitempty"`
Shell string `json:"shell,omitempty"`
TempDir string `json:"temp_dir,omitempty"`
}
// AuthInfo holds Yao user authorization extracted from OAuth token.
type AuthInfo struct {
Subject string
UserID string
ClientID string
Scope string
TeamID string
TenantID string
}
// NodeMeta is the read-only metadata snapshot of a registered Tai node.
// Carries no runtime resource references.
type NodeMeta struct {
TaiID string
MachineID string
Version string
Auth AuthInfo
System SystemInfo
Mode string // "direct" | "tunnel" | "local"
Addr string
YaoBase string
Ports Ports
Capabilities Capabilities
Status string // "online" | "offline" | "connecting"
ConnectedAt time.Time
LastPing time.Time
DisplayName string
}

View file

@ -6,7 +6,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/yaoapp/yao/tai/sandbox" "github.com/yaoapp/yao/tai/runtime"
) )
const defaultVNCContainerPort = 6080 const defaultVNCContainerPort = 6080
@ -78,11 +78,11 @@ func (t *tunnelVNC) Ping(_ context.Context, _ string) error {
// --- Local implementation --- // --- Local implementation ---
type localVNC struct { type localVNC struct {
sb sandbox.Sandbox sb runtime.Runtime
} }
// NewLocal creates a VNC that resolves host VNC ports via sandbox.Inspect. // NewLocal creates a VNC that resolves host VNC ports via runtime.Inspect.
func NewLocal(sb sandbox.Sandbox) VNC { func NewLocal(sb runtime.Runtime) VNC {
return &localVNC{sb: sb} return &localVNC{sb: sb}
} }

View file

@ -8,7 +8,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/yaoapp/yao/tai/sandbox" "github.com/yaoapp/yao/tai/runtime"
) )
func TestRemoteURL(t *testing.T) { func TestRemoteURL(t *testing.T) {
@ -63,10 +63,10 @@ func TestRemotePingError(t *testing.T) {
func TestLocalURL(t *testing.T) { func TestLocalURL(t *testing.T) {
mock := &mockSandbox{ mock := &mockSandbox{
inspectFn: func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) { inspectFn: func(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
return &sandbox.ContainerInfo{ return &runtime.ContainerInfo{
ID: id, ID: id,
Ports: []sandbox.PortMapping{ Ports: []runtime.PortMapping{
{ContainerPort: 6080, HostPort: 49152, HostIP: "127.0.0.1", Protocol: "tcp"}, {ContainerPort: 6080, HostPort: 49152, HostIP: "127.0.0.1", Protocol: "tcp"},
}, },
}, nil }, nil
@ -86,10 +86,10 @@ func TestLocalURL(t *testing.T) {
func TestLocalURLEmptyHostIP(t *testing.T) { func TestLocalURLEmptyHostIP(t *testing.T) {
mock := &mockSandbox{ mock := &mockSandbox{
inspectFn: func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) { inspectFn: func(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
return &sandbox.ContainerInfo{ return &runtime.ContainerInfo{
ID: id, ID: id,
Ports: []sandbox.PortMapping{ Ports: []runtime.PortMapping{
{ContainerPort: 6080, HostPort: 49152, HostIP: "", Protocol: "tcp"}, {ContainerPort: 6080, HostPort: 49152, HostIP: "", Protocol: "tcp"},
}, },
}, nil }, nil
@ -109,8 +109,8 @@ func TestLocalURLEmptyHostIP(t *testing.T) {
func TestLocalURLPortNotFound(t *testing.T) { func TestLocalURLPortNotFound(t *testing.T) {
mock := &mockSandbox{ mock := &mockSandbox{
inspectFn: func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) { inspectFn: func(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
return &sandbox.ContainerInfo{ID: id}, nil return &runtime.ContainerInfo{ID: id}, nil
}, },
} }
@ -123,7 +123,7 @@ func TestLocalURLPortNotFound(t *testing.T) {
func TestLocalURLInspectError(t *testing.T) { func TestLocalURLInspectError(t *testing.T) {
mock := &mockSandbox{ mock := &mockSandbox{
inspectFn: func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) { inspectFn: func(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
return nil, fmt.Errorf("not found") return nil, fmt.Errorf("not found")
}, },
} }
@ -157,10 +157,10 @@ func TestLocalPingSuccess(t *testing.T) {
} }
mock := &mockSandbox{ mock := &mockSandbox{
inspectFn: func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) { inspectFn: func(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
return &sandbox.ContainerInfo{ return &runtime.ContainerInfo{
ID: id, ID: id,
Ports: []sandbox.PortMapping{ Ports: []runtime.PortMapping{
{ContainerPort: 6080, HostPort: port, HostIP: "127.0.0.1", Protocol: "tcp"}, {ContainerPort: 6080, HostPort: port, HostIP: "127.0.0.1", Protocol: "tcp"},
}, },
}, nil }, nil
@ -175,7 +175,7 @@ func TestLocalPingSuccess(t *testing.T) {
func TestLocalPingError(t *testing.T) { func TestLocalPingError(t *testing.T) {
mock := &mockSandbox{ mock := &mockSandbox{
inspectFn: func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) { inspectFn: func(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
return nil, fmt.Errorf("not found") return nil, fmt.Errorf("not found")
}, },
} }
@ -186,12 +186,12 @@ func TestLocalPingError(t *testing.T) {
} }
} }
// mockSandbox implements sandbox.Sandbox for testing. // mockSandbox implements runtime.Sandbox for testing.
type mockSandbox struct { type mockSandbox struct {
inspectFn func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) inspectFn func(ctx context.Context, id string) (*runtime.ContainerInfo, error)
} }
func (m *mockSandbox) Create(ctx context.Context, opts sandbox.CreateOptions) (string, error) { func (m *mockSandbox) Create(ctx context.Context, opts runtime.CreateOptions) (string, error) {
return "", nil return "", nil
} }
func (m *mockSandbox) Start(ctx context.Context, id string) error { return nil } func (m *mockSandbox) Start(ctx context.Context, id string) error { return nil }
@ -199,19 +199,19 @@ func (m *mockSandbox) Stop(ctx context.Context, id string, timeout time.Duration
return nil return nil
} }
func (m *mockSandbox) Remove(ctx context.Context, id string, force bool) error { return nil } func (m *mockSandbox) Remove(ctx context.Context, id string, force bool) error { return nil }
func (m *mockSandbox) Exec(ctx context.Context, id string, cmd []string, opts sandbox.ExecOptions) (*sandbox.ExecResult, error) { func (m *mockSandbox) Exec(ctx context.Context, id string, cmd []string, opts runtime.ExecOptions) (*runtime.ExecResult, error) {
return nil, nil return nil, nil
} }
func (m *mockSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts sandbox.ExecOptions) (*sandbox.StreamHandle, error) { func (m *mockSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts runtime.ExecOptions) (*runtime.StreamHandle, error) {
return nil, nil return nil, nil
} }
func (m *mockSandbox) Inspect(ctx context.Context, id string) (*sandbox.ContainerInfo, error) { func (m *mockSandbox) Inspect(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
if m.inspectFn != nil { if m.inspectFn != nil {
return m.inspectFn(ctx, id) return m.inspectFn(ctx, id)
} }
return &sandbox.ContainerInfo{ID: id}, nil return &runtime.ContainerInfo{ID: id}, nil
} }
func (m *mockSandbox) List(ctx context.Context, opts sandbox.ListOptions) ([]sandbox.ContainerInfo, error) { func (m *mockSandbox) List(ctx context.Context, opts runtime.ListOptions) ([]runtime.ContainerInfo, error) {
return nil, nil return nil, nil
} }
func (m *mockSandbox) Close() error { return nil } func (m *mockSandbox) Close() error { return nil }

View file

@ -2,6 +2,7 @@ package jsapi_test
import ( import (
"os" "os"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -34,19 +35,48 @@ func setupForMode(t *testing.T, m testMode) {
test.Prepare(t, config.Conf) test.Prepare(t, config.Conf)
registry.Init(nil) registry.Init(nil)
var client *tai.Client
var err error
if m.Addr == "local" { if m.Addr == "local" {
dataDir := t.TempDir() dataDir := t.TempDir()
vol := volume.NewLocal(dataDir) vol := volume.NewLocal(dataDir)
client, err = tai.New("local", tai.WithVolume(vol), tai.WithDataDir(dataDir)) res, err := tai.DialLocal("", dataDir, vol)
if err != nil {
t.Fatalf("DialLocal: %v", err)
}
reg := registry.Global()
reg.Register(&registry.TaiNode{TaiID: "local", Mode: "local"})
reg.SetResources("local", res)
t.Cleanup(func() { res.Close() })
} else { } else {
client, err = tai.New(m.Addr) host, grpcPort := parseHostPort(m.Addr)
ports := tai.Ports{GRPC: grpcPort}
res, err := tai.DialRemote(host, ports)
if err != nil {
t.Fatalf("DialRemote(%s): %v", m.Addr, err)
}
taiID := taiIDFromAddr(m.Addr)
reg := registry.Global()
reg.Register(&registry.TaiNode{TaiID: taiID, Mode: "direct"})
reg.SetResources(taiID, res)
t.Cleanup(func() { res.Close() })
} }
if err != nil { }
t.Fatalf("tai.New(%s): %v", m.Addr, err)
func taiIDFromAddr(addr string) string {
addr = strings.TrimPrefix(addr, "tai://")
parts := strings.SplitN(addr, ":", 2)
return parts[0]
}
func parseHostPort(addr string) (string, int) {
addr = strings.TrimPrefix(addr, "tai://")
parts := strings.SplitN(addr, ":", 2)
h := parts[0]
if len(parts) == 2 {
if p, err := strconv.Atoi(parts[1]); err == nil {
return h, p
}
} }
t.Cleanup(func() { client.Close() }) return h, 19100
} }
func setupGlobal(t *testing.T) { func setupGlobal(t *testing.T) {

View file

@ -8,6 +8,7 @@ import (
"github.com/yaoapp/yao/tai" "github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/registry"
taitypes "github.com/yaoapp/yao/tai/types"
"github.com/yaoapp/yao/tai/volume" "github.com/yaoapp/yao/tai/volume"
taiworkspace "github.com/yaoapp/yao/tai/workspace" taiworkspace "github.com/yaoapp/yao/tai/workspace"
) )
@ -20,7 +21,6 @@ func M() *Manager {
} }
// Manager owns workspace CRUD, file I/O, and node management. // Manager owns workspace CRUD, file I/O, and node management.
// All node/client lookups go through tai.GetClient → registry.
type Manager struct{} type Manager struct{}
// NewManager creates a workspace manager. // NewManager creates a workspace manager.
@ -34,7 +34,7 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, e
return nil, ErrNodeMissing return nil, ErrNodeMissing
} }
client, ok := tai.GetClient(opts.Node) res, ok := tai.GetResources(opts.Node)
if !ok { if !ok {
return nil, ErrNodeOffline return nil, ErrNodeOffline
} }
@ -55,8 +55,7 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, e
UpdatedAt: now, UpdatedAt: now,
} }
vol := client.Volume() vol := res.Volume
if err := vol.MkdirAll(ctx, id, "."); err != nil { if err := vol.MkdirAll(ctx, id, "."); err != nil {
return nil, fmt.Errorf("workspace: create directory: %w", err) return nil, fmt.Errorf("workspace: create directory: %w", err)
} }
@ -73,14 +72,13 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, e
} }
// Get returns a workspace by ID. // Get returns a workspace by ID.
// Scans all registered nodes.
func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error) { func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error) {
for _, snap := range listNodes() { for _, snap := range listNodes() {
client, ok := tai.GetClient(snap.TaiID) res, ok := tai.GetResources(snap.TaiID)
if !ok { if !ok {
continue continue
} }
ws, err := readMeta(ctx, client, id) ws, err := readMeta(ctx, res.Volume, id)
if err != nil { if err != nil {
continue continue
} }
@ -99,11 +97,11 @@ func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, err
if opts.Node != "" && snap.TaiID != opts.Node { if opts.Node != "" && snap.TaiID != opts.Node {
continue continue
} }
client, ok := tai.GetClient(snap.TaiID) res, ok := tai.GetResources(snap.TaiID)
if !ok { if !ok {
continue continue
} }
entries, err := client.Volume().ListDir(ctx, "", ".") entries, err := res.Volume.ListDir(ctx, "", ".")
if err != nil { if err != nil {
continue continue
} }
@ -111,7 +109,7 @@ func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, err
if !e.IsDir { if !e.IsDir {
continue continue
} }
ws, err := readMeta(ctx, client, e.Path) ws, err := readMeta(ctx, res.Volume, e.Path)
if err != nil { if err != nil {
continue continue
} }
@ -128,9 +126,8 @@ func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, err
} }
// Update modifies workspace metadata (Name, Labels). // Update modifies workspace metadata (Name, Labels).
// Node and Owner are immutable after creation.
func (m *Manager) Update(ctx context.Context, id string, opts UpdateOptions) (*Workspace, error) { func (m *Manager) Update(ctx context.Context, id string, opts UpdateOptions) (*Workspace, error) {
ws, client, err := m.resolve(ctx, id) ws, vol, err := m.resolve(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -147,7 +144,7 @@ func (m *Manager) Update(ctx context.Context, id string, opts UpdateOptions) (*W
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := client.Volume().WriteFile(ctx, id, metadataFile, data, 0644); err != nil { if err := vol.WriteFile(ctx, id, metadataFile, data, 0644); err != nil {
return nil, fmt.Errorf("workspace: write metadata: %w", err) return nil, fmt.Errorf("workspace: write metadata: %w", err)
} }
return ws, nil return ws, nil
@ -155,12 +152,10 @@ func (m *Manager) Update(ctx context.Context, id string, opts UpdateOptions) (*W
// Delete removes workspace storage from the node. // Delete removes workspace storage from the node.
func (m *Manager) Delete(ctx context.Context, id string, force bool) error { func (m *Manager) Delete(ctx context.Context, id string, force bool) error {
_, client, err := m.resolve(ctx, id) _, vol, err := m.resolve(ctx, id)
if err != nil { if err != nil {
return err return err
} }
vol := client.Volume()
if err := vol.Remove(ctx, id, ".", true); err != nil { if err := vol.Remove(ctx, id, ".", true); err != nil {
return fmt.Errorf("workspace: remove: %w", err) return fmt.Errorf("workspace: remove: %w", err)
} }
@ -182,39 +177,39 @@ func (m *Manager) Nodes() []NodeInfo {
// FS returns an fs.FS-compatible filesystem for the given workspace. // FS returns an fs.FS-compatible filesystem for the given workspace.
func (m *Manager) FS(ctx context.Context, id string) (taiworkspace.FS, error) { func (m *Manager) FS(ctx context.Context, id string) (taiworkspace.FS, error) {
_, client, err := m.resolve(ctx, id) _, vol, err := m.resolve(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return client.Workspace(id), nil return taiworkspace.New(vol, id), nil
} }
// ReadFile reads a file from the workspace. // ReadFile reads a file from the workspace.
func (m *Manager) ReadFile(ctx context.Context, id string, path string) ([]byte, error) { func (m *Manager) ReadFile(ctx context.Context, id string, path string) ([]byte, error) {
_, client, err := m.resolve(ctx, id) _, vol, err := m.resolve(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
data, _, err := client.Volume().ReadFile(ctx, id, path) data, _, err := vol.ReadFile(ctx, id, path)
return data, err return data, err
} }
// WriteFile writes a file to the workspace. // WriteFile writes a file to the workspace.
func (m *Manager) WriteFile(ctx context.Context, id string, path string, data []byte, perm os.FileMode) error { func (m *Manager) WriteFile(ctx context.Context, id string, path string, data []byte, perm os.FileMode) error {
_, client, err := m.resolve(ctx, id) _, vol, err := m.resolve(ctx, id)
if err != nil { if err != nil {
return err return err
} }
return client.Volume().WriteFile(ctx, id, path, data, perm) return vol.WriteFile(ctx, id, path, data, perm)
} }
// ListDir lists entries in a workspace directory. // ListDir lists entries in a workspace directory.
func (m *Manager) ListDir(ctx context.Context, id string, path string) ([]DirEntry, error) { func (m *Manager) ListDir(ctx context.Context, id string, path string) ([]DirEntry, error) {
_, client, err := m.resolve(ctx, id) _, vol, err := m.resolve(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
entries, err := client.Volume().ListDir(ctx, id, path) entries, err := vol.ListDir(ctx, id, path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -231,42 +226,41 @@ func (m *Manager) ListDir(ctx context.Context, id string, path string) ([]DirEnt
// Remove deletes a file or directory from the workspace. // Remove deletes a file or directory from the workspace.
func (m *Manager) Remove(ctx context.Context, id string, path string) error { func (m *Manager) Remove(ctx context.Context, id string, path string) error {
_, client, err := m.resolve(ctx, id) _, vol, err := m.resolve(ctx, id)
if err != nil { if err != nil {
return err return err
} }
return client.Volume().Remove(ctx, id, path, true) return vol.Remove(ctx, id, path, true)
} }
// Rename renames a file or directory within the workspace. // Rename renames a file or directory within the workspace.
func (m *Manager) Rename(ctx context.Context, id string, oldPath, newPath string) error { func (m *Manager) Rename(ctx context.Context, id string, oldPath, newPath string) error {
_, client, err := m.resolve(ctx, id) _, vol, err := m.resolve(ctx, id)
if err != nil { if err != nil {
return err return err
} }
return client.Volume().Rename(ctx, id, oldPath, newPath) return vol.Rename(ctx, id, oldPath, newPath)
} }
// MkdirAll creates a directory (and parents) in the workspace. // MkdirAll creates a directory (and parents) in the workspace.
func (m *Manager) MkdirAll(ctx context.Context, id string, path string) error { func (m *Manager) MkdirAll(ctx context.Context, id string, path string) error {
_, client, err := m.resolve(ctx, id) _, vol, err := m.resolve(ctx, id)
if err != nil { if err != nil {
return err return err
} }
return client.Volume().MkdirAll(ctx, id, path) return vol.MkdirAll(ctx, id, path)
} }
// Volume returns the Volume interface for the node hosting the given workspace. // Volume returns the Volume interface for the node hosting the given workspace.
func (m *Manager) Volume(ctx context.Context, id string) (volume.Volume, string, error) { func (m *Manager) Volume(ctx context.Context, id string) (volume.Volume, string, error) {
_, client, err := m.resolve(ctx, id) _, vol, err := m.resolve(ctx, id)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
return client.Volume(), id, nil return vol, id, nil
} }
// NodeForWorkspace returns the node name for a given workspace ID. // NodeForWorkspace returns the node name for a given workspace ID.
// Used by sandbox.Manager to route container creation to the correct pool.
func (m *Manager) NodeForWorkspace(ctx context.Context, id string) (string, error) { func (m *Manager) NodeForWorkspace(ctx context.Context, id string) (string, error) {
ws, _, err := m.resolve(ctx, id) ws, _, err := m.resolve(ctx, id)
if err != nil { if err != nil {
@ -275,47 +269,55 @@ func (m *Manager) NodeForWorkspace(ctx context.Context, id string) (string, erro
return ws.Node, nil return ws.Node, nil
} }
// MountPath returns the host-side directory path for a workspace, // MountPath returns the host-side directory path for a workspace.
// suitable for use as a Docker bind mount source.
func (m *Manager) MountPath(ctx context.Context, id string) (string, error) { func (m *Manager) MountPath(ctx context.Context, id string) (string, error) {
_, client, err := m.resolve(ctx, id) _, vol, err := m.resolve(ctx, id)
if err != nil { if err != nil {
return "", err return "", err
} }
dataDir := client.DataDir() _ = vol
if dataDir == "" { for _, snap := range listNodes() {
return "", nil res, ok := tai.GetResources(snap.TaiID)
if !ok {
continue
}
if res.Volume == vol {
if res.DataDir == "" {
return "", nil
}
return res.DataDir + "/" + id, nil
}
} }
return dataDir + "/" + id, nil return "", nil
} }
// --- internal --- // --- internal ---
// resolve finds the workspace and its tai.Client by scanning all registered nodes. // resolve finds the workspace and its Volume by scanning all registered nodes.
func (m *Manager) resolve(ctx context.Context, id string) (*Workspace, *tai.Client, error) { func (m *Manager) resolve(ctx context.Context, id string) (*Workspace, volume.Volume, error) {
for _, snap := range listNodes() { for _, snap := range listNodes() {
client, ok := tai.GetClient(snap.TaiID) res, ok := tai.GetResources(snap.TaiID)
if !ok { if !ok {
continue continue
} }
ws, err := readMeta(ctx, client, id) ws, err := readMeta(ctx, res.Volume, id)
if err != nil { if err != nil {
continue continue
} }
return ws, client, nil return ws, res.Volume, nil
} }
return nil, nil, ErrNotFound return nil, nil, ErrNotFound
} }
func readMeta(ctx context.Context, client *tai.Client, id string) (*Workspace, error) { func readMeta(ctx context.Context, vol volume.Volume, id string) (*Workspace, error) {
data, _, err := client.Volume().ReadFile(ctx, id, metadataFile) data, _, err := vol.ReadFile(ctx, id, metadataFile)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return unmarshalMeta(data) return unmarshalMeta(data)
} }
func listNodes() []registry.NodeSnapshot { func listNodes() []taitypes.NodeMeta {
reg := registry.Global() reg := registry.Global()
if reg == nil { if reg == nil {
return nil return nil

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"net/url" "net/url"
"os" "os"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -60,32 +61,52 @@ func ensureRegistry(tb testing.TB) {
func setupManagerForPool(tb testing.TB, pc poolConfig) *workspace.Manager { func setupManagerForPool(tb testing.TB, pc poolConfig) *workspace.Manager {
tb.Helper() tb.Helper()
ensureRegistry(tb) ensureRegistry(tb)
registerClient(tb, pc) registerForTest(tb, pc)
return workspace.NewManager() return workspace.NewManager()
} }
func registerClient(tb testing.TB, pc poolConfig) *tai.Client { func registerForTest(tb testing.TB, pc poolConfig) {
tb.Helper() tb.Helper()
if pc.Addr == "local" { if pc.Addr == "local" {
return localClient(tb, tb.TempDir()) registerLocalForTest(tb, tb.TempDir())
return
} }
client, err := tai.New(pc.Addr) host, grpcPort := parseHostPort(pc.Addr)
ports := tai.Ports{GRPC: grpcPort}
res, err := tai.DialRemote(host, ports)
if err != nil { if err != nil {
tb.Fatalf("tai.New(%s): %v", pc.Addr, err) tb.Fatalf("DialRemote(%s): %v", pc.Addr, err)
} }
tb.Cleanup(func() { client.Close() }) taiID := taiIDFromAddr(pc.Addr)
return client reg := registry.Global()
reg.Register(&registry.TaiNode{TaiID: taiID, Mode: "direct"})
reg.SetResources(taiID, res)
tb.Cleanup(func() { res.Close() })
} }
func localClient(tb testing.TB, dataDir string) *tai.Client { func registerLocalForTest(tb testing.TB, dataDir string) {
tb.Helper() tb.Helper()
vol := volume.NewLocal(dataDir) vol := volume.NewLocal(dataDir)
client, err := tai.New("local", tai.WithVolume(vol), tai.WithDataDir(dataDir)) res, err := tai.DialLocal("", dataDir, vol)
if err != nil { if err != nil {
tb.Fatalf("tai.New local: %v", err) tb.Fatalf("DialLocal: %v", err)
} }
tb.Cleanup(func() { client.Close() }) reg := registry.Global()
return client reg.Register(&registry.TaiNode{TaiID: "local", Mode: "local"})
reg.SetResources("local", res)
tb.Cleanup(func() { res.Close() })
}
func parseHostPort(addr string) (string, int) {
addr = strings.TrimPrefix(addr, "tai://")
parts := strings.SplitN(addr, ":", 2)
h := parts[0]
if len(parts) == 2 {
if p, err := strconv.Atoi(parts[1]); err == nil {
return h, p
}
}
return h, 19100
} }
func setupManagerMultiNode(t *testing.T) (*workspace.Manager, string, string) { func setupManagerMultiNode(t *testing.T) (*workspace.Manager, string, string) {
@ -94,19 +115,26 @@ func setupManagerMultiNode(t *testing.T) (*workspace.Manager, string, string) {
dir1 := t.TempDir() dir1 := t.TempDir()
vol1 := volume.NewLocal(dir1) vol1 := volume.NewLocal(dir1)
_, err := tai.New("docker://node-a", tai.WithVolume(vol1), tai.WithDataDir(dir1)) res1, err := tai.DialLocal("", dir1, vol1)
if err != nil { if err != nil {
t.Fatalf("tai.New node-a: %v", err) t.Fatalf("DialLocal node-a: %v", err)
} }
reg := registry.Global()
reg.Register(&registry.TaiNode{TaiID: "node-a", Mode: "local"})
reg.SetResources("node-a", res1)
t.Cleanup(func() { res1.Close() })
dir2 := t.TempDir() dir2 := t.TempDir()
vol2 := volume.NewLocal(dir2) vol2 := volume.NewLocal(dir2)
_, err = tai.New("docker://node-b", tai.WithVolume(vol2), tai.WithDataDir(dir2)) res2, err := tai.DialLocal("", dir2, vol2)
if err != nil { if err != nil {
t.Fatalf("tai.New node-b: %v", err) t.Fatalf("DialLocal node-b: %v", err)
} }
reg.Register(&registry.TaiNode{TaiID: "node-b", Mode: "local"})
reg.SetResources("node-b", res2)
t.Cleanup(func() { res2.Close() })
return workspace.NewManager(), "docker://node-a", "docker://node-b" return workspace.NewManager(), "node-a", "node-b"
} }
func createWorkspace(tb testing.TB, m *workspace.Manager, node string, opts ...func(*workspace.CreateOptions)) *workspace.Workspace { func createWorkspace(tb testing.TB, m *workspace.Manager, node string, opts ...func(*workspace.CreateOptions)) *workspace.Workspace {