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

3
.gitignore vendored
View file

@ -75,4 +75,5 @@ tg-send
registry/data/
registry/manager/DESIGN*.md
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"
"github.com/yaoapp/yao/tai"
"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"
)
// ---------------------------------------------------------------------------
// 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 {
Name string
Addr string
TaiID string
Options []tai.Option
DialOps []tai.DialOption
}
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 {
@ -62,30 +70,84 @@ func envPort(key string, fallback int) int {
}
// ---------------------------------------------------------------------------
// node discovery
// Node / host discovery
// ---------------------------------------------------------------------------
func boxNodes() []nodeConfig {
nodes := []nodeConfig{
{Name: "local", Addr: testLocalAddr()},
}
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
nodes = append(nodes, nodeConfig{Name: "remote", Addr: addr})
for _, fn := range extraNodeProviders {
nodes = append(nodes, fn()...)
}
return nodes
}
func hostTargets() []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})
for _, fn := range extraHostExecProviders {
targets = append(targets, fn()...)
}
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
// ---------------------------------------------------------------------------
@ -100,16 +162,16 @@ func purgeStale() {
defer cancel()
for _, nc := range boxNodes() {
client, err := tai.New(nc.Addr, nc.Options...)
res, err := dialForTest(nc.Addr, nc.DialOps...)
if err != nil {
continue
}
sb := client.Sandbox()
sb := res.Runtime
if sb == nil {
client.Close()
res.Close()
continue
}
containers, _ := sb.List(ctx, taisandbox.ListOptions{All: true})
containers, _ := sb.List(ctx, tairuntime.ListOptions{All: true})
for _, c := range containers {
id := c.Name
if id == "" {
@ -120,7 +182,7 @@ func purgeStale() {
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 {
registry.Init(nil)
}
client, err := tai.New(nc.Addr, nc.Options...)
if err != nil {
t.Fatalf("tai.New(%s): %v", nc.Addr, err)
}
nc.TaiID = client.TaiID()
taiID, res := registerForTest(t, nc.Addr, nc.DialOps...)
nc.TaiID = taiID
t.Cleanup(func() { res.Close() })
sandbox.Init()
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) {

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"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
taitypes "github.com/yaoapp/yao/tai/types"
"github.com/yaoapp/yao/openapi/oauth/authorized"
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
@ -87,7 +88,7 @@ func handleOptions(c *gin.Context) {
if !nodeOwnedBy(s, authInfo) {
continue
}
if !s.Capabilities["host_exec"] {
if !s.Capabilities.HostExec {
continue
}
if !matchNodeFilter(s, osFilter, archFilter, minCPUs, minMem) {
@ -104,7 +105,7 @@ func handleOptions(c *gin.Context) {
if !nodeOwnedBy(s, authInfo) {
continue
}
hasRuntime := s.Capabilities["docker"] || s.Capabilities["k8s"]
hasRuntime := s.Capabilities.Docker || s.Capabilities.K8s
if !hasRuntime {
continue
}
@ -147,7 +148,7 @@ func handleOptions(c *gin.Context) {
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) {
return false
}
@ -163,7 +164,7 @@ func matchNodeFilter(s *registry.NodeSnapshot, osFilter, archFilter string, minC
return true
}
func nodeToHostOption(s registry.NodeSnapshot) computerOption {
func nodeToHostOption(s taitypes.NodeMeta) computerOption {
displayName := s.DisplayName
if displayName == "" {
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
if displayName == "" {
displayName = s.System.Hostname
@ -255,7 +256,7 @@ func boxToOption(b *sandboxv2.Box) computerOption {
}
var mode, addr string
if ns, ok := tai.GetNodeSnapshot(snap.NodeID); ok {
if ns, ok := tai.GetNodeMeta(snap.NodeID); ok {
mode = ns.Mode
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 {
return true
}

View file

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

View file

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@ import (
"io"
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.
@ -44,12 +44,12 @@ func (h *Host) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*Exe
return nil, fmt.Errorf("sandbox: empty command")
}
client, err := h.manager.getNode(h.nodeID)
res, err := h.manager.getNode(h.nodeID)
if err != nil {
return nil, err
}
he := client.HostExec()
he := res.HostExec
if he == nil {
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")
}
client, err := h.manager.getNode(h.nodeID)
res, err := h.manager.getNode(h.nodeID)
if err != nil {
return nil, err
}
he := client.HostExec()
he := res.HostExec
if he == nil {
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.
// Uses the special __host__ identifier to route to localhost:5900 on the Tai server.
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 {
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.
// 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) {
client, err := h.manager.getNode(h.nodeID)
res, err := h.manager.getNode(h.nodeID)
if err != nil {
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
@ -213,15 +219,18 @@ func (h *Host) BindWorkplace(workspaceID string) {
}
// 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 == "" {
return nil
}
client, err := h.manager.getNode(h.nodeID)
res, err := h.manager.getNode(h.nodeID)
if err != 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.

View file

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

View file

@ -3,6 +3,7 @@ package jsapi_test
import (
"fmt"
"os"
"strconv"
"strings"
"testing"
"time"
@ -18,10 +19,9 @@ import (
)
type testMode struct {
Name string
Addr string
TaiID string // filled by setupSandbox
Options []tai.Option
Name string
Addr string
TaiID string
}
func testModes() []testMode {
@ -46,19 +46,71 @@ func setupSandbox(t *testing.T, m *testMode) {
reg := registry.Global()
if reg == nil {
registry.Init(nil)
reg = registry.Global()
}
client, err := tai.New(m.Addr, m.Options...)
if err != nil {
t.Fatalf("tai.New: %v", err)
}
m.TaiID = client.TaiID()
taiID, _ := registerForTest(t, m.Addr)
m.TaiID = taiID
sandbox.Init()
mgr := sandbox.M()
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{} {
t.Helper()
res, err := v8runtime.Call(v8runtime.CallOptions{

View file

@ -5,6 +5,7 @@ import (
"time"
"github.com/yaoapp/yao/tai/registry"
taitypes "github.com/yaoapp/yao/tai/types"
"rogchap.com/v8go"
)
@ -63,17 +64,17 @@ func sbNodesByTeam(info *v8go.FunctionCallbackInfo) *v8go.Value {
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.
func snapshotToJS(v8ctx *v8go.Context, snap *registry.NodeSnapshot) (*v8go.Value, error) {
ports := make(map[string]interface{}, len(snap.Ports))
for k, v := range snap.Ports {
ports[k] = v
func snapshotToJS(v8ctx *v8go.Context, snap *taitypes.NodeMeta) (*v8go.Value, error) {
ports := map[string]interface{}{
"grpc": snap.Ports.GRPC, "http": snap.Ports.HTTP,
"vnc": snap.Ports.VNC, "docker": snap.Ports.Docker, "k8s": snap.Ports.K8s,
}
caps := make(map[string]interface{}, len(snap.Capabilities))
for k, v := range snap.Capabilities {
caps[k] = v
caps := map[string]interface{}{
"docker": snap.Capabilities.Docker, "k8s": snap.Capabilities.K8s,
"host_exec": snap.Capabilities.HostExec,
}
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))
}
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))
for i := range snaps {
snap := &snaps[i]
ports := make(map[string]interface{}, len(snap.Ports))
for k, v := range snap.Ports {
ports[k] = v
ports := map[string]interface{}{
"grpc": snap.Ports.GRPC, "http": snap.Ports.HTTP,
"vnc": snap.Ports.VNC, "docker": snap.Ports.Docker, "k8s": snap.Ports.K8s,
}
caps := make(map[string]interface{}, len(snap.Capabilities))
for k, v := range snap.Capabilities {
caps[k] = v
caps := map[string]interface{}{
"docker": snap.Capabilities.Docker, "k8s": snap.Capabilities.K8s,
"host_exec": snap.Capabilities.HostExec,
}
items = append(items, map[string]interface{}{
"tai_id": snap.TaiID,

View file

@ -10,7 +10,8 @@ import (
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/tai"
"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"
)
@ -38,11 +39,11 @@ func (m *Manager) Start(ctx context.Context) error {
m.ensureLocalNode(reg)
for _, snap := range reg.List() {
client, err := m.getNode(snap.TaiID)
res, err := m.getNode(snap.TaiID)
if err != nil {
continue
}
m.recoverBoxes(ctx, snap.TaiID, client)
m.recoverBoxes(ctx, snap.TaiID, res)
}
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.
func (m *Manager) Nodes() []registry.NodeSnapshot {
func (m *Manager) Nodes() []taitypes.NodeMeta {
reg := registry.Global()
if reg == nil {
return nil
@ -89,26 +90,23 @@ func (m *Manager) Host(_ context.Context, nodeID string) (*Host, error) {
return nil, ErrNodeMissing
}
client, err := m.getNode(nodeID)
res, err := m.getNode(nodeID)
if err != nil {
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)
}
var sys SystemInfo
if snap, ok := tai.GetNodeSnapshot(nodeID); ok {
sys = SystemInfo{
OS: snap.System.OS,
Arch: snap.System.Arch,
Hostname: snap.System.Hostname,
NumCPU: snap.System.NumCPU,
TotalMem: snap.System.TotalMem,
Shell: snap.System.Shell,
TempDir: snap.System.TempDir,
}
sys := SystemInfo{
OS: res.System.OS,
Arch: res.System.Arch,
Hostname: res.System.Hostname,
NumCPU: res.System.NumCPU,
TotalMem: res.System.TotalMem,
Shell: res.System.Shell,
TempDir: res.System.TempDir,
}
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())
}
client, err := m.getNode(nodeID)
res, err := m.getNode(nodeID)
if err != nil {
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)
}
taiOpts := m.buildTaiCreateOptions(opts, nodeID, id)
containerID, err := client.Sandbox().Create(ctx, taiOpts)
containerID, err := res.Runtime.Create(ctx, taiOpts)
if err != nil {
return nil, fmt.Errorf("sandbox: create container: %w", err)
}
if err := client.Sandbox().Start(ctx, containerID); err != nil {
client.Sandbox().Remove(ctx, containerID, true)
if err := res.Runtime.Start(ctx, containerID); err != nil {
res.Runtime.Remove(ctx, containerID, true)
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
}
var sys SystemInfo
if snap, ok := tai.GetNodeSnapshot(nodeID); ok {
sys = SystemInfo{
OS: snap.System.OS,
Arch: snap.System.Arch,
Hostname: snap.System.Hostname,
NumCPU: snap.System.NumCPU,
TotalMem: snap.System.TotalMem,
Shell: snap.System.Shell,
TempDir: snap.System.TempDir,
}
sys := SystemInfo{
OS: res.System.OS,
Arch: res.System.Arch,
Hostname: res.System.Hostname,
NumCPU: res.System.NumCPU,
TotalMem: res.System.TotalMem,
Shell: res.System.Shell,
TempDir: res.System.TempDir,
}
box := &Box{
@ -278,9 +273,9 @@ func (m *Manager) Remove(ctx context.Context, id string) error {
}
b := v.(*Box)
client, err := m.getNode(b.nodeID)
if err == nil && client.Sandbox() != nil {
client.Sandbox().Remove(ctx, b.containerID, true)
res, err := m.getNode(b.nodeID)
if err == nil && res.Runtime != nil {
res.Runtime.Remove(ctx, b.containerID, true)
}
m.boxes.Delete(id)
@ -303,8 +298,8 @@ func (m *Manager) Cleanup(ctx context.Context) error {
}
case LongRunning:
if timeout := b.idleTimeout(); timeout > 0 && idle > timeout {
if client, err := m.getNode(b.nodeID); err == nil && client.Sandbox() != nil {
client.Sandbox().Stop(ctx, b.containerID, b.stopTimeout())
if res, err := m.getNode(b.nodeID); err == nil && res.Runtime != nil {
res.Runtime.Stop(ctx, b.containerID, b.stopTimeout())
}
}
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) {
client, ok := tai.GetClient(name)
func (m *Manager) getNode(name string) (*tai.ConnResources, error) {
res, ok := tai.GetResources(name)
if !ok {
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)
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"}
var ports []taisandbox.PortMapping
var ports []tairuntime.PortMapping
for _, p := range opts.Ports {
ports = append(ports, taisandbox.PortMapping{
ports = append(ports, tairuntime.PortMapping{
ContainerPort: p.ContainerPort,
HostPort: p.HostPort,
HostIP: p.HostIP,
@ -413,7 +408,7 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID st
}
}
return taisandbox.CreateOptions{
return tairuntime.CreateOptions{
Name: sandboxID,
Image: opts.Image,
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) {
if client.Sandbox() == nil {
func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.ConnResources) {
if res.Runtime == nil {
return
}
containers, err := client.Sandbox().List(ctx, taisandbox.ListOptions{
containers, err := res.Runtime.List(ctx, tairuntime.ListOptions{
All: true,
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.
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 {
return false, err
}
img := client.Image()
if img == nil {
if res.Image == 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
// real-time progress events.
func (m *Manager) PullImage(ctx context.Context, nodeID, ref string, opts ImagePullOptions) (<-chan taisandbox.PullProgress, error) {
client, err := m.getNode(nodeID)
func (m *Manager) PullImage(ctx context.Context, nodeID, ref string, opts ImagePullOptions) (<-chan tairuntime.PullProgress, error) {
res, err := m.getNode(nodeID)
if err != nil {
return nil, err
}
img := client.Image()
if img == nil {
if res.Image == nil {
return nil, nil
}
pullOpts := taisandbox.PullOptions{}
pullOpts := tairuntime.PullOptions{}
if opts.Auth != nil {
pullOpts.Auth = &taisandbox.RegistryAuth{
pullOpts.Auth = &tairuntime.RegistryAuth{
Username: opts.Auth.Username,
Password: opts.Auth.Password,
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

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"
"github.com/yaoapp/yao/tai"
"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"
)
@ -25,62 +25,70 @@ var k8sSem = make(chan struct{}, 2)
// when many tests finish at once.
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) {
purgeStaleContainers()
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() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
type target struct {
name string
addr string
opts []tai.Option
name string
addr string
dialOps []tai.DialOption
}
var targets []target
targets = append(targets, target{name: "local", addr: testLocalAddr()})
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
targets = append(targets, target{name: "remote", addr: addr})
}
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 _, fn := range extraPurgeProviders {
for _, extra := range fn() {
targets = append(targets, target{name: extra.name, addr: extra.addr, dialOps: extra.dialOps})
}
}
for _, tgt := range targets {
client, err := tai.New(tgt.addr, tgt.opts...)
res, err := dialForTest(tgt.addr, tgt.dialOps...)
if err != nil {
continue
}
sb := client.Sandbox()
sb := res.Runtime
if sb == nil {
client.Close()
res.Close()
continue
}
containers, err := sb.List(ctx, taisandbox.ListOptions{All: true})
containers, err := sb.List(ctx, tairuntime.ListOptions{All: true})
if err != nil {
client.Close()
res.Close()
continue
}
for _, c := range containers {
@ -94,57 +102,57 @@ func purgeStaleContainers() {
sb.Remove(ctx, id, true)
log.Printf("[purge] %s: removed stale container %s", tgt.name, id)
}
client.Close()
res.Close()
}
}
// ---------------------------------------------------------------------------
// Node / HostExec configuration
// ---------------------------------------------------------------------------
type nodeConfig struct {
Name string // human-readable label for t.Run (e.g. "remote", "k8s")
Name string
Addr string
TaiID string // actual registry key, filled after tai.New
Options []tai.Option
TaiID string
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 {
nodes := []nodeConfig{
{Name: "local", Addr: testLocalAddr()},
}
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
nodes = append(nodes, nodeConfig{Name: "remote", Addr: addr})
}
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})
for _, fn := range extraNodeProviders {
nodes = append(nodes, fn()...)
}
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) {
t.Helper()
addr := testLocalAddr()
if addr == "" {
if testLocalAddr() == "" {
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) {
t.Helper()
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) {
if tgt.IsWinNative {
switch cmd {
@ -214,6 +200,10 @@ func linuxCmd(tgt hostExecTarget, cmd string, args ...string) (string, []string)
return cmd, args
}
// ---------------------------------------------------------------------------
// Environment helpers
// ---------------------------------------------------------------------------
func testLocalAddr() string {
if addr := os.Getenv("SANDBOX_TEST_LOCAL_ADDR"); addr != "" {
return addr
@ -237,41 +227,88 @@ func envPort(key string, fallback int) int {
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.
func registerNode(t *testing.T, pc *nodeConfig) {
t.Helper()
// ---------------------------------------------------------------------------
// Dial + Register helper (replaces old tai.New)
// ---------------------------------------------------------------------------
reg := registry.Global()
if reg == nil {
// dialForTest calls DialLocal or DialRemote based on the address.
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)
}
client, err := tai.New(pc.Addr, pc.Options...)
res, err := dialForTest(addr, dialOps...)
if err != nil {
t.Fatalf("tai.New(%s): %v", pc.Addr, err)
t.Fatalf("dialForTest(%s): %v", addr, err)
}
pc.TaiID = client.TaiID()
t.Cleanup(func() { client.Close() })
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://")
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) {
t.Helper()
reg := registry.Global()
if reg == nil {
if registry.Global() == nil {
registry.Init(nil)
}
_ = reg
out := make([]nodeConfig, len(nodes))
copy(out, nodes)
for i := range out {
client, err := tai.New(out[i].Addr, out[i].Options...)
if err != nil {
t.Fatalf("tai.New(%s): %v", out[i].Addr, err)
}
out[i].TaiID = client.TaiID()
taiID, _ := registerForTest(t, out[i].Addr, out[i].DialOps...)
out[i].TaiID = taiID
}
sandbox.Init()
@ -287,8 +324,6 @@ func setupManagerForNode(t *testing.T, pc *nodeConfig) *sandbox.Manager {
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) {
t.Helper()
sbm := setupManagerForNode(t, pc)
@ -364,3 +399,6 @@ func createTestBox(t *testing.T, m *sandbox.Manager, pc nodeConfig, opts ...func
})
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"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/taiid"
"github.com/yaoapp/yao/tai/types"
)
// authenticateBearer validates a Bearer token and returns the caller's identity.
// Package-level var so tests can inject a mock without an OAuth service.
var authenticateBearer = authenticateBearerDefault
func authenticateBearerDefault(token string) (registry.AuthInfo, error) {
func authenticateBearerDefault(token string) (types.AuthInfo, error) {
svc := oauth.OAuth
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})
if err != nil {
return registry.AuthInfo{}, err
return types.AuthInfo{}, err
}
info := registry.AuthInfo{}
info := types.AuthInfo{}
if result.Info != nil {
info.Subject = result.Info.Subject
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.
type registerRequest struct {
NodeID string `json:"node_id,omitempty"`
ClientID string `json:"client_id,omitempty"`
MachineID string `json:"machine_id"`
DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"`
Addr string `json:"addr"`
Ports map[string]int `json:"ports"`
Capabilities map[string]bool `json:"capabilities"`
System registry.SystemInfo `json:"system"`
NodeID string `json:"node_id,omitempty"`
ClientID string `json:"client_id,omitempty"`
MachineID string `json:"machine_id"`
DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"`
Addr string `json:"addr"`
Ports map[string]int `json:"ports"`
Capabilities map[string]bool `json:"capabilities"`
System types.SystemInfo `json:"system"`
}
// heartbeatRequest is the JSON body for POST /tai-nodes/heartbeat.
@ -162,8 +163,8 @@ func HandleRegister(c *gin.Context) {
System: req.System,
Mode: "direct",
Addr: addr,
Ports: req.Ports,
Capabilities: req.Capabilities,
Ports: portsFromMap(req.Ports),
Capabilities: capsFromMap(req.Capabilities),
}
reg.Register(node)
slog.Info("[register] node registered via API",
@ -180,7 +181,7 @@ func HandleRegister(c *gin.Context) {
if strings.HasPrefix(addr, "tai://") {
slog.Info("[register] launching connectRegisteredNode goroutine",
"tai_id", resolvedTaiID, "addr", addr)
go connectRegisteredNode(resolvedTaiID, addr, reg)
go connectRegisteredNode(resolvedTaiID, addr, portsFromMap(req.Ports), reg)
}
c.JSON(http.StatusOK, gin.H{
@ -278,45 +279,50 @@ func HandleUnregister(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "unregistered"})
}
// connectRegisteredNode dials the self-registered Tai node via gRPC,
// creates a tai.Client, and binds it to the node's TaiID in the registry.
// initRemote internally registers a redundant "host-port" entry; we remove
// it so that the registry contains only the canonical taiID.
func connectRegisteredNode(taiID, addr string, reg *registry.Registry) {
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"],
}
}
// 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)
client, err := tai.New(addr)
if err != nil {
slog.Warn("[connect] tai.New FAILED",
"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)
}
host := extractHost(addr)
if host == "" {
slog.Warn("[connect] failed to extract host from addr", "addr", addr)
return
}
autoID := client.TaiID()
slog.Info("[connect] tai.New OK", "tai_id", taiID, "autoID", autoID)
allAfterNew := reg.List()
slog.Info("[connect] registry after tai.New", "total", len(allAfterNew))
for _, s := range allAfterNew {
slog.Info("[connect] node", "tai_id", s.TaiID, "mode", s.Mode, "addr", s.Addr)
res, err := tai.DialRemote(host, ports)
if err != nil {
slog.Warn("[connect] DialRemote failed",
"tai_id", taiID, "addr", addr, "err", err)
return
}
if autoID != "" && autoID != taiID {
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)
}
reg.SetResources(taiID, res)
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/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/types"
)
func init() {
@ -20,8 +21,8 @@ func setupTest() func() {
registry.SetGlobalForTest(r)
origAuth := authenticateBearer
authenticateBearer = func(token string) (registry.AuthInfo, error) {
return registry.AuthInfo{
authenticateBearer = func(token string) (types.AuthInfo, error) {
return types.AuthInfo{
Subject: "sub-001",
UserID: "user-alice",
ClientID: "tai-abc123",
@ -53,7 +54,7 @@ func TestHandleRegister_Success(t *testing.T) {
Addr: "192.168.1.100",
Ports: map[string]int{"grpc": 19100, "http": 8099},
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,
},
}
@ -114,7 +115,7 @@ func TestHandleRegister_ServerGeneratedTaiID(t *testing.T) {
Addr: "192.168.1.200",
Ports: map[string]int{"grpc": 19100},
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()
@ -207,7 +208,7 @@ func TestHandleHeartbeat_Success(t *testing.T) {
reg.Register(&registry.TaiNode{
TaiID: "tai-abc123",
Mode: "direct",
Auth: registry.AuthInfo{ClientID: "tai-abc123"},
Auth: types.AuthInfo{ClientID: "tai-abc123"},
})
w := httptest.NewRecorder()
@ -232,7 +233,7 @@ func TestHandleHeartbeat_WrongOwner(t *testing.T) {
reg.Register(&registry.TaiNode{
TaiID: "tai-other",
Mode: "direct",
Auth: registry.AuthInfo{ClientID: "different-client"},
Auth: types.AuthInfo{ClientID: "different-client"},
})
w := httptest.NewRecorder()
@ -275,7 +276,7 @@ func TestHandleUnregister_Success(t *testing.T) {
reg.Register(&registry.TaiNode{
TaiID: "tai-abc123",
Mode: "direct",
Auth: registry.AuthInfo{ClientID: "tai-abc123"},
Auth: types.AuthInfo{ClientID: "tai-abc123"},
})
w := httptest.NewRecorder()
@ -303,7 +304,7 @@ func TestHandleUnregister_WrongOwner(t *testing.T) {
reg.Register(&registry.TaiNode{
TaiID: "tai-other",
Mode: "direct",
Auth: registry.AuthInfo{ClientID: "different-client"},
Auth: types.AuthInfo{ClientID: "different-client"},
})
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"
"strings"
"github.com/yaoapp/yao/tai/sandbox"
"github.com/yaoapp/yao/tai/runtime"
)
// Proxy resolves HTTP service URLs for containers.
@ -98,11 +98,11 @@ func (t *tunnelProxy) Healthz(_ context.Context) error {
// --- Local implementation ---
type localProxy struct {
sb sandbox.Sandbox
sb runtime.Runtime
}
// NewLocal creates a Proxy that resolves host ports via sandbox.Inspect.
func NewLocal(sb sandbox.Sandbox) Proxy {
// NewLocal creates a Proxy that resolves host ports via runtime.Inspect.
func NewLocal(sb runtime.Runtime) Proxy {
return &localProxy{sb: sb}
}

View file

@ -10,7 +10,7 @@ import (
"time"
"github.com/gorilla/websocket"
"github.com/yaoapp/yao/tai/sandbox"
"github.com/yaoapp/yao/tai/runtime"
)
func TestRemoteURL(t *testing.T) {
@ -72,10 +72,10 @@ func TestRemoteHealthzFail(t *testing.T) {
func TestLocalURL(t *testing.T) {
mock := &mockSandbox{
inspectFn: func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) {
return &sandbox.ContainerInfo{
inspectFn: func(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
return &runtime.ContainerInfo{
ID: id,
Ports: []sandbox.PortMapping{
Ports: []runtime.PortMapping{
{ContainerPort: 3000, HostPort: 32768, 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) {
mock := &mockSandbox{
inspectFn: func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) {
return &sandbox.ContainerInfo{ID: id}, nil
inspectFn: func(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
return &runtime.ContainerInfo{ID: id}, nil
},
}
@ -112,7 +112,7 @@ func TestLocalURLPortNotFound(t *testing.T) {
func TestLocalURLInspectError(t *testing.T) {
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")
},
}
@ -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 {
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
}
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
}
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
}
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
}
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 {
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
}
func (m *mockSandbox) Close() error { return nil }

View file

@ -13,32 +13,22 @@ import (
"time"
"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).
// Internal use only; external callers receive NodeSnapshot via Get()/List().
// Internal use only; external callers receive types.NodeMeta via Get()/List().
type TaiNode struct {
TaiID string
MachineID string
Version string
Auth AuthInfo
System SystemInfo
Mode string // "direct" | "tunnel"
Addr string // direct mode: "tai-host"; tunnel mode: empty
YaoBase string // Yao server base URL reported by Tai (tunnel mode)
Ports map[string]int // {"grpc":19100, "http":8099, "vnc":16080, "docker":12375}
Capabilities map[string]bool
Auth types.AuthInfo
System types.SystemInfo
Mode string // "direct" | "tunnel"
Addr string // direct mode: "tai-host"; tunnel mode: empty
YaoBase string // Yao server base URL reported by Tai (tunnel mode)
Ports types.Ports
Capabilities types.Capabilities
ControlConn *websocket.Conn
connMu sync.Mutex // protects ControlConn writes
@ -48,64 +38,22 @@ type TaiNode struct {
LastPing time.Time
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
}
// NodeSnapshot is a read-only copy of TaiNode fields safe to use outside locks.
type NodeSnapshot struct {
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{
func (n *TaiNode) meta() types.NodeMeta {
return types.NodeMeta{
TaiID: n.TaiID, MachineID: n.MachineID, Version: n.Version,
Auth: n.Auth, System: n.System,
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,
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.
type pendingChannel struct {
taiID string
@ -188,7 +136,8 @@ func (r *Registry) Register(node *TaiNode) {
"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) {
r.mu.Lock()
node, ok := r.nodes[taiID]
@ -208,29 +157,34 @@ func (r *Registry) Unregister(taiID string) {
r.mu.Unlock()
if ok {
if node.resources != nil {
if closer, ok := node.resources.(ResourceCloser); ok {
closer.Close()
}
}
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.
func (r *Registry) Get(taiID string) (*NodeSnapshot, bool) {
// Get returns the metadata of a Tai node by ID. Returns nil, false if not found.
func (r *Registry) Get(taiID string) (*types.NodeMeta, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
n, ok := r.nodes[taiID]
if !ok {
return nil, false
}
snap := n.snapshot()
return &snap, true
m := n.meta()
return &m, true
}
// List returns snapshots of all registered Tai nodes.
func (r *Registry) List() []NodeSnapshot {
// List returns metadata of all registered Tai nodes.
func (r *Registry) List() []types.NodeMeta {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]NodeSnapshot, 0, len(r.nodes))
result := make([]types.NodeMeta, 0, len(r.nodes))
for _, n := range r.nodes {
result = append(result, n.snapshot())
result = append(result, n.meta())
}
return result
}
@ -263,14 +217,41 @@ func (r *Registry) UpdatePing(taiID string) {
}
}
// SetClient associates a *tai.Client with a registered node.
// Called by tai.New() after successful initialization.
func (r *Registry) SetClient(taiID string, c any) {
// ResourceCloser is implemented by *tai.ConnResources to allow the registry
// to close resources without importing the tai package (avoids import cycle).
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()
defer r.mu.Unlock()
if n, ok := r.nodes[taiID]; ok {
n.client = c
n, ok := r.nodes[taiID]
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
@ -288,28 +269,28 @@ func (r *Registry) FindTaiIDByAuthClient(clientID string) string {
return ""
}
// ListByTeam returns snapshots of all nodes belonging to the given team.
func (r *Registry) ListByTeam(teamID string) []NodeSnapshot {
// ListByTeam returns metadata of all nodes belonging to the given team.
func (r *Registry) ListByTeam(teamID string) []types.NodeMeta {
r.mu.RLock()
defer r.mu.RUnlock()
var result []NodeSnapshot
var result []types.NodeMeta
for _, n := range r.nodes {
if n.Auth.TeamID == teamID {
result = append(result, n.snapshot())
result = append(result, n.meta())
}
}
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.
func (r *Registry) ListByUser(userID string) []NodeSnapshot {
func (r *Registry) ListByUser(userID string) []types.NodeMeta {
r.mu.RLock()
defer r.mu.RUnlock()
var result []NodeSnapshot
var result []types.NodeMeta
for _, n := range r.nodes {
if n.Auth.TeamID == "" && n.Auth.UserID == userID {
result = append(result, n.snapshot())
result = append(result, n.meta())
}
}
return result

View file

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

View file

@ -1,8 +1,8 @@
package sandbox
package runtime
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 {
dockerClient() *client.Client
}
@ -10,10 +10,10 @@ type dockerCliAccessor interface {
func (l *local) dockerClient() *client.Client { return l.core.cli }
func (d *dockerSandbox) dockerClient() *client.Client { return d.core.cli }
// DockerCli extracts the underlying Docker SDK client from a Sandbox.
// Returns nil if the Sandbox is not Docker-based (e.g. K8s).
func DockerCli(sb Sandbox) *client.Client {
if a, ok := sb.(dockerCliAccessor); ok {
// DockerCli extracts the underlying Docker SDK client from a Runtime.
// Returns nil if the Runtime is not Docker-based (e.g. K8s).
func DockerCli(rt Runtime) *client.Client {
if a, ok := rt.(dockerCliAccessor); ok {
return a.dockerClient()
}
return nil

View file

@ -1,4 +1,4 @@
package sandbox
package runtime
import (
"context"
@ -12,9 +12,9 @@ type dockerSandbox struct {
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".
func NewDocker(addr string) (Sandbox, error) {
func NewDocker(addr string) (Runtime, error) {
cli, err := client.NewClientWithOpts(
client.WithHost(addr),
client.WithAPIVersionNegotiation(),

View file

@ -1,4 +1,4 @@
package sandbox
package runtime
import (
"bytes"
@ -15,7 +15,7 @@ import (
"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 {
cli *client.Client
}

View file

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

View file

@ -1,4 +1,4 @@
package sandbox
package runtime
import (
"context"
@ -14,7 +14,7 @@ import (
)
// 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 {
cli *client.Client
}

View file

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

View file

@ -1,4 +1,4 @@
package sandbox
package runtime
import (
"bytes"
@ -20,7 +20,7 @@ import (
"k8s.io/client-go/tools/remotecommand"
)
// K8sOption configures a K8s sandbox.
// K8sOption configures a K8s runtime.
type K8sOption struct {
Namespace string // default "default"
KubeConfig string // path to kubeconfig file
@ -33,10 +33,10 @@ type k8sSandbox struct {
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.
// 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"
var kubeConfigPath string
if len(opts) > 0 {
@ -56,7 +56,7 @@ func NewK8s(addr string, opts ...K8sOption) (Sandbox, error) {
}
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)

View file

@ -1,4 +1,4 @@
package sandbox
package runtime
import (
"context"
@ -12,9 +12,9 @@ type local struct {
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.
func NewLocal(addr string) (Sandbox, error) {
func NewLocal(addr string) (Runtime, error) {
opts := []client.Opt{client.WithAPIVersionNegotiation()}
if addr != "" {
opts = append(opts, client.WithHost(addr))

View file

@ -1,4 +1,4 @@
package sandbox
package runtime
import (
"context"
@ -53,7 +53,7 @@ func TestHelpers(t *testing.T) {
})
}
func TestLocalSandbox(t *testing.T) {
func TestLocalRuntime(t *testing.T) {
sb, err := NewLocal("")
if err != nil {
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()
sb, err := NewDocker(addr)
if err != nil {
@ -352,7 +352,7 @@ func TestPortStr(t *testing.T) {
}
}
func TestK8sSandbox(t *testing.T) {
func TestK8sRuntime(t *testing.T) {
host := taiTestK8sHost()
port := taiTestK8sPort()
kubeconfig := taiTestKubeConfig()
@ -496,7 +496,7 @@ func TestK8sBuildResourcesPartial(t *testing.T) {
}
}
func TestK8sSandboxStopAndRemove(t *testing.T) {
func TestK8sRuntimeStopAndRemove(t *testing.T) {
host := taiTestK8sHost()
port := taiTestK8sPort()
kubeconfig := taiTestKubeConfig()

View file

@ -1,4 +1,4 @@
package sandbox
package runtime
import (
"context"
@ -6,9 +6,9 @@ import (
"time"
)
// Sandbox manages container lifecycle.
// Runtime manages container lifecycle.
// 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)
Start(ctx context.Context, id string) error
Stop(ctx context.Context, id string, timeout time.Duration) error

View file

@ -1,38 +1,16 @@
package tai
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/sandbox"
sipb "github.com/yaoapp/yao/tai/serverinfo/pb"
"github.com/yaoapp/yao/tai/vnc"
"github.com/yaoapp/yao/tai/types"
"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 Runtime int
// Type aliases kept at package level for convenience.
type Runtime = types.Runtime
type Ports = types.Ports
const (
Docker Runtime = iota
K8s
)
func (r Runtime) apply(c *config) { c.runtime = r }
// Option configures a Client.
// Option configures RegisterLocal.
type Option interface {
apply(*config)
}
@ -41,60 +19,19 @@ type optionFunc func(*config)
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.
func WithDataDir(dir string) Option {
return optionFunc(func(c *config) { c.dataDir = dir })
}
// WithKubeConfig sets the kubeconfig file path for K8s runtime.
// 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.
// WithVolume injects a custom Volume implementation (useful for testing).
func WithVolume(vol volume.Volume) Option {
return optionFunc(func(c *config) { c.volume = vol })
}
type config struct {
runtime Runtime
ports Ports
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)
dataDir string
volume volume.Volume
}
func defaultPorts() Ports {
@ -125,504 +62,15 @@ func mergedPorts(p Ports) Ports {
return d
}
// Client provides unified access to all Tai SDK sub-packages.
type Client struct {
scheme string // "tai", "docker", or "tunnel"
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
func intOr(v, fallback int) int {
if v > 0 {
return v
}
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,
// 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.
// Silently returns false if Docker is not available — this is not an error.
func RegisterLocal(opts ...Option) bool {
@ -634,34 +82,40 @@ func RegisterLocal(opts ...Option) bool {
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 {
return false
}
_ = c // registered by initLocal → reg.Register + reg.SetClient
reg.Register(&registry.TaiNode{
TaiID: "local",
Mode: "local",
})
reg.SetResources("local", res)
return true
}
// GetClient returns a registered *Client by taiID from the global registry.
func GetClient(taiID string) (*Client, bool) {
// GetResources returns the ConnResources for a registered Tai node.
func GetResources(taiID string) (*ConnResources, bool) {
reg := registry.Global()
if reg == nil {
return nil, false
}
snap, ok := reg.Get(taiID)
raw, ok := reg.GetResources(taiID)
if !ok {
return nil, false
}
c, ok := snap.Client().(*Client)
if !ok || c == nil {
return nil, false
}
return c, true
res, ok := raw.(*ConnResources)
return res, ok && res != nil
}
// GetNodeSnapshot returns the registry snapshot for a Tai node by ID.
// Callers can inspect System, Capabilities, Mode and other registry-level fields.
func GetNodeSnapshot(taiID string) (*registry.NodeSnapshot, bool) {
// GetNodeMeta returns the metadata for a registered Tai node by ID.
func GetNodeMeta(taiID string) (*types.NodeMeta, bool) {
reg := registry.Global()
if reg == nil {
return nil, false

View file

@ -1,12 +1,13 @@
package tai
import (
"fmt"
"os"
"strconv"
"testing"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/types"
"github.com/yaoapp/yao/tai/volume"
)
func taiTestHost() string {
@ -16,18 +17,6 @@ func taiTestHost() string {
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 {
return Ports{
Docker: envPort("TAI_TEST_DOCKER_PORT", 0),
@ -45,62 +34,6 @@ func envPort(key string, fallback int) int {
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) {
p := mergedPorts(Ports{HTTP: 8888})
if p.HTTP != 8888 {
@ -127,97 +60,73 @@ func TestMergedPortsAll(t *testing.T) {
}
}
func TestOptions(t *testing.T) {
cfg := &config{ports: defaultPorts()}
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")
func TestDialLocalSuccess(t *testing.T) {
res, err := DialLocal("", t.TempDir(), nil)
if err != nil {
t.Skipf("Docker not available: %v", err)
}
defer c.Close()
defer res.Close()
if !c.IsLocal() {
t.Error("expected IsLocal = true")
}
if c.Volume() == nil {
if res.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")
}
// Test Workspace accessor
ws := c.Workspace("test-session")
if ws == nil {
t.Error("Workspace should not be nil")
if res.Runtime == nil {
t.Error("Runtime should not be nil")
}
}
func TestNewLocalWithDataDir(t *testing.T) {
func TestDialLocalWithVolume(t *testing.T) {
dir := t.TempDir()
c, err := New("local", WithDataDir(dir))
vol := volume.NewLocal(dir)
res, err := DialLocal("", dir, vol)
if err != nil {
t.Skipf("Docker not available: %v", err)
}
defer c.Close()
defer res.Close()
if !c.IsLocal() {
t.Error("expected IsLocal = true")
if res.DataDir != dir {
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) {
c, err := New("unix:///var/run/docker.sock")
func TestDialLocalExplicitSocket(t *testing.T) {
res, err := DialLocal("unix:///var/run/docker.sock", t.TempDir(), nil)
if err != nil {
t.Skipf("Docker not available: %v", err)
}
defer c.Close()
defer res.Close()
if !c.IsLocal() {
t.Error("expected IsLocal = true for unix socket")
if res.Runtime == nil {
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")
kubeconfig := os.Getenv("TAI_TEST_KUBECONFIG")
if host == "" || kubeconfig == "" {
@ -232,119 +141,31 @@ func TestNewRemoteK8s(t *testing.T) {
VNC: envPort("TAI_TEST_K8S_VNC_PORT", 16080),
}
c, err := New(fmt.Sprintf("tai://%s:%d", host, grpcPort), K8s,
WithPorts(ports),
WithKubeConfig(kubeconfig),
WithNamespace("default"),
res, err := DialRemote(host, ports,
WithDialRuntime(types.K8s),
WithDialKubeConfig(kubeconfig),
WithDialNamespace("default"),
)
if err != nil {
t.Skipf("Tai K8s not available: %v", err)
}
defer c.Close()
defer res.Close()
if c.IsLocal() {
t.Error("expected IsLocal = false")
}
if c.Sandbox() == nil {
t.Error("Sandbox should not be nil")
if res.Runtime == nil {
t.Error("Runtime should not be nil")
}
}
func TestNewRemoteK8sMissingKubeConfig(t *testing.T) {
_, err := New("tai://127.0.0.1", K8s)
func TestDialRemoteK8sMissingKubeConfig(t *testing.T) {
host := taiTestHost()
grpcPort := envPort("TAI_TEST_GRPC_PORT", 19100)
_, err := DialRemote(host, Ports{GRPC: grpcPort}, WithDialRuntime(types.K8s))
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) {
registry.Init(nil)
reg := registry.Global()
@ -355,39 +176,37 @@ func TestRegisterLocal(t *testing.T) {
t.Skip("Docker not available, skipping RegisterLocal test")
}
snap, found := reg.Get("local")
meta, found := reg.Get("local")
if !found {
t.Fatal("expected 'local' node in registry after RegisterLocal")
}
if snap.Mode != "local" {
t.Errorf("mode = %q, want 'local'", snap.Mode)
if meta.Mode != "local" {
t.Errorf("mode = %q, want 'local'", meta.Mode)
}
if snap.Status != "online" {
t.Errorf("status = %q, want 'online'", snap.Status)
if meta.Status != "online" {
t.Errorf("status = %q, want 'online'", meta.Status)
}
c, got := GetClient("local")
res, got := GetResources("local")
if !got {
t.Fatal("GetClient('local') returned false after RegisterLocal")
t.Fatal("GetResources('local') returned false after RegisterLocal")
}
if c.DataDir() != dir {
t.Errorf("DataDir = %q, want %q", c.DataDir(), dir)
if res.DataDir != dir {
t.Errorf("DataDir = %q, want %q", res.DataDir, dir)
}
if c.Sandbox() == nil {
t.Error("local client Sandbox should not be nil")
if res.Runtime == nil {
t.Error("local resources Runtime should not be nil")
}
// Idempotent: second call should return true without error
ok2 := RegisterLocal(WithDataDir(dir))
if !ok2 {
t.Error("second RegisterLocal should return true (idempotent)")
}
c.Close()
res.Close()
}
func TestRegisterLocal_NoRegistry(t *testing.T) {
// RegisterLocal without a registry should return false, not panic
origReg := registry.Global()
defer func() {
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()
// Just verify it doesn't panic; result depends on Docker availability
_ = ok
}
func TestRegisterLocal_NoDocker(t *testing.T) {
registry.Init(nil)
// Use an unreachable Docker socket to ensure failure
ok := RegisterLocal(WithDataDir(t.TempDir()))
if !ok {
// Expected when Docker is not available — just ensure no panic
return
}
// If Docker happens to be available, that's also fine
c, _ := GetClient("local")
if c != nil {
c.Close()
res, got := GetResources("local")
if got && res != nil {
res.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
}
httpPort := node.Ports["http"]
httpPort := node.Ports.HTTP
if httpPort == 0 {
httpPort = 8099
}
@ -102,7 +102,7 @@ func HandleVNC(c *gin.Context) {
return
}
vncPort := node.Ports["vnc"]
vncPort := node.Ports.VNC
if vncPort == 0 {
vncPort = 16080
}

View file

@ -16,6 +16,7 @@ import (
tai "github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/taiid"
"github.com/yaoapp/yao/tai/types"
)
var upgrader = websocket.Upgrader{
@ -91,8 +92,8 @@ func HandleControl(c *gin.Context) {
Mode: "tunnel",
Addr: addr,
YaoBase: regMsg.Server,
Ports: regMsg.Ports,
Capabilities: regMsg.Capabilities,
Ports: portsFromMap(regMsg.Ports),
Capabilities: capsFromMap(regMsg.Capabilities),
ControlConn: conn,
}
reg.Register(node)
@ -183,16 +184,16 @@ func HandleData(c *gin.Context) {
// registerMessage is the JSON structure for Tai's register message.
type registerMessage struct {
Type string `json:"type"`
NodeID string `json:"node_id,omitempty"`
ClientID string `json:"client_id,omitempty"`
MachineID string `json:"machine_id"`
DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"`
Server string `json:"server"`
Ports map[string]int `json:"ports"`
Capabilities map[string]bool `json:"capabilities"`
System registry.SystemInfo `json:"system"`
Type string `json:"type"`
NodeID string `json:"node_id,omitempty"`
ClientID string `json:"client_id,omitempty"`
MachineID string `json:"machine_id"`
DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"`
Server string `json:"server"`
Ports map[string]int `json:"ports"`
Capabilities map[string]bool `json:"capabilities"`
System types.SystemInfo `json:"system"`
}
// controlMsg is a generic control channel message.
@ -210,20 +211,20 @@ func extractBearer(r *http.Request) string {
var authenticateBearerFunc = authenticateBearerDefault
func authenticateBearerDefault(token string) (registry.AuthInfo, error) {
func authenticateBearerDefault(token string) (types.AuthInfo, error) {
svc := oauth.OAuth
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,
})
if err != nil {
return registry.AuthInfo{}, err
return types.AuthInfo{}, err
}
info := registry.AuthInfo{}
info := types.AuthInfo{}
if result.Info != nil {
info.Subject = result.Info.Subject
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) 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) {
client, err := tai.New("tunnel://" + taiID)
res, err := tai.DialTunnel(taiID, reg)
if err != nil {
logger.Warn("failed to connect tunnel node",
"tai_id", taiID, "err", err)
return
}
_ = client // initTunnel already calls reg.SetClient(taiID, c)
logger.Info("tai client created for tunnel node", "tai_id", taiID)
reg.SetResources(taiID, res)
logger.Info("tunnel node connected", "tai_id", taiID)
}

View file

@ -14,6 +14,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/types"
)
func init() {
@ -26,9 +27,9 @@ func setupTestRegistry() *registry.Registry {
return r
}
func mockAuth(info registry.AuthInfo, authErr error) func() {
func mockAuth(info types.AuthInfo, authErr error) func() {
old := authenticateBearerFunc
authenticateBearerFunc = func(token string) (registry.AuthInfo, error) {
authenticateBearerFunc = func(token string) (types.AuthInfo, error) {
return info, authErr
}
return func() { authenticateBearerFunc = old }
@ -200,7 +201,7 @@ func TestHandleControl_NoRegistry(t *testing.T) {
registry.SetGlobalForTest(nil)
defer setupTestRegistry()
restore := mockAuth(registry.AuthInfo{ClientID: "tai-001"}, nil)
restore := mockAuth(types.AuthInfo{ClientID: "tai-001"}, nil)
defer restore()
srv := httptest.NewServer(newGinRouter())
@ -236,7 +237,7 @@ func TestHandleControl_NoAuth(t *testing.T) {
func TestHandleControl_AuthFailed(t *testing.T) {
setupTestRegistry()
restore := mockAuth(registry.AuthInfo{}, fmt.Errorf("bad token"))
restore := mockAuth(types.AuthInfo{}, fmt.Errorf("bad token"))
defer restore()
srv := httptest.NewServer(newGinRouter())
@ -256,7 +257,7 @@ func TestHandleControl_AuthFailed(t *testing.T) {
func TestHandleControl_RegisterAndPing(t *testing.T) {
reg := setupTestRegistry()
restore := mockAuth(registry.AuthInfo{
restore := mockAuth(types.AuthInfo{
ClientID: "tai-001",
Subject: "user-test",
Scope: "tai:tunnel",
@ -324,8 +325,8 @@ func TestHandleControl_RegisterAndPing(t *testing.T) {
if snap.Auth.Subject != "user-test" {
t.Errorf("Auth.Subject = %q, want user-test", snap.Auth.Subject)
}
if snap.Ports["grpc"] != 9100 {
t.Errorf("Ports[grpc] = %d, want 9100", snap.Ports["grpc"])
if snap.Ports.GRPC != 9100 {
t.Errorf("Ports.GRPC = %d, want 9100", snap.Ports.GRPC)
}
time.Sleep(10 * time.Millisecond)
@ -366,7 +367,7 @@ func TestHandleControl_RegisterAndPing(t *testing.T) {
func TestHandleControl_BadRegisterType(t *testing.T) {
setupTestRegistry()
restore := mockAuth(registry.AuthInfo{ClientID: "tai-001"}, nil)
restore := mockAuth(types.AuthInfo{ClientID: "tai-001"}, nil)
defer restore()
srv := httptest.NewServer(newGinRouter())
@ -390,7 +391,7 @@ func TestHandleControl_BadRegisterType(t *testing.T) {
func TestHandleControl_MissingTaiID(t *testing.T) {
setupTestRegistry()
restore := mockAuth(registry.AuthInfo{ClientID: "tai-001"}, nil)
restore := mockAuth(types.AuthInfo{ClientID: "tai-001"}, nil)
defer restore()
srv := httptest.NewServer(newGinRouter())
@ -432,7 +433,7 @@ func TestHandleData_NoAuth(t *testing.T) {
func TestHandleData_AcceptSuccess(t *testing.T) {
reg := setupTestRegistry()
restore := mockAuth(registry.AuthInfo{ClientID: "tai-001"}, nil)
restore := mockAuth(types.AuthInfo{ClientID: "tai-001"}, nil)
defer restore()
resultCh := make(chan net.Conn, 1)
@ -468,7 +469,7 @@ func TestHandleData_AcceptSuccess(t *testing.T) {
func TestHandleData_ChannelNotPending(t *testing.T) {
setupTestRegistry()
restore := mockAuth(registry.AuthInfo{ClientID: "tai-001"}, nil)
restore := mockAuth(types.AuthInfo{ClientID: "tai-001"}, nil)
defer restore()
srv := httptest.NewServer(newGinRouter())
@ -491,7 +492,7 @@ func TestHandleData_ChannelNotPending(t *testing.T) {
func TestHandleData_TaiIDMismatch(t *testing.T) {
reg := setupTestRegistry()
restore := mockAuth(registry.AuthInfo{ClientID: "tai-intruder"}, nil)
restore := mockAuth(types.AuthInfo{ClientID: "tai-intruder"}, nil)
defer restore()
resultCh := make(chan net.Conn, 1)
@ -520,7 +521,7 @@ func TestHandleData_TaiIDMismatch(t *testing.T) {
func TestHandleControl_OpenChannelAndBridge(t *testing.T) {
reg := setupTestRegistry()
restore := mockAuth(registry.AuthInfo{
restore := mockAuth(types.AuthInfo{
ClientID: "tai-001",
Subject: "user-test",
}, 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"
"strings"
"github.com/yaoapp/yao/tai/sandbox"
"github.com/yaoapp/yao/tai/runtime"
)
const defaultVNCContainerPort = 6080
@ -78,11 +78,11 @@ func (t *tunnelVNC) Ping(_ context.Context, _ string) error {
// --- Local implementation ---
type localVNC struct {
sb sandbox.Sandbox
sb runtime.Runtime
}
// NewLocal creates a VNC that resolves host VNC ports via sandbox.Inspect.
func NewLocal(sb sandbox.Sandbox) VNC {
// NewLocal creates a VNC that resolves host VNC ports via runtime.Inspect.
func NewLocal(sb runtime.Runtime) VNC {
return &localVNC{sb: sb}
}

View file

@ -8,7 +8,7 @@ import (
"testing"
"time"
"github.com/yaoapp/yao/tai/sandbox"
"github.com/yaoapp/yao/tai/runtime"
)
func TestRemoteURL(t *testing.T) {
@ -63,10 +63,10 @@ func TestRemotePingError(t *testing.T) {
func TestLocalURL(t *testing.T) {
mock := &mockSandbox{
inspectFn: func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) {
return &sandbox.ContainerInfo{
inspectFn: func(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
return &runtime.ContainerInfo{
ID: id,
Ports: []sandbox.PortMapping{
Ports: []runtime.PortMapping{
{ContainerPort: 6080, HostPort: 49152, HostIP: "127.0.0.1", Protocol: "tcp"},
},
}, nil
@ -86,10 +86,10 @@ func TestLocalURL(t *testing.T) {
func TestLocalURLEmptyHostIP(t *testing.T) {
mock := &mockSandbox{
inspectFn: func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) {
return &sandbox.ContainerInfo{
inspectFn: func(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
return &runtime.ContainerInfo{
ID: id,
Ports: []sandbox.PortMapping{
Ports: []runtime.PortMapping{
{ContainerPort: 6080, HostPort: 49152, HostIP: "", Protocol: "tcp"},
},
}, nil
@ -109,8 +109,8 @@ func TestLocalURLEmptyHostIP(t *testing.T) {
func TestLocalURLPortNotFound(t *testing.T) {
mock := &mockSandbox{
inspectFn: func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) {
return &sandbox.ContainerInfo{ID: id}, nil
inspectFn: func(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
return &runtime.ContainerInfo{ID: id}, nil
},
}
@ -123,7 +123,7 @@ func TestLocalURLPortNotFound(t *testing.T) {
func TestLocalURLInspectError(t *testing.T) {
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")
},
}
@ -157,10 +157,10 @@ func TestLocalPingSuccess(t *testing.T) {
}
mock := &mockSandbox{
inspectFn: func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) {
return &sandbox.ContainerInfo{
inspectFn: func(ctx context.Context, id string) (*runtime.ContainerInfo, error) {
return &runtime.ContainerInfo{
ID: id,
Ports: []sandbox.PortMapping{
Ports: []runtime.PortMapping{
{ContainerPort: 6080, HostPort: port, HostIP: "127.0.0.1", Protocol: "tcp"},
},
}, nil
@ -175,7 +175,7 @@ func TestLocalPingSuccess(t *testing.T) {
func TestLocalPingError(t *testing.T) {
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")
},
}
@ -186,12 +186,12 @@ func TestLocalPingError(t *testing.T) {
}
}
// mockSandbox implements sandbox.Sandbox for testing.
// mockSandbox implements runtime.Sandbox for testing.
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
}
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
}
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
}
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
}
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 {
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
}
func (m *mockSandbox) Close() error { return nil }

View file

@ -2,6 +2,7 @@ package jsapi_test
import (
"os"
"strconv"
"strings"
"testing"
"time"
@ -34,19 +35,48 @@ func setupForMode(t *testing.T, m testMode) {
test.Prepare(t, config.Conf)
registry.Init(nil)
var client *tai.Client
var err error
if m.Addr == "local" {
dataDir := t.TempDir()
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 {
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) {

View file

@ -8,6 +8,7 @@ import (
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
taitypes "github.com/yaoapp/yao/tai/types"
"github.com/yaoapp/yao/tai/volume"
taiworkspace "github.com/yaoapp/yao/tai/workspace"
)
@ -20,7 +21,6 @@ func M() *Manager {
}
// Manager owns workspace CRUD, file I/O, and node management.
// All node/client lookups go through tai.GetClient → registry.
type Manager struct{}
// NewManager creates a workspace manager.
@ -34,7 +34,7 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, e
return nil, ErrNodeMissing
}
client, ok := tai.GetClient(opts.Node)
res, ok := tai.GetResources(opts.Node)
if !ok {
return nil, ErrNodeOffline
}
@ -55,8 +55,7 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, e
UpdatedAt: now,
}
vol := client.Volume()
vol := res.Volume
if err := vol.MkdirAll(ctx, id, "."); err != nil {
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.
// Scans all registered nodes.
func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error) {
for _, snap := range listNodes() {
client, ok := tai.GetClient(snap.TaiID)
res, ok := tai.GetResources(snap.TaiID)
if !ok {
continue
}
ws, err := readMeta(ctx, client, id)
ws, err := readMeta(ctx, res.Volume, id)
if err != nil {
continue
}
@ -99,11 +97,11 @@ func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, err
if opts.Node != "" && snap.TaiID != opts.Node {
continue
}
client, ok := tai.GetClient(snap.TaiID)
res, ok := tai.GetResources(snap.TaiID)
if !ok {
continue
}
entries, err := client.Volume().ListDir(ctx, "", ".")
entries, err := res.Volume.ListDir(ctx, "", ".")
if err != nil {
continue
}
@ -111,7 +109,7 @@ func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, err
if !e.IsDir {
continue
}
ws, err := readMeta(ctx, client, e.Path)
ws, err := readMeta(ctx, res.Volume, e.Path)
if err != nil {
continue
}
@ -128,9 +126,8 @@ func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, err
}
// 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) {
ws, client, err := m.resolve(ctx, id)
ws, vol, err := m.resolve(ctx, id)
if err != nil {
return nil, err
}
@ -147,7 +144,7 @@ func (m *Manager) Update(ctx context.Context, id string, opts UpdateOptions) (*W
if err != nil {
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 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.
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 {
return err
}
vol := client.Volume()
if err := vol.Remove(ctx, id, ".", true); err != nil {
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.
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 {
return nil, err
}
return client.Workspace(id), nil
return taiworkspace.New(vol, id), nil
}
// ReadFile reads a file from the workspace.
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 {
return nil, err
}
data, _, err := client.Volume().ReadFile(ctx, id, path)
data, _, err := vol.ReadFile(ctx, id, path)
return data, err
}
// WriteFile writes a file to the workspace.
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 {
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.
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 {
return nil, err
}
entries, err := client.Volume().ListDir(ctx, id, path)
entries, err := vol.ListDir(ctx, id, path)
if err != nil {
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.
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 {
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.
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 {
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.
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 {
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.
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 {
return nil, "", err
}
return client.Volume(), id, nil
return vol, id, nil
}
// 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) {
ws, _, err := m.resolve(ctx, id)
if err != nil {
@ -275,47 +269,55 @@ func (m *Manager) NodeForWorkspace(ctx context.Context, id string) (string, erro
return ws.Node, nil
}
// MountPath returns the host-side directory path for a workspace,
// suitable for use as a Docker bind mount source.
// MountPath returns the host-side directory path for a workspace.
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 {
return "", err
}
dataDir := client.DataDir()
if dataDir == "" {
return "", nil
_ = vol
for _, snap := range listNodes() {
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 ---
// resolve finds the workspace and its tai.Client by scanning all registered nodes.
func (m *Manager) resolve(ctx context.Context, id string) (*Workspace, *tai.Client, error) {
// resolve finds the workspace and its Volume by scanning all registered nodes.
func (m *Manager) resolve(ctx context.Context, id string) (*Workspace, volume.Volume, error) {
for _, snap := range listNodes() {
client, ok := tai.GetClient(snap.TaiID)
res, ok := tai.GetResources(snap.TaiID)
if !ok {
continue
}
ws, err := readMeta(ctx, client, id)
ws, err := readMeta(ctx, res.Volume, id)
if err != nil {
continue
}
return ws, client, nil
return ws, res.Volume, nil
}
return nil, nil, ErrNotFound
}
func readMeta(ctx context.Context, client *tai.Client, id string) (*Workspace, error) {
data, _, err := client.Volume().ReadFile(ctx, id, metadataFile)
func readMeta(ctx context.Context, vol volume.Volume, id string) (*Workspace, error) {
data, _, err := vol.ReadFile(ctx, id, metadataFile)
if err != nil {
return nil, err
}
return unmarshalMeta(data)
}
func listNodes() []registry.NodeSnapshot {
func listNodes() []taitypes.NodeMeta {
reg := registry.Global()
if reg == nil {
return nil

View file

@ -4,6 +4,7 @@ import (
"context"
"net/url"
"os"
"strconv"
"strings"
"testing"
"time"
@ -60,32 +61,52 @@ func ensureRegistry(tb testing.TB) {
func setupManagerForPool(tb testing.TB, pc poolConfig) *workspace.Manager {
tb.Helper()
ensureRegistry(tb)
registerClient(tb, pc)
registerForTest(tb, pc)
return workspace.NewManager()
}
func registerClient(tb testing.TB, pc poolConfig) *tai.Client {
func registerForTest(tb testing.TB, pc poolConfig) {
tb.Helper()
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 {
tb.Fatalf("tai.New(%s): %v", pc.Addr, err)
tb.Fatalf("DialRemote(%s): %v", pc.Addr, err)
}
tb.Cleanup(func() { client.Close() })
return client
taiID := taiIDFromAddr(pc.Addr)
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()
vol := volume.NewLocal(dataDir)
client, err := tai.New("local", tai.WithVolume(vol), tai.WithDataDir(dataDir))
res, err := tai.DialLocal("", dataDir, vol)
if err != nil {
tb.Fatalf("tai.New local: %v", err)
tb.Fatalf("DialLocal: %v", err)
}
tb.Cleanup(func() { client.Close() })
return client
reg := registry.Global()
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) {
@ -94,19 +115,26 @@ func setupManagerMultiNode(t *testing.T) (*workspace.Manager, string, string) {
dir1 := t.TempDir()
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 {
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()
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 {
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 {