diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index d4e872e9..b86b3670 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -1071,7 +1071,7 @@ jobs: - name: Pull Test Images run: | docker pull yaoapp/tai-sandbox-test:latest || true - docker pull yaoapp/tai:latest + docker pull yaoapp/tai:1.2.0 docker pull alpine:latest - name: Install k3d @@ -1087,26 +1087,27 @@ jobs: run: | docker run -d --name tai-docker \ -v /var/run/docker.sock:/var/run/docker.sock \ - -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \ - yaoapp/tai:latest + -p 19100:19100 -p 8099:8099 -p 12375:12375 -p 16080:16080 \ + yaoapp/tai:1.2.0 server \ + -grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -docker 0.0.0.0:12375 for i in $(seq 1 30); do - if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then + if curl -sf http://127.0.0.1:8099/healthz > /dev/null 2>&1; then echo "Tai Docker HTTP ready"; break fi echo "Waiting for Tai Docker HTTP... ($i)"; sleep 1 done - curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1 || { + curl -sf http://127.0.0.1:8099/healthz > /dev/null 2>&1 || { echo "::error::Tai Docker HTTP failed"; docker logs tai-docker 2>&1; exit 1 } for i in $(seq 1 15); do - if nc -z 127.0.0.1 9100 2>/dev/null; then + if nc -z 127.0.0.1 19100 2>/dev/null; then echo "Tai Docker gRPC ready"; break fi echo "Waiting for Tai Docker gRPC... ($i)"; sleep 1 done - nc -z 127.0.0.1 9100 2>/dev/null || { + nc -z 127.0.0.1 19100 2>/dev/null || { echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1 } @@ -1136,44 +1137,48 @@ jobs: docker run -d --name tai-k8s \ --network k3d-tai-test \ - -p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \ + -p 19101:19100 -p 8100:8099 -p 6443:16443 -p 16081:16080 \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ -v /tmp/kubeconfig-tai-k8s.yml:/etc/tai/kubeconfig.yml:ro \ -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ -e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \ - yaoapp/tai:latest + yaoapp/tai:1.2.0 server \ + -grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -k8s 0.0.0.0:16443 for i in $(seq 1 30); do - if curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1; then + if curl -sf http://127.0.0.1:8100/healthz > /dev/null 2>&1; then echo "Tai K8s HTTP ready"; break fi echo "Waiting for Tai K8s HTTP... ($i)"; sleep 1 done - curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1 || { + curl -sf http://127.0.0.1:8100/healthz > /dev/null 2>&1 || { echo "::error::Tai K8s HTTP failed"; docker logs tai-k8s 2>&1; exit 1 } for i in $(seq 1 15); do - if nc -z 127.0.0.1 9101 2>/dev/null; then + if nc -z 127.0.0.1 19101 2>/dev/null; then echo "Tai K8s gRPC ready"; break fi echo "Waiting for Tai K8s gRPC... ($i)"; sleep 1 done - nc -z 127.0.0.1 9101 2>/dev/null || { + nc -z 127.0.0.1 19101 2>/dev/null || { echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1 } - name: Run Sandbox V2 CI Tests (tai + workspace) env: TAI_TEST_HOST: "127.0.0.1" - TAI_TEST_DOCKER: "tcp://127.0.0.1:2375" - TAI_TEST_GRPC_PORT: "9100" + TAI_TEST_DOCKER: "tcp://127.0.0.1:12375" + TAI_TEST_GRPC_PORT: "19100" + TAI_TEST_HTTP_PORT: "8099" + TAI_TEST_VNC_PORT: "16080" + TAI_TEST_DOCKER_PORT: "12375" TAI_TEST_K8S_HOST: "127.0.0.1" TAI_TEST_K8S_PORT: "6443" - TAI_TEST_K8S_GRPC_PORT: "9101" + TAI_TEST_K8S_GRPC_PORT: "19101" TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml" TAI_TEST_HOST_IP: "172.17.0.1" - SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100" + SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:19100" SANDBOX_TEST_IMAGE: "yaoapp/tai-sandbox-test:latest" run: make unit-test-sandbox-v2 diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 519f655f..661b0dad 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -782,7 +782,7 @@ jobs: - name: Pull Test Images run: | docker pull yaoapp/tai-sandbox-test:latest || true - docker pull yaoapp/tai:latest + docker pull yaoapp/tai:1.2.0 docker pull alpine:latest - name: Install k3d @@ -798,26 +798,27 @@ jobs: run: | docker run -d --name tai-docker \ -v /var/run/docker.sock:/var/run/docker.sock \ - -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \ - yaoapp/tai:latest + -p 19100:19100 -p 8099:8099 -p 12375:12375 -p 16080:16080 \ + yaoapp/tai:1.2.0 server \ + -grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -docker 0.0.0.0:12375 for i in $(seq 1 30); do - if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then + if curl -sf http://127.0.0.1:8099/healthz > /dev/null 2>&1; then echo "Tai Docker HTTP ready"; break fi echo "Waiting for Tai Docker HTTP... ($i)"; sleep 1 done - curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1 || { + curl -sf http://127.0.0.1:8099/healthz > /dev/null 2>&1 || { echo "::error::Tai Docker HTTP failed"; docker logs tai-docker 2>&1; exit 1 } for i in $(seq 1 15); do - if nc -z 127.0.0.1 9100 2>/dev/null; then + if nc -z 127.0.0.1 19100 2>/dev/null; then echo "Tai Docker gRPC ready"; break fi echo "Waiting for Tai Docker gRPC... ($i)"; sleep 1 done - nc -z 127.0.0.1 9100 2>/dev/null || { + nc -z 127.0.0.1 19100 2>/dev/null || { echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1 } @@ -847,45 +848,49 @@ jobs: docker run -d --name tai-k8s \ --network k3d-tai-test \ - -p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \ + -p 19101:19100 -p 8100:8099 -p 6443:16443 -p 16081:16080 \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ -v /tmp/kubeconfig-tai-k8s.yml:/etc/tai/kubeconfig.yml:ro \ -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ -e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \ - yaoapp/tai:latest + yaoapp/tai:1.2.0 server \ + -grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -k8s 0.0.0.0:16443 for i in $(seq 1 30); do - if curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1; then + if curl -sf http://127.0.0.1:8100/healthz > /dev/null 2>&1; then echo "Tai K8s HTTP ready"; break fi echo "Waiting for Tai K8s HTTP... ($i)"; sleep 1 done - curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1 || { + curl -sf http://127.0.0.1:8100/healthz > /dev/null 2>&1 || { echo "::error::Tai K8s HTTP failed"; docker logs tai-k8s 2>&1; exit 1 } for i in $(seq 1 15); do - if nc -z 127.0.0.1 9101 2>/dev/null; then + if nc -z 127.0.0.1 19101 2>/dev/null; then echo "Tai K8s gRPC ready"; break fi echo "Waiting for Tai K8s gRPC... ($i)"; sleep 1 done - nc -z 127.0.0.1 9101 2>/dev/null || { + nc -z 127.0.0.1 19101 2>/dev/null || { echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1 } - name: Run Sandbox V2 CI Tests (tai + workspace) env: TAI_TEST_HOST: "127.0.0.1" - TAI_TEST_DOCKER: "tcp://127.0.0.1:2375" - TAI_TEST_GRPC_PORT: "9100" + TAI_TEST_DOCKER: "tcp://127.0.0.1:12375" + TAI_TEST_GRPC_PORT: "19100" + TAI_TEST_HTTP_PORT: "8099" + TAI_TEST_VNC_PORT: "16080" + TAI_TEST_DOCKER_PORT: "12375" TAI_TEST_K8S_HOST: "127.0.0.1" TAI_TEST_K8S_PORT: "6443" - TAI_TEST_K8S_GRPC_PORT: "9101" + TAI_TEST_K8S_GRPC_PORT: "19101" TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml" TAI_TEST_HOST_IP: "172.17.0.1" - SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100" - SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest" + SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:19100" + SANDBOX_TEST_IMAGE: "yaoapp/tai-sandbox-test:latest" run: make unit-test-sandbox-v2 - name: Codecov Report diff --git a/sandbox/v2/docs/API.md b/sandbox/v2/docs/API.md index 468270ec..1ce2b865 100644 --- a/sandbox/v2/docs/API.md +++ b/sandbox/v2/docs/API.md @@ -26,7 +26,7 @@ err := sandbox.Init(sandbox.Config{ Pool: []sandbox.Pool{ { Name: "docker", - Addr: "tai://192.168.1.10:9100", + Addr: "tai://192.168.1.10:19100", MaxPerUser: 5, MaxTotal: 20, IdleTimeout: 30 * time.Minute, @@ -247,7 +247,7 @@ Registers a new pool at runtime. ```go err := sandbox.M().AddPool(ctx, sandbox.Pool{ Name: "k8s-gpu", - Addr: "tai://10.0.0.5:9100", + Addr: "tai://10.0.0.5:19100", MaxTotal: 10, }) ``` @@ -423,7 +423,7 @@ Returns the VNC WebSocket URL for the sandbox (requires `VNC: true` at creation) ```go url, err := box.VNC(ctx) -// url = "ws://tai-host:6080/websockify?container=xxx" +// url = "ws://tai-host:16080/vnc/xxx/ws" ``` ### Proxy @@ -436,7 +436,7 @@ Returns the HTTP proxy URL for a service on the given port. ```go url, err := box.Proxy(ctx, 3000, "/api/health") -// url = "http://tai-host:8080/proxy/container-id/3000/api/health" +// url = "http://tai-host:8099/container-id:3000/api/health" ``` ### Workspace @@ -581,7 +581,7 @@ func WithTimeout(timeout time.Duration) ExecOption ## AttachOption Functions ```go -func WithProtocol(protocol string) AttachOption // "ws" (default), "tcp" +func WithProtocol(protocol string) AttachOption // "ws" (default) or "sse" func WithPath(path string) AttachOption // URL path on the target service func WithHeaders(headers map[string]string) AttachOption ``` @@ -802,6 +802,6 @@ Builds environment variables injected into sandbox containers: | `YAO_GRPC_ADDR` | gRPC server address (auto-derived) | Address derivation logic: -- `tai://host:port` → `host:port` +- `tai://host:port` → `host:port` (default port 19100 when omitted) - `tunnel://...` → `127.0.0.1:` - Local/default → `127.0.0.1:` diff --git a/sandbox/v2/grpc.go b/sandbox/v2/grpc.go index 41561d45..5f99eab7 100644 --- a/sandbox/v2/grpc.go +++ b/sandbox/v2/grpc.go @@ -63,7 +63,7 @@ func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) m taiHost := u.Hostname() taiPort := u.Port() if taiPort == "" { - taiPort = "9100" + taiPort = "19100" } env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:%s", taiHost, taiPort) diff --git a/sandbox/v2/grpc_test.go b/sandbox/v2/grpc_test.go index a0462406..b9905988 100644 --- a/sandbox/v2/grpc_test.go +++ b/sandbox/v2/grpc_test.go @@ -25,8 +25,8 @@ func TestBuildGRPCEnvRemote(t *testing.T) { pool := &sandbox.Pool{Name: "gpu", Addr: "tai://gpu-server"} env := sandbox.BuildGRPCEnv(pool, "sb-002", "access", "refresh", 9099) - if env["YAO_GRPC_ADDR"] != "gpu-server:9100" { - t.Errorf("YAO_GRPC_ADDR = %q, want gpu-server:9100", env["YAO_GRPC_ADDR"]) + if env["YAO_GRPC_ADDR"] != "gpu-server:19100" { + t.Errorf("YAO_GRPC_ADDR = %q, want gpu-server:19100", env["YAO_GRPC_ADDR"]) } } diff --git a/sandbox/v2/testutils_test.go b/sandbox/v2/testutils_test.go index 9cb3ae89..4234542f 100644 --- a/sandbox/v2/testutils_test.go +++ b/sandbox/v2/testutils_test.go @@ -55,7 +55,7 @@ func purgeStaleContainers() { 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", 9100)) + grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 19100)) opts := []tai.Option{ tai.K8s, tai.WithKubeConfig(kubeconfig), @@ -128,7 +128,7 @@ func testPools() []poolConfig { if kubeconfig == "" { return pools } - grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 9100)) + 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, @@ -176,7 +176,7 @@ func hostExecTargets() []hostExecTarget { 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", 9100)) + 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 != "" { diff --git a/tai/api/register_test.go b/tai/api/register_test.go index d8f1b4e3..d027469a 100644 --- a/tai/api/register_test.go +++ b/tai/api/register_test.go @@ -50,7 +50,7 @@ func TestHandleRegister_Success(t *testing.T) { MachineID: "m-001", Version: "0.2.0", Addr: "192.168.1.100", - Ports: map[string]int{"grpc": 9100, "http": 8080}, + Ports: map[string]int{"grpc": 19100, "http": 8099}, Capabilities: map[string]bool{"docker": true, "host_exec": false}, System: registry.SystemInfo{ OS: "linux", Arch: "amd64", Hostname: "docker-host-01", NumCPU: 16, diff --git a/tai/docs/README.md b/tai/docs/README.md index 8048d7c8..5b2c9bd1 100644 --- a/tai/docs/README.md +++ b/tai/docs/README.md @@ -1,6 +1,6 @@ # Tai SDK -Go client library for the [Tai](https://github.com/YaoApp/tai) runtime bridge. Provides unified access to container sandboxes, volume IO, HTTP proxy, and VNC routing — transparently working in both **Local** (direct Docker) and **Remote** (via Tai server) modes. +Go client library for the [Tai](https://github.com/YaoApp/tai) runtime bridge. Provides unified access to container sandboxes, volume IO, HTTP proxy, and VNC routing — transparently working in **Local** (direct Docker), **Remote** (via Tai server), and **Tunnel** (via Yao WebSocket tunnel) modes. ## Package Layout @@ -12,6 +12,11 @@ Go client library for the [Tai](https://github.com/YaoApp/tai) runtime bridge. P | `workspace` | `github.com/yaoapp/yao/tai/workspace` | `fs.FS`-compatible filesystem over Volume | | `proxy` | `github.com/yaoapp/yao/tai/proxy` | HTTP reverse proxy URL resolution | | `vnc` | `github.com/yaoapp/yao/tai/vnc` | VNC WebSocket URL resolution | +| `registry` | `github.com/yaoapp/yao/tai/registry` | In-memory Tai node registry (direct + tunnel) | +| `api` | `github.com/yaoapp/yao/tai/api` | HTTP handlers for node registration/heartbeat | +| `tunnel` | `github.com/yaoapp/yao/tai/tunnel` | WebSocket tunnel server (control + data + proxy) | +| `hostexec/pb` | `github.com/yaoapp/yao/tai/hostexec/pb` | HostExec gRPC client (host command execution) | +| `serverinfo/pb` | `github.com/yaoapp/yao/tai/serverinfo/pb` | ServerInfo gRPC client (port/capability discovery) | ## Quick Start @@ -47,52 +52,71 @@ fmt.Println(result.Stdout) // "hello\n" c, err := tai.New("tai://192.168.1.100", tai.K8s, tai.WithKubeConfig("/path/to/kubeconfig.yml"), tai.WithNamespace("default"), - tai.WithPorts(tai.Ports{K8s: 6443}), + tai.WithPorts(tai.Ports{K8s: 16443}), ) defer c.Close() ``` +### Tunnel Mode (via Yao WebSocket tunnel) + +```go +// Requires a running Yao server with the Tai node registered via tunnel. +// The taiID is the node's identifier in the registry. +c, err := tai.New("tunnel://tai-abc123") +defer c.Close() +``` + ## Address Protocols | Address | Mode | Description | |---------|------|-------------| -| `""` | Local | Platform default Docker socket | +| `"local"` | Local | Platform default Docker socket | +| `"127.0.0.1"` / `"localhost"` / `"::1"` | Local | Auto-detected as local Docker | | `unix:///var/run/docker.sock` | Local | Explicit Unix socket | | `tcp://host:port` | Local | Explicit TCP Docker daemon | | `npipe:////./pipe/docker_engine` | Local | Windows named pipe | | `docker://host:port` | Local | Docker scheme | -| `tai://host` | Remote | Connect via Tai server | +| `tai://host` | Remote | Connect via Tai server (gRPC default 19100) | +| `tai://host:port` | Remote | Connect via Tai server on custom gRPC port | +| `tunnel://tai-id` | Tunnel | Connect via Yao WebSocket tunnel | +| `192.168.x.x` (non-local IP) | Remote | Auto-prepends `tai://` | ## Options | Option | Description | Default | |--------|-------------|---------| -| `WithPorts(Ports{...})` | Override Tai service ports | gRPC=9100, HTTP=8080, VNC=6080 | +| `WithPorts(Ports{...})` | Override Tai service ports (takes precedence over ServerInfo) | gRPC=19100, HTTP=8099, VNC=16080 | | `WithHTTPClient(*http.Client)` | Custom HTTP client for proxy/VNC | `http.DefaultClient` | | `WithDataDir(path)` | Volume storage root (Local mode) | `/tmp/tai-volumes` | | `WithKubeConfig(path)` | Kubeconfig file path (K8s mode, **required**) | - | | `WithNamespace(ns)` | K8s namespace | `"default"` | +| `WithVolume(vol)` | Inject custom Volume implementation (testing) | - | ## Default Ports | Service | Port | Description | |---------|------|-------------| -| gRPC | 9100 | Volume IO + Gateway | -| HTTP | 8080 | HTTP reverse proxy | -| VNC | 6080 | VNC WebSocket router | -| Docker | 2375 | Docker API proxy | -| K8s | 6443 | Kubernetes API proxy | +| gRPC | 19100 | Volume IO + Gateway + ServerInfo + HostExec | +| HTTP | 8099 | HTTP reverse proxy | +| VNC | 16080 | VNC WebSocket router | +| Docker | 12375 | Docker API proxy | +| K8s | 16443 | Kubernetes API proxy | + +Ports are auto-discovered via Tai's `ServerInfo.GetInfo` gRPC call. Values set via `WithPorts` take precedence over server-reported values. ## Client API ```go -c.Volume() // volume.Volume -c.Workspace(sessionID) // workspace.FS -c.Sandbox() // sandbox.Sandbox -c.Proxy() // proxy.Proxy -c.VNC() // vnc.VNC -c.IsLocal() // bool -c.Close() // error +c.Volume() // volume.Volume — file IO (never nil) +c.Workspace(sessionID) // workspace.FS — fs.FS over Volume +c.DataDir() // string — host-side data directory (Local mode only) +c.Sandbox() // sandbox.Sandbox — container lifecycle (nil if host-exec-only) +c.Image() // sandbox.Image — image management (nil if host-exec-only) +c.Proxy() // proxy.Proxy — HTTP reverse proxy (nil if host-exec-only) +c.VNC() // vnc.VNC — VNC WebSocket (nil if host-exec-only) +c.HostExec() // hepb.HostExecClient — host command execution (nil in local mode) +c.IsLocal() // bool — true for local mode (docker/unix/tcp/npipe/local) +c.Close() // error — releases all resources ``` ## Runtime Constants @@ -102,10 +126,37 @@ tai.Docker // default — use Docker runtime via Tai tai.K8s // use Kubernetes runtime via Tai ``` +## Yao gRPC Compatibility + +The `tai` package re-exports Yao gRPC helpers for backward compatibility: + +```go +tai.NewTokenManagerFromEnv() // *TokenManager from env vars +tai.NewTokenManager(access, refresh, sandboxID) +tai.NewYaoClientFromEnv() // *YaoClient from env vars +tai.DialYao(addr, tm) // connect to Yao gRPC +tai.Run(ctx, client, process, args, timeout) // execute Yao process +tai.Shell(ctx, client, cmd, args, env, timeout) // execute shell command +tai.HeartbeatLoop(ctx, client, sandboxID) // periodic heartbeat (blocks) +``` + +New code should use `grpc/client` directly. These wrappers exist for sandbox/container code that imports `tai`. + +## Capabilities + +When connecting to a remote Tai server, the client calls `ServerInfo.GetInfo` to discover: +- **Ports**: actual listening ports (http, docker, vnc, k8s) +- **Capabilities**: `docker`, `k8s`, `host_exec` + +If no usable capabilities are found, `New()` returns an error. Remote mode checks `docker`/`k8s`/`host_exec`; Tunnel mode checks `docker`/`host_exec` (K8s is not supported over tunnel). + ## Sub-Package Documentation -- [sandbox.md](sandbox.md) — Container lifecycle management +- [sandbox.md](sandbox.md) — Container lifecycle & Image management - [volume.md](volume.md) — File IO and sync - [workspace.md](workspace.md) — fs.FS-compatible filesystem - [proxy.md](proxy.md) — HTTP reverse proxy - [vnc.md](vnc.md) — VNC WebSocket routing +- [registry.md](registry.md) — Tai node registry (direct + tunnel) +- [api.md](api.md) — HTTP registration API +- [tunnel.md](tunnel.md) — WebSocket tunnel handlers diff --git a/tai/docs/api.md b/tai/docs/api.md new file mode 100644 index 00000000..fcb63a4a --- /dev/null +++ b/tai/docs/api.md @@ -0,0 +1,135 @@ +# Package `api` + +HTTP handlers for Tai node registration, heartbeat, and unregistration. Built on [Gin](https://github.com/gin-gonic/gin), these handlers are mounted on the Yao server to allow remote Tai instances to register themselves. + +## Routes + +| Method | Path | Handler | Description | +|--------|------|---------|-------------| +| `POST` | `/tai-nodes/register` | `HandleRegister` | Register a Tai node | +| `POST` | `/tai-nodes/heartbeat` | `HandleHeartbeat` | Update heartbeat timestamp | +| `DELETE` | `/tai-nodes/register/:tai_id` | `HandleUnregister` | Remove a Tai node | + +All endpoints require a `Bearer` token in the `Authorization` header. Tokens are validated via the Yao OAuth service. + +## Authentication + +``` +Authorization: Bearer +``` + +The token is validated against `oauth.OAuth.AuthenticateToken()`. On success, an `AuthInfo` is extracted containing `Subject`, `UserID`, `ClientID`, `Scope`, `TeamID`, and `TenantID`. The `ClientID` is used for ownership checks on heartbeat and unregister. + +## Endpoints + +### POST /tai-nodes/register + +Registers a new Tai node in the global registry. + +**Request Body:** + +```json +{ + "tai_id": "tai-abc123", + "machine_id": "m-001", + "version": "1.2.0", + "addr": "192.168.1.100", + "ports": {"grpc": 19100, "http": 8099, "vnc": 16080, "docker": 12375}, + "capabilities": {"docker": true, "host_exec": true}, + "system": { + "os": "linux", + "arch": "amd64", + "hostname": "docker-host-01", + "num_cpu": 16, + "total_mem": 34359738368 + } +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `tai_id` | string | yes | Unique identifier for this Tai instance | +| `machine_id` | string | no | Host machine identifier | +| `version` | string | no | Tai version string | +| `addr` | string | no | Reachable address of the Tai server | +| `ports` | map[string]int | no | Service ports (grpc, http, vnc, docker, k8s) | +| `capabilities` | map[string]bool | no | Supported features (docker, k8s, host_exec) | +| `system` | object | no | Host system information | + +**Response (200):** + +```json +{ + "status": "registered", + "tai_id": "tai-abc123", + "remote_ip": "203.0.113.50" +} +``` + +**Errors:** + +| Code | Condition | +|------|-----------| +| 400 | Missing `tai_id` or invalid JSON body | +| 401 | Missing or invalid Bearer token | +| 500 | Registry not initialized | + +### POST /tai-nodes/heartbeat + +Updates the `LastPing` timestamp for a registered node. The node's `ClientID` must match the token's `ClientID`. + +**Request Body:** + +```json +{ + "tai_id": "tai-abc123" +} +``` + +**Response (200):** + +```json +{ + "status": "ok" +} +``` + +**Errors:** + +| Code | Condition | +|------|-----------| +| 400 | Missing `tai_id` or invalid JSON body | +| 401 | Missing or invalid Bearer token | +| 403 | `tai_id` belongs to a different client | +| 404 | `tai_id` not found in registry | +| 500 | Registry not initialized | + +### DELETE /tai-nodes/register/:tai_id + +Removes a registered node. The node's `ClientID` must match the token's `ClientID`. + +**Response (200):** + +```json +{ + "status": "unregistered" +} +``` + +**Errors:** + +| Code | Condition | +|------|-----------| +| 400 | Missing `tai_id` path parameter | +| 401 | Missing or invalid Bearer token | +| 403 | `tai_id` belongs to a different client | +| 404 | `tai_id` not found in registry | +| 500 | Registry not initialized | + +## Node Mode + +Nodes registered via this HTTP API are marked with `Mode: "direct"`. This means the Yao server can reach the Tai instance directly over the network. For tunnel-mode nodes (registered via WebSocket), see [registry.md](registry.md). + +## Health Check + +The registry runs a background health checker (started via `Registry.StartHealthCheck`). Direct-mode nodes that miss heartbeats beyond the configured timeout are marked `"offline"`. Nodes that remain offline longer than the cleanup threshold are automatically unregistered. diff --git a/tai/docs/proxy.md b/tai/docs/proxy.md index 97411a5c..216d7f0d 100644 --- a/tai/docs/proxy.md +++ b/tai/docs/proxy.md @@ -7,6 +7,7 @@ HTTP reverse proxy URL resolution. Resolves service URLs for containers so that ```go type Proxy interface { URL(ctx context.Context, containerID string, port int, path string) (string, error) + Connect(ctx context.Context, containerID string, opts ConnectOptions) (*Connection, error) Healthz(ctx context.Context) error } ``` @@ -15,8 +16,9 @@ type Proxy interface { | Implementation | Constructor | Mode | URL Pattern | |----------------|-------------|------|-------------| -| **Remote** | `NewRemote(host, port, hc)` | Via Tai HTTP proxy | `http://tai-host:8080/{containerID}:{port}/{path}` | +| **Remote** | `NewRemote(host, port, hc)` | Via Tai HTTP proxy | `http://tai-host:8099/{containerID}:{port}/{path}` | | **Local** | `NewLocal(sb)` | Direct host port lookup | `http://127.0.0.1:{hostPort}/{path}` | +| **Tunnel** | `NewTunnel(taiID, yaoBase)` | Via Yao reverse proxy | `{yaoBase}/tai/{taiID}/proxy/{containerID}:{port}/{path}` | ## Constructors @@ -29,7 +31,7 @@ func NewRemote(host string, port int, hc *http.Client) Proxy Creates a Proxy that routes through Tai's HTTP reverse proxy. URLs are constructed by combining the Tai server address with the container ID and port. - `host` — Tai server hostname/IP -- `port` — Tai HTTP proxy port (default 8080) +- `port` — Tai HTTP proxy port (default 8099) - `hc` — custom HTTP client, `nil` uses `http.DefaultClient` ### NewLocal @@ -42,6 +44,17 @@ Creates a Proxy that resolves URLs by inspecting the container's port mappings v Returns an error if the requested port is not mapped. +### NewTunnel + +```go +func NewTunnel(taiID, yaoBase string) Proxy +``` + +Creates a Proxy that routes through Yao's HTTP reverse proxy for tunnel-mode connections. + +- `taiID` — the Tai node identifier in the registry +- `yaoBase` — the Yao server base URL (e.g. `"http://yao-server:5099"`) + ## Methods ### URL @@ -53,11 +66,44 @@ URL(ctx context.Context, containerID string, port int, path string) (string, err Resolves an HTTP URL to reach a service running on `port` inside the given container. **Remote example:** container `abc123` port `3000` path `/api/health` -→ `http://tai-host:8080/abc123:3000/api/health` +→ `http://tai-host:8099/abc123:3000/api/health` **Local example:** container `abc123` port `3000` mapped to host port `32768` → `http://127.0.0.1:32768/api/health` +### Connect + +```go +Connect(ctx context.Context, containerID string, opts ConnectOptions) (*Connection, error) +``` + +Establishes a persistent connection to a container service. Supports WebSocket and SSE protocols. + +### ConnectOptions + +```go +type ConnectOptions struct { + Port int // container port + Path string // URL path (e.g. "/ws" or "/events") + Protocol string // "ws" or "sse" +} +``` + +### Connection + +```go +type Connection struct { + Messages <-chan []byte // incoming data; closed when connection ends + Send func(data []byte) error // write data (only valid for "ws" protocol) + Close func() error // terminate the connection +} +``` + +| Protocol | Messages | Send | Description | +|----------|----------|------|-------------| +| `"ws"` | WebSocket messages | write to WS | Full-duplex WebSocket | +| `"sse"` | SSE `data:` lines | returns error | Read-only Server-Sent Events | + ### Healthz ```go @@ -68,6 +114,7 @@ Checks the health of the proxy backend. - **Remote**: sends `GET /healthz` to the Tai HTTP proxy server - **Local**: always returns `nil` (no external dependency) +- **Tunnel**: always returns `nil` ## Example @@ -83,4 +130,23 @@ resp, _ := http.Get(url) if err := c.Proxy().Healthz(ctx); err != nil { log.Fatal("Tai HTTP proxy is down:", err) } + +// WebSocket connection to a service +conn, _ := c.Proxy().Connect(ctx, containerID, proxy.ConnectOptions{ + Port: 8080, Path: "/ws", Protocol: "ws", +}) +defer conn.Close() +conn.Send([]byte(`{"action":"ping"}`)) +for msg := range conn.Messages { + fmt.Println(string(msg)) +} + +// SSE event stream +conn, _ = c.Proxy().Connect(ctx, containerID, proxy.ConnectOptions{ + Port: 8080, Path: "/events", Protocol: "sse", +}) +defer conn.Close() +for msg := range conn.Messages { + fmt.Println("event:", string(msg)) +} ``` diff --git a/tai/docs/registry.md b/tai/docs/registry.md new file mode 100644 index 00000000..a8f46834 --- /dev/null +++ b/tai/docs/registry.md @@ -0,0 +1,178 @@ +# Package `registry` + +In-memory registry for Tai nodes. Manages both **direct** (network-reachable) and **tunnel** (WebSocket-bridged) connections. Used server-side by Yao to track all connected Tai instances. + +## Architecture + +``` +Direct Mode: Yao ── TCP ──> Tai (gRPC/HTTP/Docker/VNC) +Tunnel Mode: Yao <── WS ── Tai (control channel) + Yao <── WS ── Tai (data channels, on-demand) +``` + +## Types + +### TaiNode + +```go +type TaiNode struct { + TaiID string + MachineID string + Version string + Auth AuthInfo + System SystemInfo + Mode string // "direct" | "tunnel" + Addr string // direct: "tai-host"; tunnel: empty + YaoBase string // tunnel: Yao server base URL + Ports map[string]int // {"grpc":19100, "http":8099, ...} + Capabilities map[string]bool // {"docker":true, "host_exec":true} + ControlConn *websocket.Conn // tunnel: WS control channel + Status string // "online" | "offline" | "connecting" + ConnectedAt time.Time + LastPing time.Time + PoolName string +} +``` + +### NodeSnapshot + +Read-only copy of `TaiNode` safe to use outside locks. Returned by `Get()` and `List()`. + +```go +type NodeSnapshot struct { + TaiID, MachineID, Version string + Auth AuthInfo + System SystemInfo + Mode, Addr, YaoBase string + Ports map[string]int + Capabilities map[string]bool + Status string + ConnectedAt, LastPing time.Time + PoolName string +} +``` + +### AuthInfo + +```go +type AuthInfo struct { + Subject string + UserID string + ClientID string + Scope string + TeamID string + TenantID string +} +``` + +### SystemInfo + +```go +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"` +} +``` + +## Registry API + +### Init / Global + +```go +func Init(logger *slog.Logger) +func Global() *Registry +``` + +`Init` creates the global singleton (once). `Global` returns it (nil before Init). + +### Register / Unregister + +```go +func (r *Registry) Register(node *TaiNode) +func (r *Registry) Unregister(taiID string) +``` + +`Register` adds or replaces a node, setting `Status="online"` and recording timestamps. `Unregister` closes all tunnel listeners and the control connection, then removes the node. + +### Query + +```go +func (r *Registry) Get(taiID string) (*NodeSnapshot, bool) +func (r *Registry) List() []NodeSnapshot +func (r *Registry) ListByTeam(teamID string) []NodeSnapshot +``` + +### Heartbeat + +```go +func (r *Registry) UpdatePing(taiID string) +``` + +### Health Check + +```go +func (r *Registry) StartHealthCheck(done <-chan struct{}, interval, timeout, cleanupAfter time.Duration) +``` + +Runs a background goroutine that: +1. Marks direct-mode nodes as `"offline"` if `LastPing` exceeds `timeout` +2. Auto-unregisters nodes that stay offline longer than `timeout + cleanupAfter` + +## Tunnel API + +For tunnel-mode nodes, the registry manages on-demand TCP-over-WebSocket channels. + +### RequestChannel + +```go +func (r *Registry) RequestChannel(taiID string, targetPort int) (channelID string, result chan net.Conn, err error) +``` + +Sends an `{"type":"open", "channel_id":"...", "target_port":...}` command to the node's control WebSocket. Returns a channel that receives the `net.Conn` when Tai connects back with the data channel. Times out after 30 seconds. + +### AcceptDataChannel + +```go +func (r *Registry) AcceptDataChannel(channelID, taiID string, conn net.Conn) error +``` + +Called when Tai establishes a data WebSocket for a pending channel. Validates `taiID` ownership and delivers the connection to the waiting `RequestChannel` caller. + +### WriteControlJSON + +```go +func (r *Registry) WriteControlJSON(taiID string, v interface{}) error +``` + +Thread-safe JSON write to a node's control WebSocket. + +### OpenLocalListener + +```go +func (r *Registry) OpenLocalListener(taiID string, targetPort int) (net.Listener, error) +``` + +Creates a `127.0.0.1:0` TCP listener. Every accepted connection is automatically bridged through the tunnel to `targetPort` on the Tai node. Returns the listener so the caller can read `ln.Addr()` to get the ephemeral port. + +## Connection Flow (Tunnel) + +``` +1. Tai → Yao: WebSocket upgrade to GET /ws/tai (Bearer auth) +2. Tai → Yao: sends {"type":"register", "tai_id":"xxx", ...} on WS +3. Yao: Register(node) with Mode="tunnel", ControlConn=ws +4. Yao → Tai: sends {"type":"registered", "tai_id":"xxx"} +5. Client → Yao: tai.New("tunnel://tai-abc123") +6. Yao: OpenLocalListener("tai-abc123", 19100) → 127.0.0.1:54321 +7. Yao: grpc.Dial("passthrough:///127.0.0.1:54321") → triggers accept +8. Yao: RequestChannel("tai-abc123", 19100) → sends {"type":"open"} on control WS +9. Tai: receives "open", dials localhost:19100, connects data WS to GET /ws/tai/data/:channel_id +10. Yao: AcceptDataChannel(channelID, taiID, conn) → bridges local TCP ↔ data WS +11. gRPC traffic flows transparently through the tunnel +``` + +### Keep-alive + +Tai sends `{"type":"ping"}` periodically on the control channel. Yao replies `{"type":"pong"}` and updates `LastPing`. diff --git a/tai/docs/sandbox.md b/tai/docs/sandbox.md index fd82e0d0..f27e7669 100644 --- a/tai/docs/sandbox.md +++ b/tai/docs/sandbox.md @@ -12,17 +12,32 @@ Container lifecycle management. Provides a unified `Sandbox` interface with thre ```go type Sandbox interface { - Create(ctx context.Context, opts CreateOptions) (id string, err error) + 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 Remove(ctx context.Context, id string, force bool) error Exec(ctx context.Context, id string, cmd []string, opts ExecOptions) (*ExecResult, error) + ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*StreamHandle, error) Inspect(ctx context.Context, id string) (*ContainerInfo, error) List(ctx context.Context, opts ListOptions) ([]ContainerInfo, error) Close() error } ``` +### StreamHandle + +```go +type StreamHandle struct { + Stdin io.WriteCloser + Stdout io.Reader + Stderr io.Reader + Wait func() (int, error) // blocks until exec finishes, returns exit code + Cancel func() // aborts the exec process +} +``` + +`ExecStream` provides real-time I/O access to a running exec process. Unlike `Exec` which collects all output, `ExecStream` returns immediately with readers/writers for interactive use. + ## Constructors ### NewLocal @@ -44,7 +59,7 @@ Pings the daemon on creation; returns an error if unreachable. func NewDocker(addr string) (Sandbox, error) ``` -Connects to Docker Engine API through Tai's Docker proxy. `addr` should be `"tcp://tai-host:2375"`. +Connects to Docker Engine API through Tai's Docker proxy. `addr` should be `"tcp://tai-host:12375"`. ### NewK8s @@ -77,8 +92,10 @@ type CreateOptions struct { WorkingDir string // working directory Memory int64 // memory limit in bytes, 0 = no limit CPUs float64 // CPU limit, 0 = no limit - VNC bool // enable VNC port mapping (Local only) + VNC bool // enable VNC port mapping (Local and Docker modes) Ports []PortMapping // port mappings (Docker only) + Labels map[string]string // container/pod labels for discovery and management + User string // container user, e.g. "1000:1000" or "sandbox" } ``` @@ -97,13 +114,14 @@ type PortMapping struct { ```go type ContainerInfo struct { - ID string // container/pod ID - Name string // container/pod name - Image string // image name - Status string // "created", "running", "exited", "removing" (Docker) - // "Pending", "Running", "Succeeded", "Failed" (K8s) - IP string // container/pod IP address - Ports []PortMapping // mapped ports (Docker only) + ID string // container/pod ID + Name string // container/pod name + Image string // image name + Status string // "created", "running", "exited", "removing" (Docker) + // "Pending", "Running", "Succeeded", "Failed" (K8s) + IP string // container/pod IP address + Ports []PortMapping // mapped ports (Docker only) + Labels map[string]string // container/pod labels } ``` @@ -149,16 +167,88 @@ type K8sOption struct { | Behavior | Docker (Local/Remote) | K8s | |----------|----------------------|-----| | `Create` returns | container ID (hash) | pod name | -| `Start` | starts a stopped container | polls until pod leaves Pending (up to 30s) | +| `Start` | starts a stopped container | polls until pod leaves Pending (up to 60s) | | `Stop` | stops with timeout, container persists | deletes the pod with grace period | | `Remove(force=true)` | force-removes | deletes with grace period 0 | | `Exec` | Docker exec API | `kubectl exec` via SPDY | | `Inspect.Ports` | populated from Docker | always empty | -| `List` | filters by `tai-sdk=true` label | filters by `managed-by=yao-tai-sdk` label | +| `List` | filters only by `opts.Labels` (no auto label) | auto-merges `managed-by=yao-tai-sdk` + `opts.Labels` | | `Binds` | supported | not supported | -| `VNC` flag | auto port-maps 6080 on macOS/Windows | not applicable | +| `VNC` flag | auto port-maps 6080 and 5900 (all platforms) | not applicable | -## Example +## Image Interface + +```go +type Image interface { + Exists(ctx context.Context, ref string) (bool, error) + Pull(ctx context.Context, ref string, opts PullOptions) (<-chan PullProgress, error) + Remove(ctx context.Context, ref string, force bool) error + List(ctx context.Context) ([]ImageInfo, error) +} +``` + +Accessed via `c.Image()` on the top-level client. Nil when the Tai server has no container runtime. + +| Implementation | Constructor | Backend | Notes | +|----------------|-------------|---------|-------| +| **Docker** | `NewDockerImage(cli)` | Docker SDK | Shared by Local and Docker-via-Tai modes | +| **K8s** | `NewK8sImage()` | No-op | Image pulling is handled by kubelet | + +### DockerCli Helper + +```go +func DockerCli(sb Sandbox) *client.Client +``` + +Extracts the underlying Docker SDK client from a `Sandbox` (Local or Docker). Returns `nil` for K8s sandboxes. Used internally to construct `NewDockerImage(DockerCli(sb))`. + +### Types + +```go +type PullOptions struct { + Auth *RegistryAuth // nil = anonymous / public +} + +type RegistryAuth struct { + Username string + Password string + Server string // e.g. "ghcr.io", "registry.example.com" +} + +type PullProgress struct { + Status string // "Pulling fs layer", "Downloading", "Extracting", "Pull complete", etc. + Layer string // layer digest / short ID + Current int64 // bytes completed + Total int64 // bytes total (0 if unknown) + Error string // non-empty on failure +} + +type ImageInfo struct { + ID string + Tags []string + Size int64 + Created time.Time +} +``` + +### Image Example + +```go +c, _ := tai.New("tai://192.168.1.100") +defer c.Close() + +progress, _ := c.Image().Pull(ctx, "alpine:latest", sandbox.PullOptions{}) +for p := range progress { + fmt.Printf("%s %s %d/%d\n", p.Status, p.Layer, p.Current, p.Total) +} + +images, _ := c.Image().List(ctx) +for _, img := range images { + fmt.Printf("%s %v\n", img.ID[:12], img.Tags) +} +``` + +## Sandbox Example ```go sb, _ := sandbox.NewLocal("") diff --git a/tai/docs/tunnel.md b/tai/docs/tunnel.md new file mode 100644 index 00000000..20e854d2 --- /dev/null +++ b/tai/docs/tunnel.md @@ -0,0 +1,91 @@ +# Package `tunnel` + +WebSocket tunnel handlers for Tai nodes that cannot be reached directly (e.g. behind NAT/firewall). Provides Gin HTTP handlers mounted on the Yao server. + +## Handlers + +| Method | Route | Handler | Description | +|--------|-------|---------|-------------| +| `GET` | `/ws/tai` | `HandleControl` | Control channel WebSocket | +| `GET` | `/ws/tai/data/:channel_id` | `HandleData` | Data channel WebSocket | +| `ANY` | `/tai/:taiID/proxy/*path` | `HandleProxy` | HTTP reverse proxy via tunnel | +| `GET` | `/tai/:taiID/vnc/*path` | `HandleVNC` | VNC WebSocket proxy via tunnel | + +All WebSocket endpoints require `Authorization: Bearer ` header. + +## HandleControl + +Manages the long-lived control WebSocket for a Tai node. + +**Flow:** +1. Authenticate Bearer token via OAuth +2. Upgrade to WebSocket +3. Read `register` message (JSON): + +```json +{ + "type": "register", + "tai_id": "tai-abc123", + "machine_id": "m-001", + "version": "1.2.0", + "server": "http://yao-server:5099", + "ports": {"grpc": 19100, "http": 8099, "vnc": 16080, "docker": 12375}, + "capabilities": {"docker": true, "host_exec": true}, + "system": {"os": "linux", "arch": "amd64", "hostname": "host-01", "num_cpu": 8} +} +``` + +4. Register node in global registry with `Mode="tunnel"` +5. Reply `{"type": "registered", "tai_id": "tai-abc123"}` +6. Loop reading messages: + - `{"type": "ping"}` → update heartbeat, reply `{"type": "pong"}` +7. On disconnect → unregister node + +## HandleData + +Handles on-demand data channel connections from Tai. + +**Flow:** +1. Authenticate Bearer token +2. Extract `:channel_id` from URL +3. Upgrade to WebSocket +4. Wrap WebSocket as `net.Conn` (bidirectional byte bridge) +5. Call `registry.AcceptDataChannel(channelID, clientID, conn)` + +The `channel_id` must match a pending `RequestChannel` call. The `clientID` (from token) must match the Tai node that owns the channel. + +## HandleProxy + +HTTP reverse proxy for tunnel-connected Tai nodes. + +**Flow:** +1. Look up Tai node from `:taiID` in registry +2. Get the node's HTTP port (from `node.Ports["http"]`, default 8099) +3. Open a tunnel data channel to that port via `RequestChannel` +4. Forward the incoming HTTP request through the tunnel +5. Read the response and stream it back to the client + +## HandleVNC + +VNC WebSocket proxy for tunnel-connected Tai nodes. + +**Flow:** +1. Look up Tai node from `:taiID` in registry +2. Get the node's VNC port (from `node.Ports["vnc"]`, default 16080) +3. Open a tunnel data channel to that port via `RequestChannel` +4. Upgrade the client connection to WebSocket +5. Bridge client WebSocket ↔ tunnel data channel (binary messages) + +## Internal Types + +### wsConn + +`wsConn` wraps `gorilla/websocket.Conn` to implement `net.Conn` for bidirectional byte bridging. This allows tunnel data channels to be treated as standard TCP connections by the registry's bridge logic. + +```go +type wsConn struct { ... } +func (c *wsConn) Read(p []byte) (int, error) // reads WS binary messages +func (c *wsConn) Write(p []byte) (int, error) // writes WS binary messages +func (c *wsConn) Close() error +// Also implements: LocalAddr, RemoteAddr, SetDeadline, SetReadDeadline, SetWriteDeadline +``` diff --git a/tai/docs/vnc.md b/tai/docs/vnc.md index e1d9e425..b4d922a6 100644 --- a/tai/docs/vnc.md +++ b/tai/docs/vnc.md @@ -15,8 +15,9 @@ type VNC interface { | Implementation | Constructor | Mode | URL Pattern | |----------------|-------------|------|-------------| -| **Remote** | `NewRemote(host, port, hc)` | Via Tai VNC router | `ws://tai-host:6080/vnc/{containerID}/ws` | +| **Remote** | `NewRemote(host, port, hc)` | Via Tai VNC router | `ws://tai-host:16080/vnc/{containerID}/ws` | | **Local** | `NewLocal(sb)` | Direct host port lookup | `ws://127.0.0.1:{hostPort}/ws` | +| **Tunnel** | `NewTunnel(taiID, yaoBase)` | Via Yao reverse proxy | `ws(s)://{yaoHost}/tai/{taiID}/vnc/{containerID}/ws` | ## Constructors @@ -29,7 +30,7 @@ func NewRemote(host string, port int, hc *http.Client) VNC Creates a VNC that routes through Tai's VNC WebSocket router. - `host` — Tai server hostname/IP -- `port` — Tai VNC router port (default 6080) +- `port` — Tai VNC router port (default 16080) - `hc` — custom HTTP client for Ping, `nil` uses `http.DefaultClient` ### NewLocal @@ -42,6 +43,17 @@ Creates a VNC that resolves URLs by inspecting the container's port mappings. Lo Returns an error if port 6080 is not mapped. On macOS and Windows (Docker Desktop), the Local sandbox automatically maps port 6080 when `CreateOptions.VNC` is `true`. +### NewTunnel + +```go +func NewTunnel(taiID, yaoBase string) VNC +``` + +Creates a VNC that routes through Yao's HTTP reverse proxy for tunnel-mode connections. + +- `taiID` — the Tai node identifier in the registry +- `yaoBase` — the Yao server base URL (e.g. `"http://yao-server:5099"`) + ## Methods ### URL @@ -52,7 +64,7 @@ URL(ctx context.Context, containerID string) (string, error) Returns a WebSocket URL for connecting to the container's VNC session. -**Remote:** `ws://tai-host:6080/vnc/abc123/ws` +**Remote:** `ws://tai-host:16080/vnc/abc123/ws` **Local:** `ws://127.0.0.1:32769/ws` ### Ping @@ -63,8 +75,9 @@ Ping(ctx context.Context, containerID string) error Checks if the VNC endpoint is reachable by making an HTTP GET request to the WebSocket URL. Useful for verifying that the VNC server inside the container is ready before connecting a client. -- **Remote**: sends GET to `http://tai-host:6080/vnc/{containerID}/ws` +- **Remote**: sends GET to `http://tai-host:16080/vnc/{containerID}/ws` - **Local**: resolves the host port via Inspect, then sends GET +- **Tunnel**: always returns `nil` (no direct network path to probe) ## Example @@ -90,5 +103,5 @@ for i := 0; i < 10; i++ { // Get the WebSocket URL for a noVNC client url, _ := c.VNC().URL(ctx, id) -fmt.Println(url) // ws://192.168.1.100:6080/vnc/desktop/ws +fmt.Println(url) // ws://192.168.1.100:16080/vnc/desktop/ws ``` diff --git a/tai/docs/volume.md b/tai/docs/volume.md index 15b75e67..a3430568 100644 --- a/tai/docs/volume.md +++ b/tai/docs/volume.md @@ -5,7 +5,7 @@ File IO and directory synchronization. Provides a `Volume` interface with two im | Implementation | Constructor | Backend | Mode | |----------------|-------------|---------|------| | **Local** | `NewLocal(root)` | Direct filesystem | Local | -| **Remote** | `NewRemote(conn)` | gRPC to Tai :9100 | Remote | +| **Remote** | `NewRemote(conn)` | gRPC to Tai :19100 | Remote | ## Interface @@ -33,10 +33,10 @@ All paths are **relative** to the session's workspace root. The `sessionID` iden ### NewLocal ```go -func NewLocal(root string) Volume +func NewLocal(dataDir string) Volume ``` -Creates a Volume backed by the local filesystem. Files are stored under `//`. +Creates a Volume backed by the local filesystem. Files are stored under `//`. ### NewRemote @@ -44,7 +44,7 @@ Creates a Volume backed by the local filesystem. Files are stored under `/ func NewRemote(conn *grpc.ClientConn) Volume ``` -Creates a Volume backed by Tai's gRPC Volume service. The connection should target Tai's gRPC port (default 9100). Uses lz4 compression for `SyncPush`/`SyncPull` bulk transfers. +Creates a Volume backed by Tai's gRPC Volume service. The connection should target Tai's gRPC port (default 19100). Uses lz4 compression for `SyncPush`/`SyncPull` bulk transfers. ## Types diff --git a/tai/proxy/proxy.go b/tai/proxy/proxy.go index ba556259..71102662 100644 --- a/tai/proxy/proxy.go +++ b/tai/proxy/proxy.go @@ -21,7 +21,7 @@ type Proxy interface { type ConnectOptions struct { Port int // container port Path string // URL path (e.g. "/ws" or "/events") - Protocol string // "ws", "sse", or "tcp" + Protocol string // "ws" or "sse" } // Connection represents a persistent connection to a container service. diff --git a/tai/registry/registry.go b/tai/registry/registry.go index d1f65cc4..7386b720 100644 --- a/tai/registry/registry.go +++ b/tai/registry/registry.go @@ -33,7 +33,7 @@ type TaiNode struct { 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":9100, "http":8080, "vnc":6080, "docker":2375} + Ports map[string]int // {"grpc":19100, "http":8099, "vnc":16080, "docker":12375} Capabilities map[string]bool ControlConn *websocket.Conn diff --git a/tai/registry/registry_test.go b/tai/registry/registry_test.go index b9fe4a9e..5b42f05f 100644 --- a/tai/registry/registry_test.go +++ b/tai/registry/registry_test.go @@ -29,7 +29,7 @@ func TestRegister_SetsFieldsAndOnline(t *testing.T) { MachineID: "m-abc", Version: "1.0.0", Mode: "tunnel", - Ports: map[string]int{"grpc": 9100}, + Ports: map[string]int{"grpc": 19100}, } r.Register(node) @@ -117,14 +117,14 @@ func TestSnapshot_DeepCopy(t *testing.T) { r := newTestRegistry() r.Register(&TaiNode{ TaiID: "tai-001", - Ports: map[string]int{"grpc": 9100, "http": 8080}, + Ports: map[string]int{"grpc": 19100, "http": 8099}, }) snap, _ := r.Get("tai-001") snap.Ports["grpc"] = 0 snap2, _ := r.Get("tai-001") - if snap2.Ports["grpc"] != 9100 { + if snap2.Ports["grpc"] != 19100 { t.Error("snapshot modification leaked into registry node") } } @@ -165,7 +165,7 @@ func TestWriteControlJSON_NilConn(t *testing.T) { func TestRequestChannel_NotFound(t *testing.T) { r := newTestRegistry() - _, _, err := r.RequestChannel("ghost", 9100) + _, _, err := r.RequestChannel("ghost", 19100) if err == nil { t.Fatal("expected error for missing node") } @@ -174,7 +174,7 @@ func TestRequestChannel_NotFound(t *testing.T) { func TestRequestChannel_DirectMode(t *testing.T) { r := newTestRegistry() r.Register(&TaiNode{TaiID: "tai-001", Mode: "direct"}) - _, _, err := r.RequestChannel("tai-001", 9100) + _, _, err := r.RequestChannel("tai-001", 19100) if err == nil { t.Fatal("expected error for direct-mode node") } @@ -357,7 +357,7 @@ func TestRequestChannel_Success(t *testing.T) { r := newTestRegistry() r.Register(&TaiNode{TaiID: "tai-001", Mode: "tunnel", ControlConn: wsConn}) - channelID, resultCh, err := r.RequestChannel("tai-001", 9100) + channelID, resultCh, err := r.RequestChannel("tai-001", 19100) if err != nil { t.Fatalf("RequestChannel: %v", err) } @@ -379,8 +379,8 @@ func TestRequestChannel_Success(t *testing.T) { if cmd["channel_id"] != channelID { t.Errorf("cmd channel_id = %v, want %s", cmd["channel_id"], channelID) } - if tp, ok := cmd["target_port"].(float64); !ok || int(tp) != 9100 { - t.Errorf("cmd target_port = %v, want 9100", cmd["target_port"]) + if tp, ok := cmd["target_port"].(float64); !ok || int(tp) != 19100 { + t.Errorf("cmd target_port = %v, want 19100", cmd["target_port"]) } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for open command") @@ -391,7 +391,7 @@ func TestRequestChannel_NoControlConn(t *testing.T) { r := newTestRegistry() r.Register(&TaiNode{TaiID: "tai-001", Mode: "tunnel"}) - _, _, err := r.RequestChannel("tai-001", 9100) + _, _, err := r.RequestChannel("tai-001", 19100) if err == nil { t.Fatal("expected error for nil ControlConn") } @@ -420,7 +420,7 @@ func TestOpenLocalListener_Success(t *testing.T) { r.Register(&TaiNode{TaiID: "tai-001", Mode: "tunnel", ControlConn: wsConn}) - ln, err := r.OpenLocalListener("tai-001", 9100) + ln, err := r.OpenLocalListener("tai-001", 19100) if err != nil { t.Fatalf("OpenLocalListener: %v", err) } @@ -448,8 +448,8 @@ func TestOpenLocalListener_Success(t *testing.T) { if _, ok := cmd["channel_id"].(string); !ok { t.Error("open cmd missing channel_id") } - if tp, ok := cmd["target_port"].(float64); !ok || int(tp) != 9100 { - t.Errorf("target_port = %v, want 9100", cmd["target_port"]) + if tp, ok := cmd["target_port"].(float64); !ok || int(tp) != 19100 { + t.Errorf("target_port = %v, want 19100", cmd["target_port"]) } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for open command from local listener") @@ -458,7 +458,7 @@ func TestOpenLocalListener_Success(t *testing.T) { func TestOpenLocalListener_NodeNotFound(t *testing.T) { r := newTestRegistry() - _, err := r.OpenLocalListener("ghost", 9100) + _, err := r.OpenLocalListener("ghost", 19100) if err == nil { t.Fatal("expected error for missing node") } diff --git a/tai/sandbox/docker.go b/tai/sandbox/docker.go index 7ae908b1..7f7e94c6 100644 --- a/tai/sandbox/docker.go +++ b/tai/sandbox/docker.go @@ -13,7 +13,7 @@ type dockerSandbox struct { } // NewDocker creates a Sandbox backed by Docker SDK through Tai's Docker API proxy. -// addr should be "tcp://tai-host:2375". +// addr should be "tcp://tai-host:12375". func NewDocker(addr string) (Sandbox, error) { cli, err := client.NewClientWithOpts( client.WithHost(addr), diff --git a/tai/tai.go b/tai/tai.go index c11cd7d0..39d2a13d 100644 --- a/tai/tai.go +++ b/tai/tai.go @@ -43,11 +43,11 @@ func (f optionFunc) apply(c *config) { f(c) } // Ports configures service ports for Tai server. type Ports struct { - GRPC int // default 9100 - HTTP int // default 8080 - VNC int // default 6080 - Docker int // default 2375 - K8s int // default 6443 + 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. @@ -99,9 +99,9 @@ type config struct { func defaultPorts() Ports { return Ports{ - GRPC: 9100, - HTTP: 8080, - VNC: 6080, + GRPC: 19100, + HTTP: 8099, + VNC: 16080, } } @@ -245,7 +245,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) { } k8sPort := c.ports.K8s if k8sPort == 0 { - k8sPort = 6443 + k8sPort = 16443 } sbAddr := fmt.Sprintf("%s:%d", c.host, k8sPort) sb, err := sandbox.NewK8s(sbAddr, sandbox.K8sOption{ @@ -259,7 +259,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) { } else if hasDocker { dockerPort := c.ports.Docker if dockerPort == 0 { - dockerPort = 2375 + dockerPort = 12375 } sbAddr := fmt.Sprintf("tcp://%s:%d", c.host, dockerPort) sb, err := sandbox.NewDocker(sbAddr) @@ -306,10 +306,10 @@ func (c *Client) initTunnel(cfg *config) (*Client, error) { } c.ports = Ports{ - GRPC: nodePort(node.Ports, "grpc", 9100), - HTTP: nodePort(node.Ports, "http", 8080), - VNC: nodePort(node.Ports, "vnc", 6080), - Docker: nodePort(node.Ports, "docker", 2375), + GRPC: nodePort(node.Ports, "grpc", 19100), + HTTP: nodePort(node.Ports, "http", 8099), + VNC: nodePort(node.Ports, "vnc", 16080), + Docker: nodePort(node.Ports, "docker", 12375), } grpcLn, err := reg.OpenLocalListener(taiID, c.ports.GRPC) @@ -458,7 +458,7 @@ func parseAddr(addr string) (scheme, host, dockerAddr string, grpcPort int, err if isLocalHost(addr) { return "docker", "", "", 0, nil } - // host:port — split carefully (IPv6 like [::1]:9100 is already handled above) + // host:port — split carefully (IPv6 like [::1]:19100 is already handled above) h := addr if idx := strings.LastIndex(addr, ":"); idx > 0 { h = addr[:idx] diff --git a/tai/tai_test.go b/tai/tai_test.go index d00838e6..3695492c 100644 --- a/tai/tai_test.go +++ b/tai/tai_test.go @@ -14,6 +14,26 @@ 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), + HTTP: envPort("TAI_TEST_HTTP_PORT", 0), + VNC: envPort("TAI_TEST_VNC_PORT", 0), + } +} + func envPort(key string, fallback int) int { if v := os.Getenv(key); v != "" { if p, err := strconv.Atoi(v); err == nil { @@ -84,11 +104,11 @@ func TestMergedPorts(t *testing.T) { if p.HTTP != 8888 { t.Errorf("HTTP = %d, want 8888", p.HTTP) } - if p.GRPC != 9100 { - t.Errorf("GRPC = %d, want 9100 (default)", p.GRPC) + if p.GRPC != 19100 { + t.Errorf("GRPC = %d, want 19100 (default)", p.GRPC) } - if p.VNC != 6080 { - t.Errorf("VNC = %d, want 6080 (default)", p.VNC) + if p.VNC != 16080 { + t.Errorf("VNC = %d, want 16080 (default)", p.VNC) } if p.Docker != 0 { t.Errorf("Docker = %d, want 0 (unset)", p.Docker) @@ -202,12 +222,12 @@ func TestNewRemoteK8s(t *testing.T) { t.Skip("TAI_TEST_K8S_HOST or TAI_TEST_KUBECONFIG not set") } - grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 9100)) + grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 19100)) ports := Ports{ - K8s: envPort("TAI_TEST_K8S_PORT", 6443), + K8s: envPort("TAI_TEST_K8S_PORT", 16443), GRPC: grpcPort, - HTTP: envPort("TAI_TEST_K8S_HTTP_PORT", 8080), - VNC: envPort("TAI_TEST_K8S_VNC_PORT", 6080), + HTTP: envPort("TAI_TEST_K8S_HTTP_PORT", 8099), + VNC: envPort("TAI_TEST_K8S_VNC_PORT", 16080), } c, err := New(fmt.Sprintf("tai://%s:%d", host, grpcPort), K8s, @@ -255,13 +275,16 @@ func TestNewInvalidScheme(t *testing.T) { } func TestNewRemoteDocker(t *testing.T) { - addr := "tai://" + taiTestHost() - c, err := New(addr) + 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://") } @@ -284,7 +307,7 @@ func TestNewRemoteDocker(t *testing.T) { } func TestDiscoverPorts(t *testing.T) { - addr := "tai://" + taiTestHost() + addr := taiRemoteAddr() c, err := New(addr) if err != nil { t.Skipf("Tai not available at %s: %v", addr, err) @@ -303,7 +326,7 @@ func TestDiscoverPorts(t *testing.T) { } func TestDiscoverPortsWithUserOverride(t *testing.T) { - addr := "tai://" + taiTestHost() + addr := taiRemoteAddr() c, err := New(addr, WithPorts(Ports{HTTP: 9999})) if err != nil { t.Skipf("Tai not available at %s: %v", addr, err) diff --git a/tai/tunnel/proxy.go b/tai/tunnel/proxy.go index a02d92aa..8676868b 100644 --- a/tai/tunnel/proxy.go +++ b/tai/tunnel/proxy.go @@ -33,7 +33,7 @@ func HandleProxy(c *gin.Context) { httpPort := node.Ports["http"] if httpPort == 0 { - httpPort = 8080 + httpPort = 8099 } channelID, resultCh, err := reg.RequestChannel(taiID, httpPort) @@ -104,7 +104,7 @@ func HandleVNC(c *gin.Context) { vncPort := node.Ports["vnc"] if vncPort == 0 { - vncPort = 6080 + vncPort = 16080 } channelID, resultCh, err := reg.RequestChannel(taiID, vncPort) diff --git a/tai/volume/pb/volume.proto b/tai/volume/pb/volume.proto index c40ea461..dc39e07a 100644 --- a/tai/volume/pb/volume.proto +++ b/tai/volume/pb/volume.proto @@ -3,7 +3,7 @@ package volume; option go_package = "github.com/yaoapp/tai/volume/pb"; // Volume provides bulk file synchronization and real-time filesystem I/O. -// Shares gRPC port :9100 with Yao Gateway. +// Shares gRPC port :19100 with Yao Gateway. service Volume { // --- Bulk Sync --- diff --git a/tai/volume/pb/volume_grpc.pb.go b/tai/volume/pb/volume_grpc.pb.go index ac253cb3..7924e483 100644 --- a/tai/volume/pb/volume_grpc.pb.go +++ b/tai/volume/pb/volume_grpc.pb.go @@ -35,7 +35,7 @@ const ( // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // // Volume provides bulk file synchronization and real-time filesystem I/O. -// Shares gRPC port :9100 with Yao Gateway. +// Shares gRPC port :19100 with Yao Gateway. type VolumeClient interface { // SyncPush: Yao sends code to Tai (before container start). // Bidirectional stream: @@ -183,7 +183,7 @@ func (c *volumeClient) MkdirAll(ctx context.Context, in *FSRequest, opts ...grpc // for forward compatibility. // // Volume provides bulk file synchronization and real-time filesystem I/O. -// Shares gRPC port :9100 with Yao Gateway. +// Shares gRPC port :19100 with Yao Gateway. type VolumeServer interface { // SyncPush: Yao sends code to Tai (before container start). // Bidirectional stream: diff --git a/tai/volume/volume.go b/tai/volume/volume.go index a74cf0ee..31ac3967 100644 --- a/tai/volume/volume.go +++ b/tai/volume/volume.go @@ -8,7 +8,7 @@ import ( ) // Volume provides filesystem IO and directory synchronization. -// Remote connects to Tai gRPC :9100; Local operates directly on disk. +// Remote connects to Tai gRPC :19100; Local operates directly on disk. type Volume interface { ReadFile(ctx context.Context, sessionID, path string) ([]byte, os.FileMode, error) WriteFile(ctx context.Context, sessionID, path string, data []byte, perm os.FileMode) error