diff --git a/sandbox/v2/DESIGN.md b/sandbox/v2/DESIGN.md index feef38d1..c8dac981 100644 --- a/sandbox/v2/DESIGN.md +++ b/sandbox/v2/DESIGN.md @@ -602,27 +602,27 @@ type Proxy interface { Local: resolves host ports via `Inspect()`. Remote: routes through Tai HTTP proxy which handles WebSocket upgrade and SSE streaming natively. -## gRPC Token Injection +## gRPC Environment Injection ```go -func CreateContainerTokens(sandboxID, owner string, scopes []string) (access, refresh string, err error) -func RevokeContainerTokens(refresh string) error -func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) map[string]string +func BuildGRPCEnv(pool *Pool, sandboxID string, grpcPort int) map[string]string ``` -Environment variables injected into each container: +`BuildGRPCEnv` sets **only** routing variables — token injection is decoupled: ``` -# All modes +# Set by BuildGRPCEnv (always) YAO_SANDBOX_ID= +YAO_GRPC_ADDR=127.0.0.1:9099 # local / tunnel mode +YAO_GRPC_ADDR=:19100 # remote mode (tai://) + +# Set by caller via CreateOptions.Env (OAuth is caller's responsibility) YAO_TOKEN= YAO_REFRESH_TOKEN= -YAO_GRPC_ADDR=127.0.0.1:9099 - -# Remote mode (tai://) -YAO_GRPC_ADDR=:19100 ``` +`CreateOptions.Env` is merged **after** `BuildGRPCEnv`, so the caller can override any variable including `YAO_GRPC_ADDR`. + ## Errors ```go diff --git a/sandbox/v2/IMPL.md b/sandbox/v2/IMPL.md index 60f13385..418ae85d 100644 --- a/sandbox/v2/IMPL.md +++ b/sandbox/v2/IMPL.md @@ -27,7 +27,7 @@ Reference: [DESIGN.md](./DESIGN.md) | `types.go` | LifecyclePolicy (OneShot/Session/LongRunning/Persistent), Pool, PoolInfo, PortMapping, CreateOptions (with WorkspaceID/MountMode/MountPath), ListOptions, ExecOption/ExecResult/ExecStream, AttachOption/ServiceConn, ImagePullOptions/RegistryAuth, BoxInfo, DefaultStopTimeout | DONE | | `config.go` | Config struct | DONE | | `errors.go` | ErrNotAvailable, ErrNotFound, ErrLimitExceeded, ErrPoolNotFound, ErrPoolInUse | DONE | -| `grpc.go` | CreateContainerTokens, RevokeContainerTokens, BuildGRPCEnv | DONE | +| `grpc.go` | BuildGRPCEnv (sandbox ID + gRPC addr only; token injection is caller's responsibility via Env) | DONE | ### workspace Module — DONE @@ -78,60 +78,102 @@ Reference: [DESIGN.md](./DESIGN.md) --- -## Phase 2: JSAPI + OAuth — PENDING +## Phase 2: JSAPI + Computer Unification — DONE -| Task | Package | Detail | -|------|---------|--------| -| `jsapi/jsapi.go` + `box.go` | `sandbox/v2/jsapi` | V8 JSAPI `Sandbox()` Create/Get/List/Delete + Box object (registered in gou runtime) | -| `jsapi/host.go` | `sandbox/v2/jsapi` | V8 JSAPI `sandbox.Host(pool?)` + Host object (Exec, Stream, Workspace) | -| `jsapi/node.go` | `sandbox/v2/jsapi` | V8 JSAPI `sandbox.GetNode(id)` / `Nodes()` / `NodesByTeam(tid)` + snapshotToJS converter | -| Wire `openapi/oauth` | `sandbox/v2/grpc.go` | `CreateContainerTokens` currently generates random strings; `RevokeContainerTokens` is a no-op. Replace with real `openapi/oauth` issue/revoke calls | -| `cmd/start.go` integration | `yao` | Call `sandbox.Init(config.Conf.Sandbox)` + `sandbox.M().Start(ctx)` in startup sequence | -| Heartbeat bridge | `yao/grpc` | Wire gRPC Heartbeat handler → `sandbox.M().Heartbeat()` | +### Unified Computer Interface — DONE -### JSAPI (planned) +Box and Host now share a single `Computer` interface (`types.go`). Both `sandbox.Create()` and `sandbox.Host()` return the same JS `Computer` object; `kind` property distinguishes them. Box-only methods (`Info`, `Start`, `Stop`, `Remove`) throw at runtime when called on a host. + +| Step | Package | What | Status | +|------|---------|------|--------| +| Computer interface | `sandbox/v2/types.go` | `Computer` interface: Exec, Stream, VNC, Proxy, ComputerInfo, BindWorkplace, Workplace | DONE | +| Host implementation | `sandbox/v2/host.go` | `Host` struct implements `Computer` via tai HostExec + VNC/Proxy | DONE | +| ComputerInfo | `sandbox/v2/types.go` | `ComputerInfo` struct with Kind, Pool, TaiID, System, Capabilities, box-specific fields | DONE | + +### JSAPI — DONE + +| File | What | Status | +|------|------|--------| +| `jsapi/jsapi.go` | Static methods: `sandbox.Create`, `Get`, `List`, `Delete` | DONE | +| `jsapi/computer.go` | `NewComputerObject` factory (11 methods + 4 properties), `sbHost`, helpers | DONE | +| `jsapi/node.go` | `sandbox.GetNode`, `Nodes`, `NodesByTeam`, `snapshotToJS` | DONE | +| `jsapi/API.md` | Full JavaScript API reference | DONE | + +Design decisions: +- **No Go objects in V8**: closures capture only `kind` (string) and `identifier` (string); `getComputer()` re-fetches from Manager on each call — prevents memory leaks across runtimes. +- **Stream**: blocking with callback `function(type, data)`, goroutines feed a channel, main V8 thread drains it. +- **Workplace()**: delegates to `workspace/jsapi.NewFSObject()` — reuses existing WorkspaceFS JSAPI. ```javascript -// Sandbox -var box = sandbox.Create({ - image: "yaoapp/workspace:latest", - owner: "user-123", - workspace_id: "my-workspace" -}) -box.Exec(["go", "build", "./..."]) -box.Stream(["npm", "run", "dev"], function(type, data) { +// Unified Computer — same API for box and host +const pc = sandbox.Create({ image: "node:20", owner: "user-123" }) +pc.Exec(["node", "-e", "console.log('hello')"]) +pc.Stream(["npm", "run", "dev"], function(type, data) { if (type === "stdout") console.log(data) if (type === "exit") console.log("exited:", data) }) -var url = box.Attach(3000, { protocol: "ws", path: "/ws" }) -box.Info() -box.Stop() -box.Start() -box.Remove() +pc.VNC() // → "ws://host:port/vnc/{id}/ws" +pc.Proxy(3000, "/api") // → "http://host:port/{id}:3000/api" +pc.ComputerInfo() // → { kind, pool, system, ... } +pc.BindWorkplace("ws-abc") +pc.Workplace().ReadFile("main.go") +pc.Info() // box-only +pc.Remove() // box-only -// Box workspace file I/O -var ws = box.Workspace() -ws.ReadFile("src/main.go") -ws.WriteFile("src/main.go", "package main\n...") -ws.ReadDir("src/") -ws.Remove("tmp.txt") - -// Host (Tai host_exec — no container) -var host = sandbox.Host("gpu") -host.Exec("ls", ["-la", "/workspace"], { workdir: "/workspace" }) -var wsHost = host.Workspace("my-session") -wsHost.ReadFile("config.yml") +// Host — same interface, no container +const host = sandbox.Host("gpu") +host.Exec(["nvidia-smi"]) +host.VNC() // → "ws://host:port/vnc/__host__/ws" +host.Proxy(8080) // → "http://host:port/__host__:8080/" +host.kind // "host" +host.Info() // throws: "not supported: Info() requires a box computer" // Nodes (registry read-only query) -var nodes = sandbox.Nodes() -nodes.forEach(function(n) { console.log(n.tai_id, n.status, n.system.hostname) }) - -var node = sandbox.GetNode("tai-abc123") -if (node) { console.log(node.pool, node.ports.grpc, node.capabilities) } - -var teamNodes = sandbox.NodesByTeam("team-001") +const nodes = sandbox.Nodes() +const node = sandbox.GetNode("tai-abc123") +const team = sandbox.NodesByTeam("team-001") ``` +### JSAPI Tests — DONE + +| Test | Coverage | Status | +|------|----------|--------| +| `TestCreate` | Create box, verify kind/id | DONE | +| `TestGet` | Get existing box | DONE | +| `TestGetNotFound` | Get non-existent → null | DONE | +| `TestDelete` | Delete + verify gone | DONE | +| `TestList` | List with owner filter | DONE | +| `TestExec` | Exec echo, verify stdout | DONE | +| `TestExecWithOptions` | Exec with workdir option | DONE | +| `TestStream` | Stream with callback, verify chunks + exit code | DONE | +| `TestComputerInfo` | Verify kind field | DONE | +| `TestBoxInfo` | Box-only Info() | DONE | +| `TestHostBoxMethodsThrow` | Host.Info() throws "not supported" | DONE | +| `TestComputerKind` | kind property = "box" | DONE | +| `TestNodes` | Nodes() returns array | DONE | +| `TestGetNodeNotFound` | GetNode non-existent → null | DONE | + +All 14 tests pass in both local and remote modes. + +### OAuth Decoupling — DONE + +Token injection (YAO_TOKEN, YAO_REFRESH_TOKEN) has been **removed from sandbox Manager**. +`CreateContainerTokens`, `RevokeContainerTokens`, and the `Box.refreshToken` field have been deleted. +`BuildGRPCEnv` now only sets `YAO_SANDBOX_ID` and `YAO_GRPC_ADDR`. + +Token provisioning is the **caller's responsibility** via `CreateOptions.Env`: +- The caller (e.g. Agent Hook) already holds an OAuth context +- It calls `oauth.OAuth.MakeAccessToken(...)` to issue a scoped token +- Passes it in `CreateOptions.Env["YAO_TOKEN"]` / `Env["YAO_REFRESH_TOKEN"]` +- `opts.Env` takes priority over `BuildGRPCEnv` output (caller can override anything) + +### Remaining (Startup) — PENDING + +| Task | Package | Detail | +|------|---------|--------| +| `cmd/start.go` integration | `yao` | Call `sandbox.Init()` + `sandbox.M().Start(ctx)` in startup (no config needed — node discovery via tai/registry) | +| Heartbeat bridge | `yao/grpc` | Wire gRPC Heartbeat handler → `sandbox.M().Heartbeat()` | + --- ## Phase 3: Agent Integration — PENDING @@ -223,9 +265,10 @@ Every test iterates over all available pools: ```go func TestSomething(t *testing.T) { for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - // test logic + m := setupManagerForPool(t, &pc) + // test logic — use pc.TaiID as pool identifier }) } } @@ -247,18 +290,20 @@ K8s-specific behavior: ## File Inventory -### sandbox/v2 (7 source + 10 test = 17 files) +### sandbox/v2 (9 source + 10 test = 19 files) | File | Lines | Purpose | |------|-------|---------| | `sandbox.go` | ~25 | Global singleton | | `manager.go` | ~620 | Manager implementation | -| `box.go` | ~230 | Box implementation | -| `types.go` | ~170 | Type definitions | +| `box.go` | ~317 | Box implementation (Computer interface) | +| `host.go` | ~232 | Host implementation (Computer interface) | +| `types.go` | ~247 | Type definitions (Computer, ComputerInfo, ExecOption, etc.) | | `config.go` | ~5 | Config struct | | `errors.go` | ~10 | Error definitions | -| `grpc.go` | ~55 | Token/env injection | -| `testutils_test.go` | ~130 | Test helpers | +| `grpc.go` | ~50 | BuildGRPCEnv (sandbox ID + addr) | +| `export_test.go` | ~6 | ResetForTest | +| `testutils_test.go` | ~364 | Test helpers (multi-pool, host exec targets) | | `sandbox_test.go` | ~30 | Singleton tests | | `manager_test.go` | ~250 | CRUD tests | | `manager_lifecycle_test.go` | ~120 | Lifecycle tests | @@ -266,9 +311,19 @@ K8s-specific behavior: | `box_attach_test.go` | ~260 | Attach/VNC tests | | `box_workspace_test.go` | ~285 | Workspace tests | | `box_image_test.go` | ~120 | Image tests | -| `grpc_test.go` | ~80 | Token tests | +| `grpc_test.go` | ~40 | BuildGRPCEnv tests | | `bench_test.go` | ~230 | Benchmarks | +### sandbox/v2/jsapi (3 source + 1 test + 1 doc = 5 files) + +| File | Lines | Purpose | +|------|-------|---------| +| `jsapi.go` | ~286 | Static methods (Create/Get/List/Delete) + V8 registration | +| `computer.go` | ~472 | NewComputerObject factory, sbHost, helpers | +| `node.go` | ~143 | Node query methods (GetNode/Nodes/NodesByTeam) + snapshotToJS | +| `jsapi_test.go` | ~430 | 14 test cases (local + remote modes) | +| `API.md` | ~604 | JavaScript API reference | + ### workspace (3 source + 4 test = 7 files) | File | Lines | Purpose | @@ -280,3 +335,12 @@ K8s-specific behavior: | `workspace_test.go` | ~325 | CRUD tests | | `fileio_test.go` | ~235 | File I/O tests | | `bench_test.go` | ~150 | Benchmarks | + +### workspace/jsapi (2 source + 1 test + 1 doc = 4 files) + +| File | Lines | Purpose | +|------|-------|---------| +| `jsapi.go` | ~100 | Static methods (Create/Get/List/Delete) + V8 registration | +| `fs.go` | ~630 | NewFSObject factory (WorkspaceFS methods) | +| `jsapi_test.go` | ~460 | JSAPI tests (local + remote modes) | +| `API.md` | ~220 | Workspace JavaScript API reference | diff --git a/sandbox/v2/bench_test.go b/sandbox/v2/bench_test.go index 605982da..c242995d 100644 --- a/sandbox/v2/bench_test.go +++ b/sandbox/v2/bench_test.go @@ -7,14 +7,17 @@ import ( "time" sandbox "github.com/yaoapp/yao/sandbox/v2" + "github.com/yaoapp/yao/tai" + "github.com/yaoapp/yao/tai/registry" ) // BenchmarkContainerLifecycle measures the full Create → Exec → Remove cycle. func BenchmarkContainerLifecycle(b *testing.B) { for _, pc := range testPools() { + pc := pc b.Run(pc.Name, func(b *testing.B) { - m := setupManagerForBench(b, pc) - ensureTestImageBench(b, m, pc.Name) + m := setupManagerForBench(b, &pc) + ensureTestImageBench(b, m, pc.TaiID) b.ResetTimer() for i := 0; i < b.N; i++ { @@ -42,9 +45,10 @@ func BenchmarkContainerLifecycle(b *testing.B) { // BenchmarkCreate measures container creation time only. func BenchmarkCreate(b *testing.B) { for _, pc := range testPools() { + pc := pc b.Run(pc.Name, func(b *testing.B) { - m := setupManagerForBench(b, pc) - ensureTestImageBench(b, m, pc.Name) + m := setupManagerForBench(b, &pc) + ensureTestImageBench(b, m, pc.TaiID) ids := make([]string, 0, b.N) b.ResetTimer() @@ -70,8 +74,9 @@ func BenchmarkCreate(b *testing.B) { // BenchmarkExec measures command execution latency on a pre-created container. func BenchmarkExec(b *testing.B) { for _, pc := range testPools() { + pc := pc b.Run(pc.Name, func(b *testing.B) { - m := setupManagerForBench(b, pc) + m := setupManagerForBench(b, &pc) box := createBoxForBench(b, m) b.ResetTimer() @@ -91,8 +96,9 @@ func BenchmarkExec(b *testing.B) { // BenchmarkExecHeavy measures execution of a heavier command (write + read file). func BenchmarkExecHeavy(b *testing.B) { for _, pc := range testPools() { + pc := pc b.Run(pc.Name, func(b *testing.B) { - m := setupManagerForBench(b, pc) + m := setupManagerForBench(b, &pc) box := createBoxForBench(b, m) b.ResetTimer() @@ -113,9 +119,10 @@ func BenchmarkExecHeavy(b *testing.B) { // BenchmarkRemove measures container removal time. func BenchmarkRemove(b *testing.B) { for _, pc := range testPools() { + pc := pc b.Run(pc.Name, func(b *testing.B) { - m := setupManagerForBench(b, pc) - ensureTestImageBench(b, m, pc.Name) + m := setupManagerForBench(b, &pc) + ensureTestImageBench(b, m, pc.TaiID) boxes := make([]*sandbox.Box, b.N) for i := 0; i < b.N; i++ { @@ -142,8 +149,9 @@ func BenchmarkRemove(b *testing.B) { // BenchmarkInfo measures Info() latency on a running container. func BenchmarkInfo(b *testing.B) { for _, pc := range testPools() { + pc := pc b.Run(pc.Name, func(b *testing.B) { - m := setupManagerForBench(b, pc) + m := setupManagerForBench(b, &pc) box := createBoxForBench(b, m) b.ResetTimer() @@ -160,11 +168,12 @@ func BenchmarkInfo(b *testing.B) { // BenchmarkStopStart measures Stop → Start cycle time. func BenchmarkStopStart(b *testing.B) { for _, pc := range testPools() { + pc := pc b.Run(pc.Name, func(b *testing.B) { if pc.Name == "k8s" { b.Skip("K8s Stop deletes Pod; Stop→Start cycle not applicable") } - m := setupManagerForBench(b, pc) + m := setupManagerForBench(b, &pc) box := createBoxForBench(b, m) b.ResetTimer() @@ -183,8 +192,9 @@ func BenchmarkStopStart(b *testing.B) { // BenchmarkWorkspaceReadWrite measures workspace file read/write via container Box. func BenchmarkWorkspaceReadWrite(b *testing.B) { for _, pc := range testPools() { + pc := pc b.Run(pc.Name, func(b *testing.B) { - m := setupManagerForBench(b, pc) + m := setupManagerForBench(b, &pc) box := createBoxForBench(b, m) ws := box.Workspace() if ws == nil { @@ -213,13 +223,18 @@ func BenchmarkWorkspaceReadWrite(b *testing.B) { // --- helpers --- -func setupManagerForBench(b *testing.B, pc poolConfig) *sandbox.Manager { +func setupManagerForBench(b *testing.B, pc *poolConfig) *sandbox.Manager { b.Helper() - pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options} - cfg := sandbox.Config{Pool: []sandbox.Pool{pool}} - if err := sandbox.Init(cfg); err != nil { - b.Fatalf("Init: %v", err) + reg := registry.Global() + if reg == 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() + sandbox.Init() m := sandbox.M() b.Cleanup(func() { m.Close() }) return m @@ -237,14 +252,17 @@ func ensureTestImageBench(b *testing.B, m *sandbox.Manager, pool string) { func createBoxForBench(b *testing.B, m *sandbox.Manager) *sandbox.Box { b.Helper() pools := m.Pools() + var poolName string if len(pools) > 0 { - ensureTestImageBench(b, m, pools[0].Name) + poolName = pools[0].TaiID + ensureTestImageBench(b, m, poolName) } ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() box, err := m.Create(ctx, sandbox.CreateOptions{ Image: testImage(), Owner: "bench", + Pool: poolName, }) if err != nil { b.Fatalf("Create: %v", err) diff --git a/sandbox/v2/box.go b/sandbox/v2/box.go index 1f0c340a..c55416fb 100644 --- a/sandbox/v2/box.go +++ b/sandbox/v2/box.go @@ -23,9 +23,9 @@ type Box struct { lastHeartbeat atomic.Int64 processCount atomic.Int32 idleTimeoutD time.Duration + maxLifetimeD time.Duration stopTimeoutD time.Duration createdAt time.Time - refreshToken string vnc bool image string workspaceID string @@ -286,31 +286,16 @@ func (b *Box) lastActiveTime() time.Time { } func (b *Box) idleTimeout() time.Duration { - if b.idleTimeoutD > 0 { - return b.idleTimeoutD - } - pd := b.manager.findPoolDef(b.pool) - if pd != nil { - return pd.IdleTimeout - } - return 0 + return b.idleTimeoutD } func (b *Box) maxLifetime() time.Duration { - pd := b.manager.findPoolDef(b.pool) - if pd != nil { - return pd.MaxLifetime - } - return 0 + return b.maxLifetimeD } func (b *Box) stopTimeout() time.Duration { if b.stopTimeoutD > 0 { return b.stopTimeoutD } - pd := b.manager.findPoolDef(b.pool) - if pd != nil && pd.StopTimeout > 0 { - return pd.StopTimeout - } return DefaultStopTimeout } diff --git a/sandbox/v2/box_attach_test.go b/sandbox/v2/box_attach_test.go index 7c91a521..0bfdfdcb 100644 --- a/sandbox/v2/box_attach_test.go +++ b/sandbox/v2/box_attach_test.go @@ -66,9 +66,10 @@ func TestAttachWS(t *testing.T) { } for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m, func(co *sandbox.CreateOptions) { + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) { co.Ports = []sandbox.PortMapping{ {ContainerPort: 9800, HostPort: 0, Protocol: "tcp"}, } @@ -114,9 +115,10 @@ func TestAttachSSE(t *testing.T) { } for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m, func(co *sandbox.CreateOptions) { + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) { co.Ports = []sandbox.PortMapping{ {ContainerPort: 9801, HostPort: 0, Protocol: "tcp"}, } @@ -163,9 +165,10 @@ func TestVNCURL(t *testing.T) { } for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m, func(co *sandbox.CreateOptions) { + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) { co.VNC = true }) @@ -193,9 +196,10 @@ func TestVNCConnect(t *testing.T) { } for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m, func(co *sandbox.CreateOptions) { + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) { co.VNC = true }) diff --git a/sandbox/v2/box_image_test.go b/sandbox/v2/box_image_test.go index 1f43fedb..c4f56fb3 100644 --- a/sandbox/v2/box_image_test.go +++ b/sandbox/v2/box_image_test.go @@ -16,28 +16,28 @@ func TestImageExists(t *testing.T) { t.Run(pc.Name, func(t *testing.T) { if pc.Name == "k8s" { t.Run("always_true", func(t *testing.T) { - m := setupManagerForPool(t, pc) + m := setupManagerForPool(t, &pc) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - exists, err := m.ImageExists(ctx, pc.Name, "anything:nonexistent") + exists, err := m.ImageExists(ctx, pc.TaiID, "anything:nonexistent") require.NoError(t, err) assert.True(t, exists, "k8s mode should always return true") }) return } - m := setupManagerForPool(t, pc) + m := setupManagerForPool(t, &pc) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() t.Run("existing", func(t *testing.T) { - exists, err := m.ImageExists(ctx, pc.Name, "alpine:latest") + exists, err := m.ImageExists(ctx, pc.TaiID, "alpine:latest") require.NoError(t, err) assert.True(t, exists) }) t.Run("missing", func(t *testing.T) { - exists, err := m.ImageExists(ctx, pc.Name, "nonexistent/image:no-such-tag-ever-12345") + exists, err := m.ImageExists(ctx, pc.TaiID, "nonexistent/image:no-such-tag-ever-12345") require.NoError(t, err) assert.False(t, exists) }) @@ -51,22 +51,22 @@ func TestImagePull(t *testing.T) { t.Run(pc.Name, func(t *testing.T) { if pc.Name == "k8s" { t.Run("noop", func(t *testing.T) { - m := setupManagerForPool(t, pc) + m := setupManagerForPool(t, &pc) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - ch, err := m.PullImage(ctx, pc.Name, "alpine:latest", sandbox.ImagePullOptions{}) + ch, err := m.PullImage(ctx, pc.TaiID, "alpine:latest", sandbox.ImagePullOptions{}) require.NoError(t, err) assert.Nil(t, ch, "k8s mode should return nil channel") }) return } - m := setupManagerForPool(t, pc) + m := setupManagerForPool(t, &pc) ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() t.Run("pull_with_progress", func(t *testing.T) { - ch, err := m.PullImage(ctx, pc.Name, "alpine:latest", sandbox.ImagePullOptions{}) + ch, err := m.PullImage(ctx, pc.TaiID, "alpine:latest", sandbox.ImagePullOptions{}) require.NoError(t, err) require.NotNil(t, ch) @@ -87,15 +87,15 @@ func TestEnsureImage(t *testing.T) { for _, pc := range testPools() { pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) + m := setupManagerForPool(t, &pc) ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() - err := m.EnsureImage(ctx, pc.Name, "alpine:latest", sandbox.ImagePullOptions{}) + err := m.EnsureImage(ctx, pc.TaiID, "alpine:latest", sandbox.ImagePullOptions{}) require.NoError(t, err) if pc.Name != "k8s" { - exists, err := m.ImageExists(ctx, pc.Name, "alpine:latest") + exists, err := m.ImageExists(ctx, pc.TaiID, "alpine:latest") require.NoError(t, err) assert.True(t, exists) } @@ -110,11 +110,11 @@ func TestEnsureImage_BadRef(t *testing.T) { continue } t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) + m := setupManagerForPool(t, &pc) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - err := m.EnsureImage(ctx, pc.Name, "nonexistent/image:no-such-tag-ever-12345", sandbox.ImagePullOptions{}) + err := m.EnsureImage(ctx, pc.TaiID, "nonexistent/image:no-such-tag-ever-12345", sandbox.ImagePullOptions{}) assert.Error(t, err) }) } diff --git a/sandbox/v2/box_test.go b/sandbox/v2/box_test.go index 83f9677d..8de47dd2 100644 --- a/sandbox/v2/box_test.go +++ b/sandbox/v2/box_test.go @@ -14,9 +14,10 @@ func TestBoxExec(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m) + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -36,9 +37,10 @@ func TestBoxExecWithOptions(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m) + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc) ctx := context.Background() result, err := box.Exec(ctx, []string{"pwd"}, @@ -58,9 +60,10 @@ func TestBoxStream(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m) + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc) ctx := context.Background() stream, err := box.Stream(ctx, []string{"sh", "-c", "echo line1; echo line2"}) @@ -91,9 +94,10 @@ func TestBoxWorkspace(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m) + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc) ws := box.Workspace() if ws == nil { @@ -132,9 +136,10 @@ func TestBoxInfo(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m) + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc) ctx := context.Background() info, err := box.Info(ctx) @@ -158,9 +163,10 @@ func TestBoxStopStart(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m) + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc) ctx := context.Background() if err := box.Stop(ctx); err != nil { @@ -186,13 +192,15 @@ func TestBoxGetOrCreate(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) + m := setupManagerForPool(t, &pc) ctx := context.Background() box1, err := m.GetOrCreate(ctx, sandbox.CreateOptions{ ID: "goc-" + pc.Name, Image: testImage(), Owner: "test-user", + Pool: pc.TaiID, }) if err != nil { t.Fatalf("GetOrCreate first: %v", err) @@ -203,6 +211,7 @@ func TestBoxGetOrCreate(t *testing.T) { ID: "goc-" + pc.Name, Image: testImage(), Owner: "test-user", + Pool: pc.TaiID, }) if err != nil { t.Fatalf("GetOrCreate second: %v", err) diff --git a/sandbox/v2/box_workspace_test.go b/sandbox/v2/box_workspace_test.go index 76c4ee96..40525a50 100644 --- a/sandbox/v2/box_workspace_test.go +++ b/sandbox/v2/box_workspace_test.go @@ -16,19 +16,20 @@ func TestWorkspaceID_Set(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - sbm, wsm := setupManagerWithWorkspace(t, pc) + sbm, wsm := setupManagerWithWorkspace(t, &pc) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() ws, err := wsm.Create(ctx, workspace.CreateOptions{ - Name: "test-ws", Owner: "user", Node: pc.Name, + Name: "test-ws", Owner: "user", Node: pc.TaiID, }) require.NoError(t, err) defer wsm.Delete(context.Background(), ws.ID, true) - box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) { co.WorkspaceID = ws.ID }) @@ -41,9 +42,10 @@ func TestWorkspaceID_Empty(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m) + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc) assert.Empty(t, box.WorkspaceID()) }) } @@ -53,23 +55,24 @@ func TestWorkspace_NodeRouting(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - sbm, wsm := setupManagerWithWorkspace(t, pc) + sbm, wsm := setupManagerWithWorkspace(t, &pc) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() ws, err := wsm.Create(ctx, workspace.CreateOptions{ - Name: "routed-ws", Owner: "user", Node: pc.Name, + Name: "routed-ws", Owner: "user", Node: pc.TaiID, }) require.NoError(t, err) defer wsm.Delete(context.Background(), ws.ID, true) - box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) { co.WorkspaceID = ws.ID }) - assert.Equal(t, pc.Name, box.Pool()) + assert.Equal(t, pc.TaiID, box.Pool()) }) } } @@ -78,9 +81,10 @@ func TestWorkspace_InvalidID(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - sbm, _ := setupManagerWithWorkspace(t, pc) - ensureTestImage(t, sbm, pc.Name) + sbm, _ := setupManagerWithWorkspace(t, &pc) + ensureTestImage(t, sbm, pc.TaiID) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -100,20 +104,20 @@ func TestWorkspace_BindMountLocal(t *testing.T) { skipIfNoDocker(t) pc := poolConfig{Name: "local", Addr: testLocalAddr()} - sbm, wsm := setupManagerWithWorkspace(t, pc) + sbm, wsm := setupManagerWithWorkspace(t, &pc) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() ws, err := wsm.Create(ctx, workspace.CreateOptions{ - Name: "mount-ws", Owner: "user", Node: pc.Name, + Name: "mount-ws", Owner: "user", Node: pc.TaiID, }) require.NoError(t, err) defer wsm.Delete(context.Background(), ws.ID, true) require.NoError(t, wsm.WriteFile(ctx, ws.ID, "seed.txt", []byte("hello from workspace"), 0644)) - box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) { co.WorkspaceID = ws.ID }) @@ -126,18 +130,18 @@ func TestWorkspace_ContainerWriteBack(t *testing.T) { skipIfNoDocker(t) pc := poolConfig{Name: "local", Addr: testLocalAddr()} - sbm, wsm := setupManagerWithWorkspace(t, pc) + sbm, wsm := setupManagerWithWorkspace(t, &pc) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() ws, err := wsm.Create(ctx, workspace.CreateOptions{ - Name: "writeback-ws", Owner: "user", Node: pc.Name, + Name: "writeback-ws", Owner: "user", Node: pc.TaiID, }) require.NoError(t, err) defer wsm.Delete(context.Background(), ws.ID, true) - box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) { co.WorkspaceID = ws.ID }) @@ -153,20 +157,20 @@ func TestWorkspace_ReadOnlyMount(t *testing.T) { skipIfNoDocker(t) pc := poolConfig{Name: "local", Addr: testLocalAddr()} - sbm, wsm := setupManagerWithWorkspace(t, pc) + sbm, wsm := setupManagerWithWorkspace(t, &pc) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() ws, err := wsm.Create(ctx, workspace.CreateOptions{ - Name: "ro-ws", Owner: "user", Node: pc.Name, + Name: "ro-ws", Owner: "user", Node: pc.TaiID, }) require.NoError(t, err) defer wsm.Delete(context.Background(), ws.ID, true) require.NoError(t, wsm.WriteFile(ctx, ws.ID, "readonly.txt", []byte("immutable"), 0644)) - box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) { co.WorkspaceID = ws.ID co.MountMode = "ro" }) @@ -186,20 +190,20 @@ func TestWorkspace_CustomMountPath(t *testing.T) { skipIfNoDocker(t) pc := poolConfig{Name: "local", Addr: testLocalAddr()} - sbm, wsm := setupManagerWithWorkspace(t, pc) + sbm, wsm := setupManagerWithWorkspace(t, &pc) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() ws, err := wsm.Create(ctx, workspace.CreateOptions{ - Name: "custom-path-ws", Owner: "user", Node: pc.Name, + Name: "custom-path-ws", Owner: "user", Node: pc.TaiID, }) require.NoError(t, err) defer wsm.Delete(context.Background(), ws.ID, true) require.NoError(t, wsm.WriteFile(ctx, ws.ID, "data.json", []byte(`{"ok":true}`), 0644)) - box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) { co.WorkspaceID = ws.ID co.MountPath = "/data" }) @@ -214,25 +218,22 @@ func TestWorkspace_BoxWorkspaceFS(t *testing.T) { for _, pc := range testPools() { if pc.Name == "local" { - // Local mode: sandbox and workspace use separate tai.Clients with - // different dataDirs, so Box.Workspace() writes to the sandbox volume - // while wsm reads from the workspace volume. Bind mount tests cover - // local workspace I/O end-to-end instead. continue } + pc := pc t.Run(pc.Name, func(t *testing.T) { - sbm, wsm := setupManagerWithWorkspace(t, pc) + sbm, wsm := setupManagerWithWorkspace(t, &pc) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() ws, err := wsm.Create(ctx, workspace.CreateOptions{ - Name: "fs-ws", Owner: "user", Node: pc.Name, + Name: "fs-ws", Owner: "user", Node: pc.TaiID, }) require.NoError(t, err) defer wsm.Delete(context.Background(), ws.ID, true) - box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) { co.WorkspaceID = ws.ID }) @@ -254,23 +255,23 @@ func TestWorkspace_LabelPersistence(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - sbm, wsm := setupManagerWithWorkspace(t, pc) + sbm, wsm := setupManagerWithWorkspace(t, &pc) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() ws, err := wsm.Create(ctx, workspace.CreateOptions{ - Name: "label-ws", Owner: "user", Node: pc.Name, + Name: "label-ws", Owner: "user", Node: pc.TaiID, }) require.NoError(t, err) defer wsm.Delete(context.Background(), ws.ID, true) - box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + box := createTestBox(t, sbm, pc, func(co *sandbox.CreateOptions) { co.WorkspaceID = ws.ID }) - // WorkspaceID getter should reflect what was set assert.Equal(t, ws.ID, box.WorkspaceID()) // Container should also carry the label (verify via exec reading env or diff --git a/sandbox/v2/config.go b/sandbox/v2/config.go deleted file mode 100644 index 8cc9973c..00000000 --- a/sandbox/v2/config.go +++ /dev/null @@ -1,5 +0,0 @@ -package sandbox - -type Config struct { - Pool []Pool -} diff --git a/sandbox/v2/docs/API.md b/sandbox/v2/docs/API.md index 1ce2b865..bffbe3f6 100644 --- a/sandbox/v2/docs/API.md +++ b/sandbox/v2/docs/API.md @@ -16,25 +16,14 @@ Supports workspace mounting, VNC, WebSocket proxying, and HostExec. ### Init ```go -func Init(cfg Config) error +func Init() ``` Initializes the global Manager singleton. Must be called once at startup. +No configuration is needed — node discovery is handled by `tai/registry`. ```go -err := sandbox.Init(sandbox.Config{ - Pool: []sandbox.Pool{ - { - Name: "docker", - Addr: "tai://192.168.1.10:19100", - MaxPerUser: 5, - MaxTotal: 20, - IdleTimeout: 30 * time.Minute, - MaxLifetime: 24 * time.Hour, - StopTimeout: 5 * time.Second, - }, - }, -}) +sandbox.Init() ``` ### M @@ -51,28 +40,12 @@ mgr := sandbox.M() --- -## Config +## Node Discovery -```go -type Config struct { - Pool []Pool -} -``` - -### Pool - -```go -type Pool struct { - Name string - Addr string // "tai://host:port", "tunnel://host:port", or Docker socket - Options []tai.Option // tai.Client options - MaxPerUser int // 0 = unlimited - MaxTotal int // 0 = unlimited - IdleTimeout time.Duration // 0 = no idle cleanup - MaxLifetime time.Duration // 0 = no max lifetime - StopTimeout time.Duration // SIGTERM grace period; 0 = DefaultStopTimeout (2s) -} -``` +Sandbox V2 no longer uses a static pool configuration. Nodes are discovered dynamically +through `tai/registry`. Each Tai node registers itself with a unique **TaiID** (e.g. +`"192.168.1.10-19100"` for direct mode, `"local"` for Docker). The TaiID is used as the +`Pool` identifier in `CreateOptions`, `ListOptions`, `Host()`, `ImageExists()`, etc. --- @@ -126,7 +99,7 @@ Creates and starts a new sandbox container. Returns a `Box` handle. box, err := sandbox.M().Create(ctx, sandbox.CreateOptions{ Image: "alpine:latest", Owner: "user-123", - Pool: "docker", + Pool: "192.168.1.10-19100", // TaiID from registry Policy: sandbox.Session, WorkDir: "/workspace", Env: map[string]string{"LANG": "en_US.UTF-8"}, @@ -151,12 +124,13 @@ box, err := sandbox.M().Create(ctx, sandbox.CreateOptions{ func (m *Manager) Host(ctx context.Context, pool string) (*Host, error) ``` -Returns a `Host` handle for the given pool. Unlike `Create`, no container is provisioned — -the Host is available as long as the pool's Tai server reports `host_exec` capability. -Returns `ErrPoolNotFound` if the pool does not exist, or an error if the pool has no `host_exec`. +Returns a `Host` handle for the given pool (identified by TaiID). Unlike `Create`, no +container is provisioned — the Host is available as long as the Tai server reports +`host_exec` capability. Returns `ErrPoolNotFound` if the TaiID is not registered, +`ErrPoolMissing` if the pool argument is empty, or an error if the node has no `host_exec`. ```go -host, err := sandbox.M().Host(ctx, "remote") +host, err := sandbox.M().Host(ctx, "192.168.1.10-19100") ``` ### Get @@ -198,7 +172,7 @@ Returns all sandboxes matching the given filters. Empty fields = no filter. ```go boxes, err := sandbox.M().List(ctx, sandbox.ListOptions{ Owner: "user-123", - Pool: "docker", + Pool: "192.168.1.10-19100", Labels: map[string]string{"project": "demo"}, }) ``` @@ -209,7 +183,7 @@ boxes, err := sandbox.M().List(ctx, sandbox.ListOptions{ func (m *Manager) Remove(ctx context.Context, id string) error ``` -Force-removes a sandbox (SIGKILL + delete). Revokes container tokens. +Force-removes a sandbox (SIGKILL + delete). ```go err := sandbox.M().Remove(ctx, "sb-12345") @@ -236,63 +210,21 @@ Updates a sandbox's last-active timestamp. Called by the gRPC heartbeat service. err := sandbox.M().Heartbeat("sb-12345", true, 3) ``` -### AddPool - -```go -func (m *Manager) AddPool(ctx context.Context, p Pool) error -``` - -Registers a new pool at runtime. - -```go -err := sandbox.M().AddPool(ctx, sandbox.Pool{ - Name: "k8s-gpu", - Addr: "tai://10.0.0.5:19100", - MaxTotal: 10, -}) -``` - -### RemovePool - -```go -func (m *Manager) RemovePool(ctx context.Context, name string, force bool) error -``` - -Removes a pool. Returns `ErrPoolInUse` if the pool has running boxes and `force=false`. -With `force=true`, all boxes in the pool are removed first. - ### Pools ```go -func (m *Manager) Pools() []PoolInfo +func (m *Manager) Pools() []registry.NodeSnapshot ``` -Returns all registered pools and their status. +Returns all registered Tai nodes from the `tai/registry`. ```go -for _, p := range sandbox.M().Pools() { - fmt.Printf("pool=%s addr=%s connected=%v boxes=%d\n", - p.Name, p.Addr, p.Connected, p.Boxes) +for _, n := range sandbox.M().Pools() { + fmt.Printf("tai_id=%s mode=%s addr=%s status=%s\n", + n.TaiID, n.Mode, n.Addr, n.Status) } ``` -### SetGRPCPort - -```go -func (m *Manager) SetGRPCPort(port int) -``` - -Sets the local gRPC port injected into container env vars (`YAO_GRPC_ADDR`). Default: `9099`. - -### SetWorkspaceManager - -```go -func (m *Manager) SetWorkspaceManager(wm *workspace.Manager) -``` - -Links the workspace manager. When `CreateOptions.WorkspaceID` is set, the Manager uses it -to resolve the workspace's bound node and route the container to the correct pool. - ### ImageExists ```go @@ -303,7 +235,7 @@ Reports whether the given image ref exists on the target pool node. Returns `(true, nil)` when the pool has no image service (e.g. K8s — kubelet handles pulls). ```go -exists, err := sandbox.M().ImageExists(ctx, "docker", "alpine:latest") +exists, err := sandbox.M().ImageExists(ctx, "192.168.1.10-19100", "alpine:latest") ``` ### PullImage @@ -319,7 +251,7 @@ service (e.g. K8s). `PullProgress` fields: `Status string`, `Layer string`, `Current int64`, `Total int64`, `Error string`. ```go -ch, err := sandbox.M().PullImage(ctx, "docker", "myapp:v2", sandbox.ImagePullOptions{ +ch, err := sandbox.M().PullImage(ctx, "192.168.1.10-19100", "myapp:v2", sandbox.ImagePullOptions{ Auth: &sandbox.RegistryAuth{ Username: "user", Password: "pass", @@ -340,7 +272,7 @@ func (m *Manager) EnsureImage(ctx context.Context, pool, ref string, opts ImageP Checks if the image exists; if not, pulls it and blocks until complete. ```go -err := sandbox.M().EnsureImage(ctx, "docker", "alpine:latest", sandbox.ImagePullOptions{}) +err := sandbox.M().EnsureImage(ctx, "192.168.1.10-19100", "alpine:latest", sandbox.ImagePullOptions{}) ``` --- @@ -508,7 +440,7 @@ func (h *Host) Exec(ctx context.Context, cmd string, args []string, opts ...Host Runs a command directly on the Tai host machine via HostExec gRPC. ```go -host, _ := sandbox.M().Host(ctx, "remote") +host, _ := sandbox.M().Host(ctx, "192.168.1.10-19100") result, err := host.Exec(ctx, "git", []string{"status"}, sandbox.WithHostWorkDir("/data/repos/project"), sandbox.WithHostEnv(map[string]string{"GIT_AUTHOR_NAME": "bot"}), @@ -529,7 +461,7 @@ Runs a command on the Tai host and streams stdout/stderr in real time via HostEx ExecStream. Returns a `HostExecStream` with separate channels for stdout and stderr. ```go -host, _ := sandbox.M().Host(ctx, "remote") +host, _ := sandbox.M().Host(ctx, "192.168.1.10-19100") stream, err := host.Stream(ctx, "tail", []string{"-f", "/var/log/app.log"}, sandbox.WithHostWorkDir("/data"), sandbox.WithHostTimeout(60000), @@ -607,7 +539,7 @@ type CreateOptions struct { ID string Owner string Labels map[string]string - Pool string // empty = default pool + Pool string // TaiID from registry (required unless WorkspaceID routes to a node) Image string // required WorkDir string // default "/workspace" User string // container user @@ -617,8 +549,9 @@ type CreateOptions struct { VNC bool Ports []PortMapping Policy LifecyclePolicy // default Session - IdleTimeout time.Duration // overrides pool default - StopTimeout time.Duration // overrides pool default + IdleTimeout time.Duration // 0 = no idle cleanup + MaxLifetime time.Duration // 0 = no max lifetime + StopTimeout time.Duration // SIGTERM grace period; 0 = DefaultStopTimeout (2s) WorkspaceID string // workspace to mount; empty = none MountMode string // "rw" (default) or "ro" MountPath string // default "/workspace" @@ -699,21 +632,6 @@ type BoxInfo struct { } ``` -### PoolInfo - -```go -type PoolInfo struct { - Name string - Addr string - Connected bool - Boxes int - MaxPerUser int - MaxTotal int - IdleTimeout time.Duration - MaxLifetime time.Duration -} -``` - ### ImagePullOptions / RegistryAuth ```go @@ -758,11 +676,10 @@ type HostExecStream struct { ```go var ( - ErrNotAvailable = errors.New("sandbox: not available (no pools configured)") - ErrNotFound = errors.New("sandbox: not found") - ErrLimitExceeded = errors.New("sandbox: limit exceeded") - ErrPoolNotFound = errors.New("sandbox: pool not found") - ErrPoolInUse = errors.New("sandbox: pool has running boxes") + ErrNotAvailable = errors.New("sandbox: not available (no pools configured)") + ErrNotFound = errors.New("sandbox: not found") + ErrPoolNotFound = errors.New("sandbox: pool not found") + ErrPoolMissing = errors.New("sandbox: pool name is required") ) ``` @@ -770,38 +687,28 @@ var ( ## Helper Functions -### CreateContainerTokens - -```go -func CreateContainerTokens(sandboxID, owner string, scopes []string) (access, refresh string, err error) -``` - -Creates an OAuth token pair for a sandbox container. - -### RevokeContainerTokens - -```go -func RevokeContainerTokens(refresh string) error -``` - -Revokes a container refresh token. - ### BuildGRPCEnv ```go -func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) map[string]string +func BuildGRPCEnv(mode, addr, sandboxID string) map[string]string ``` -Builds environment variables injected into sandbox containers: +Builds environment variables injected into sandbox containers. The gRPC port is read from +`config.Conf.GRPC.Port` (defaults to `9099`). -| Variable | Description | -|--------------------|--------------------------------------| -| `YAO_SANDBOX_ID` | Sandbox identifier | -| `YAO_TOKEN` | Access token for gRPC auth | -| `YAO_REFRESH_TOKEN` | Refresh token for token rotation | -| `YAO_GRPC_ADDR` | gRPC server address (auto-derived) | +- `mode` — the `TaiNode.Mode` (`"local"`, `"direct"`, `"tunnel"`) +- `addr` — the `TaiNode.Addr` (e.g. `"tai://192.168.1.10:19100"` for direct mode) +- `sandboxID` — the container's sandbox identifier + +| Variable | Description | +|------------------|------------------------------------| +| `YAO_SANDBOX_ID` | Sandbox identifier | +| `YAO_GRPC_ADDR` | gRPC server address (auto-derived) | Address derivation logic: -- `tai://host:port` → `host:port` (default port 19100 when omitted) -- `tunnel://...` → `127.0.0.1:` -- Local/default → `127.0.0.1:` +- `local` → `host.docker.internal:` +- `direct` with `tai://host:port` → `host:port` +- `tunnel` → `127.0.0.1:` + +Token injection (`YAO_TOKEN`, `YAO_REFRESH_TOKEN`) is the **caller's responsibility** via +`CreateOptions.Env`. See IMPL.md "OAuth Decoupling" for details. diff --git a/sandbox/v2/errors.go b/sandbox/v2/errors.go index deda6ea7..bfc0de8d 100644 --- a/sandbox/v2/errors.go +++ b/sandbox/v2/errors.go @@ -3,9 +3,8 @@ package sandbox import "errors" var ( - ErrNotAvailable = errors.New("sandbox: not available (no pools configured)") - ErrNotFound = errors.New("sandbox: not found") - ErrLimitExceeded = errors.New("sandbox: limit exceeded") - ErrPoolNotFound = errors.New("sandbox: pool not found") - ErrPoolInUse = errors.New("sandbox: pool has running boxes") + ErrNotAvailable = errors.New("sandbox: not available (no pools configured)") + ErrNotFound = errors.New("sandbox: not found") + ErrPoolNotFound = errors.New("sandbox: pool not found") + ErrPoolMissing = errors.New("sandbox: pool name is required") ) diff --git a/sandbox/v2/grpc.go b/sandbox/v2/grpc.go index 5f99eab7..e3d85399 100644 --- a/sandbox/v2/grpc.go +++ b/sandbox/v2/grpc.go @@ -1,63 +1,43 @@ package sandbox import ( - "crypto/rand" - "encoding/hex" "fmt" "net/url" "strconv" - "strings" + + "github.com/yaoapp/yao/config" ) -func createToken() (string, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", err +// BuildGRPCEnv builds the gRPC environment variables for a sandbox container +// based on the Tai node's mode and address from the registry. +// +// mode is the TaiNode.Mode ("local", "direct", "tunnel"). +// addr is the TaiNode.Addr (e.g. "tai://host:port" for direct mode). +// sandboxID is the container's sandbox identifier. +// +// The Yao gRPC port is read from config.Conf.GRPC.Port. +func BuildGRPCEnv(mode, addr, sandboxID string) map[string]string { + grpcPort := config.Conf.GRPC.Port + if grpcPort == 0 { + grpcPort = 9099 } - return hex.EncodeToString(b), nil -} - -// CreateContainerTokens creates an OAuth token pair for a sandbox container. -func CreateContainerTokens(sandboxID, owner string, scopes []string) (access, refresh string, err error) { - access, err = createToken() - if err != nil { - return "", "", err - } - refresh, err = createToken() - if err != nil { - return "", "", err - } - return access, refresh, nil -} - -// RevokeContainerTokens revokes a refresh token for a sandbox container. -func RevokeContainerTokens(refresh string) error { - return nil -} - -// BuildGRPCEnv builds the gRPC environment variables for a sandbox container. -// Supports tai:// (direct), tunnel:// (NAT traversal), and local modes. -func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) map[string]string { portStr := strconv.Itoa(grpcPort) + env := map[string]string{ - "YAO_SANDBOX_ID": sandboxID, - "YAO_TOKEN": access, - "YAO_REFRESH_TOKEN": refresh, + "YAO_SANDBOX_ID": sandboxID, } - if pool == nil { + switch mode { + case "local": + env["YAO_GRPC_ADDR"] = fmt.Sprintf("host.docker.internal:%s", portStr) + + case "tunnel": env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%s", portStr) - return env - } - switch { - case strings.HasPrefix(pool.Addr, "tunnel://"): - env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%d", grpcPort) - - case strings.HasPrefix(pool.Addr, "tai://"): - u, err := url.Parse(pool.Addr) - if err != nil { - env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%s", portStr) + case "direct": + u, err := url.Parse(addr) + if err != nil || u.Hostname() == "" { + env["YAO_GRPC_ADDR"] = fmt.Sprintf("host.docker.internal:%s", portStr) return env } taiHost := u.Hostname() @@ -68,7 +48,7 @@ func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) m env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:%s", taiHost, taiPort) default: - env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%s", portStr) + env["YAO_GRPC_ADDR"] = fmt.Sprintf("host.docker.internal:%s", portStr) } return env } diff --git a/sandbox/v2/grpc_test.go b/sandbox/v2/grpc_test.go index b9905988..800a2cae 100644 --- a/sandbox/v2/grpc_test.go +++ b/sandbox/v2/grpc_test.go @@ -3,27 +3,28 @@ package sandbox_test import ( "testing" + "github.com/yaoapp/yao/config" sandbox "github.com/yaoapp/yao/sandbox/v2" ) func TestBuildGRPCEnvLocal(t *testing.T) { - pool := &sandbox.Pool{Name: "local", Addr: "local"} - env := sandbox.BuildGRPCEnv(pool, "sb-001", "access-tok", "refresh-tok", 9099) + config.Conf.GRPC.Port = 9099 + env := sandbox.BuildGRPCEnv("local", "", "sb-001") if env["YAO_SANDBOX_ID"] != "sb-001" { t.Errorf("YAO_SANDBOX_ID = %q", env["YAO_SANDBOX_ID"]) } - if env["YAO_TOKEN"] != "access-tok" { - t.Errorf("YAO_TOKEN = %q", env["YAO_TOKEN"]) + if _, ok := env["YAO_TOKEN"]; ok { + t.Error("YAO_TOKEN should not be set by BuildGRPCEnv") } - if env["YAO_GRPC_ADDR"] != "127.0.0.1:9099" { - t.Errorf("YAO_GRPC_ADDR = %q", env["YAO_GRPC_ADDR"]) + if env["YAO_GRPC_ADDR"] != "host.docker.internal:9099" { + t.Errorf("YAO_GRPC_ADDR = %q, want host.docker.internal:9099", env["YAO_GRPC_ADDR"]) } } -func TestBuildGRPCEnvRemote(t *testing.T) { - pool := &sandbox.Pool{Name: "gpu", Addr: "tai://gpu-server"} - env := sandbox.BuildGRPCEnv(pool, "sb-002", "access", "refresh", 9099) +func TestBuildGRPCEnvDirect(t *testing.T) { + config.Conf.GRPC.Port = 9099 + env := sandbox.BuildGRPCEnv("direct", "tai://gpu-server", "sb-002") if env["YAO_GRPC_ADDR"] != "gpu-server:19100" { t.Errorf("YAO_GRPC_ADDR = %q, want gpu-server:19100", env["YAO_GRPC_ADDR"]) @@ -31,26 +32,10 @@ func TestBuildGRPCEnvRemote(t *testing.T) { } func TestBuildGRPCEnvTunnel(t *testing.T) { - pool := &sandbox.Pool{Name: "tunnel", Addr: "tunnel://relay.example.com"} - env := sandbox.BuildGRPCEnv(pool, "sb-003", "access", "refresh", 9099) + config.Conf.GRPC.Port = 9099 + env := sandbox.BuildGRPCEnv("tunnel", "tunnel://relay.example.com", "sb-003") if env["YAO_GRPC_ADDR"] != "127.0.0.1:9099" { t.Errorf("YAO_GRPC_ADDR = %q, want 127.0.0.1:9099", env["YAO_GRPC_ADDR"]) } } - -func TestCreateContainerTokens(t *testing.T) { - access, refresh, err := sandbox.CreateContainerTokens("sb-001", "user1", nil) - if err != nil { - t.Fatalf("CreateContainerTokens: %v", err) - } - if len(access) != 64 { - t.Errorf("access token len = %d, want 64 hex chars", len(access)) - } - if len(refresh) != 64 { - t.Errorf("refresh token len = %d, want 64 hex chars", len(refresh)) - } - if access == refresh { - t.Error("access and refresh tokens should be different") - } -} diff --git a/sandbox/v2/host_test.go b/sandbox/v2/host_test.go index 54d74c97..2d9301e3 100644 --- a/sandbox/v2/host_test.go +++ b/sandbox/v2/host_test.go @@ -12,16 +12,11 @@ import ( "github.com/yaoapp/yao/tai" ) -func setupHostManager(t *testing.T, tgt hostExecTarget) *sandbox.Manager { +func setupHostManager(t *testing.T, tgt *hostExecTarget) *sandbox.Manager { t.Helper() addr := fmt.Sprintf("tai://%s", tgt.Addr) - pool := sandbox.Pool{Name: tgt.Name, Addr: addr} - cfg := sandbox.Config{Pool: []sandbox.Pool{pool}} - if err := sandbox.Init(cfg); err != nil { - t.Fatalf("Init: %v", err) - } - m := sandbox.M() - t.Cleanup(func() { m.Close() }) + m, pools := setupManager(t, poolConfig{Name: tgt.Name, Addr: addr}) + tgt.TaiID = pools[0].TaiID return m } @@ -29,10 +24,11 @@ func TestHost_Exec_Echo(t *testing.T) { skipIfNoHostExec(t) for _, tgt := range hostExecTargets() { + tgt := tgt t.Run(tgt.Name, func(t *testing.T) { - m := setupHostManager(t, tgt) + m := setupHostManager(t, &tgt) - host, err := m.Host(context.Background(), tgt.Name) + host, err := m.Host(context.Background(), tgt.TaiID) if err != nil { t.Skipf("Host(%s): %v", tgt.Name, err) } @@ -66,10 +62,11 @@ func TestHost_Exec_Env(t *testing.T) { skipIfNoHostExec(t) for _, tgt := range hostExecTargets() { + tgt := tgt t.Run(tgt.Name, func(t *testing.T) { - m := setupHostManager(t, tgt) + m := setupHostManager(t, &tgt) - host, err := m.Host(context.Background(), tgt.Name) + host, err := m.Host(context.Background(), tgt.TaiID) if err != nil { t.Skipf("Host(%s): %v", tgt.Name, err) } @@ -106,10 +103,11 @@ func TestHost_Workplace(t *testing.T) { skipIfNoHostExec(t) for _, tgt := range hostExecTargets() { + tgt := tgt t.Run(tgt.Name, func(t *testing.T) { - m := setupHostManager(t, tgt) + m := setupHostManager(t, &tgt) - host, err := m.Host(context.Background(), tgt.Name) + host, err := m.Host(context.Background(), tgt.TaiID) if err != nil { t.Skipf("Host(%s): %v", tgt.Name, err) } @@ -163,10 +161,11 @@ func TestHost_Stream_Incremental(t *testing.T) { if tgt.IsWinNative { continue } + tgt := tgt t.Run(tgt.Name, func(t *testing.T) { - m := setupHostManager(t, tgt) + m := setupHostManager(t, &tgt) - host, err := m.Host(context.Background(), tgt.Name) + host, err := m.Host(context.Background(), tgt.TaiID) if err != nil { t.Skipf("Host(%s): %v", tgt.Name, err) } @@ -225,10 +224,11 @@ func TestHost_Stream_MultiLine(t *testing.T) { if tgt.IsWinNative { continue } + tgt := tgt t.Run(tgt.Name, func(t *testing.T) { - m := setupHostManager(t, tgt) + m := setupHostManager(t, &tgt) - host, err := m.Host(context.Background(), tgt.Name) + host, err := m.Host(context.Background(), tgt.TaiID) if err != nil { t.Skipf("Host(%s): %v", tgt.Name, err) } @@ -270,10 +270,11 @@ func TestHost_Stream_Stderr(t *testing.T) { if tgt.IsWinNative { continue } + tgt := tgt t.Run(tgt.Name, func(t *testing.T) { - m := setupHostManager(t, tgt) + m := setupHostManager(t, &tgt) - host, err := m.Host(context.Background(), tgt.Name) + host, err := m.Host(context.Background(), tgt.TaiID) if err != nil { t.Skipf("Host(%s): %v", tgt.Name, err) } @@ -319,10 +320,11 @@ func TestHost_Stream_Cancel(t *testing.T) { if tgt.IsWinNative { continue } + tgt := tgt t.Run(tgt.Name, func(t *testing.T) { - m := setupHostManager(t, tgt) + m := setupHostManager(t, &tgt) - host, err := m.Host(context.Background(), tgt.Name) + host, err := m.Host(context.Background(), tgt.TaiID) if err != nil { t.Skipf("Host(%s): %v", tgt.Name, err) } @@ -369,10 +371,11 @@ func TestHost_ComputerInfo(t *testing.T) { skipIfNoHostExec(t) for _, tgt := range hostExecTargets() { + tgt := tgt t.Run(tgt.Name, func(t *testing.T) { - m := setupHostManager(t, tgt) + m := setupHostManager(t, &tgt) - host, err := m.Host(context.Background(), tgt.Name) + host, err := m.Host(context.Background(), tgt.TaiID) if err != nil { t.Skipf("Host(%s): %v", tgt.Name, err) } @@ -381,8 +384,8 @@ func TestHost_ComputerInfo(t *testing.T) { if info.Kind != "host" { t.Errorf("Kind = %q, want 'host'", info.Kind) } - if info.Pool != tgt.Name { - t.Errorf("Pool = %q, want %q", info.Pool, tgt.Name) + if info.Pool != tgt.TaiID { + t.Errorf("Pool = %q, want %q", info.Pool, tgt.TaiID) } }) } @@ -392,10 +395,11 @@ func TestHost_ComputerInterface(t *testing.T) { skipIfNoHostExec(t) for _, tgt := range hostExecTargets() { + tgt := tgt t.Run(tgt.Name, func(t *testing.T) { - m := setupHostManager(t, tgt) + m := setupHostManager(t, &tgt) - host, err := m.Host(context.Background(), tgt.Name) + host, err := m.Host(context.Background(), tgt.TaiID) if err != nil { t.Skipf("Host(%s): %v", tgt.Name, err) } @@ -416,7 +420,7 @@ func TestHost_CreateRejectsNoContainerPool(t *testing.T) { t.Skip("no host-exec-only target available") } - m := setupHostManager(t, *tgt) + m := setupHostManager(t, tgt) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -424,7 +428,7 @@ func TestHost_CreateRejectsNoContainerPool(t *testing.T) { _, err := m.Create(ctx, sandbox.CreateOptions{ Image: "alpine:latest", Owner: "test", - Pool: tgt.Name, + Pool: tgt.TaiID, }) if err == nil { t.Fatal("expected error for Create on host-exec-only pool, got nil") @@ -437,7 +441,7 @@ func TestHost_CreateRejectsNoContainerPool(t *testing.T) { func TestHost_PoolNotFound(t *testing.T) { skipIfNoHostExec(t) tgt := hostExecTargets()[0] - m := setupHostManager(t, tgt) + m := setupHostManager(t, &tgt) _, err := m.Host(context.Background(), "nonexistent-pool") if err == nil { diff --git a/sandbox/v2/jsapi/API.md b/sandbox/v2/jsapi/API.md index f22dc48c..0f2807a5 100644 --- a/sandbox/v2/jsapi/API.md +++ b/sandbox/v2/jsapi/API.md @@ -13,7 +13,8 @@ pc.Remove() // Or use the host directly (no container) const host = sandbox.Host() -host.Exec(["ls", "-la", "/workspace"]) +const info = host.Exec(["uname", "-a"]) +console.log(info.stdout) // same ExecResult as box ``` Both `sandbox.Create()` and `sandbox.Host()` return a **Computer** object with the same interface. The `kind` property tells you which type it is. @@ -30,7 +31,7 @@ Create a new sandbox container. Returns a Computer (`kind = "box"`). If `options const pc = sandbox.Create({ image: "node:20", // required — container image owner: "user-123", // required — owner identifier - pool: "gpu", // optional — pool name (default: first pool) + pool: "192.168.1.10-19100", // optional — TaiID from registry (required unless workspace_id routes to a node) id: "my-sandbox", // optional — if set, uses GetOrCreate workdir: "/app", // optional — working directory user: "1000:1000", // optional — UID:GID @@ -73,8 +74,8 @@ const all = sandbox.List() // Filter by owner const mine = sandbox.List({ owner: "user-123" }) -// Filter by pool and labels -const gpu = sandbox.List({ pool: "gpu", labels: { team: "ml" } }) +// Filter by pool (TaiID) and labels +const gpu = sandbox.List({ pool: "10.0.0.5-19100", labels: { team: "ml" } }) ``` Each element in the returned array: @@ -83,7 +84,7 @@ Each element in the returned array: { id: "sb-xxx", container_id: "abc123...", - pool: "default", + pool: "192.168.1.10-19100", owner: "user-123", status: "running", // "running"|"stopped"|"creating"|... image: "node:20", @@ -104,13 +105,12 @@ Remove a sandbox and its container. sandbox.Delete("my-sandbox") ``` -### sandbox.Host(pool?) → Computer +### sandbox.Host(pool) → Computer -Get a Computer (`kind = "host"`) for executing commands directly on the Tai host machine (no container). Only available when the pool's Tai server has `host_exec` capability. +Get a Computer (`kind = "host"`) for executing commands directly on the Tai host machine (no container). Only available when the node's Tai server has `host_exec` capability. The `pool` argument is the TaiID (e.g. `"192.168.1.10-19100"`). ```javascript -const host = sandbox.Host() // default pool -const gpu = sandbox.Host("gpu") // specific pool +const host = sandbox.Host("192.168.1.10-19100") ``` ### sandbox.GetNode(taiID) → NodeInfo | null @@ -149,7 +149,7 @@ const nodes = sandbox.NodesByTeam("team-001") Returned by `sandbox.Create()`, `sandbox.Get()`, and `sandbox.Host()`. This is the unified interface for all execution environments — containers and bare-metal hosts. -Use the `kind` property to check the type. Methods marked **box-only** throw an error when called on a host computer. +Use the `kind` property to check the type. Methods marked **box-only** throw an error when called on a host computer. `Proxy()` covers HTTP, WebSocket, and SSE — use it for all protocol access to container/host services. ### Properties (read-only) @@ -158,7 +158,7 @@ Use the `kind` property to check the type. Methods marked **box-only** throw an | `pc.kind` | string | `"box"` or `"host"` | | `pc.id` | string | Sandbox ID (box-only; empty for host) | | `pc.owner` | string | Owner identifier (box-only; empty for host) | -| `pc.pool` | string | Pool name | +| `pc.pool` | string | TaiID (e.g. `"192.168.1.10-19100"`, `"local"`) | ### pc.Exec(cmd, options?) → ExecResult @@ -238,7 +238,7 @@ If no VNC server is running, the WebSocket connection will fail — handle this ### pc.Proxy(port, path?) → string -Get an HTTP proxy URL for a service port. +Get a proxy URL for a service port. Supports HTTP, WebSocket (`ws://`), and SSE — the Tai proxy handles protocol upgrades automatically. - **Box**: routes to `container-ip:{port}` - **Host**: routes to `127.0.0.1:{port}` on the Tai machine via `__host__` @@ -260,7 +260,7 @@ Get identity and registry information. ```javascript const info = pc.ComputerInfo() console.log(info.kind) // "box" or "host" -console.log(info.pool) // pool name +console.log(info.pool) // TaiID console.log(info.system.os) // "linux" | "windows" | "darwin" console.log(info.status) // "running" | "stopped" | ... ``` @@ -269,7 +269,7 @@ Returns a [ComputerInfo](#computerinfo-object) object. ### pc.BindWorkplace(workspaceID) → void -Bind a workspace to this computer for the current session. +Bind a workspace to this computer for the current session. For box computers created with a `workspace_id` option, the workspace is already bound at creation time — calling `BindWorkplace` overrides it. ```javascript pc.BindWorkplace("ws-project-abc") @@ -277,7 +277,7 @@ pc.BindWorkplace("ws-project-abc") ### pc.Workplace() → WorkspaceFS | null -Access the workspace bound via `BindWorkplace()`. Returns `null` if no workspace is bound. +Access the workspace filesystem bound via `BindWorkplace()`. Returns `null` if no workspace is bound. ("Workplace" is the binding on a Computer; "Workspace" is the filesystem it points to.) ```javascript pc.BindWorkplace("ws-project-abc") @@ -288,30 +288,9 @@ ws.WriteFile("output.json", JSON.stringify(data)) See [WorkspaceFS Object](#workspacefs-object) for the full method list. -### pc.Attach(port, options?) → string — box-only - -Get a WebSocket or SSE endpoint URL for a service running inside the container. Throws on host computers. - -```javascript -const wsURL = pc.Attach(3000, { protocol: "ws", path: "/ws" }) -// "ws://tai-host:8099/container-id:3000/ws" - -const sseURL = pc.Attach(8080, { protocol: "sse", path: "/events" }) -// "http://tai-host:8099/container-id:8080/events" -``` - -Options: - -```javascript -{ - protocol: "ws" | "sse", // default "ws"; affects URL scheme (ws:// vs http://) - path: "/ws" // optional URL path suffix -} -``` - ### pc.Info() → BoxInfo — box-only -Get current container status information. Throws on host computers. +Get current container runtime status (process count, last active time, etc.). For node-level identity info (OS, CPU, capabilities), use `ComputerInfo()` instead. Throws on host computers. ```javascript const info = pc.Info() @@ -353,7 +332,7 @@ Returned by `pc.ComputerInfo()`. Read-only snapshot of a Computer's identity and ```javascript { kind: "box", // "box" | "host" - pool: "default", + pool: "192.168.1.10-19100", // TaiID tai_id: "tai-abc123", machine_id: "m-xyz", version: "1.2.3", @@ -390,7 +369,7 @@ Returned by `sandbox.GetNode()`, `sandbox.Nodes()`, `sandbox.NodesByTeam()`. Rea machine_id: "m-xyz", version: "1.2.3", mode: "direct", // "direct" | "tunnel" - addr: "192.168.1.100", + addr: "tai://192.168.1.100:19100", status: "online", // "online" | "offline" | "connecting" pool: "gpu", connected_at: "2026-03-07T08:00:00Z", @@ -509,16 +488,17 @@ pc.Stream(["npm", "run", "dev"], { workdir: "/app" }, function(type, data) { ### Host execution for GPU workloads ```javascript -const host = sandbox.Host("gpu") +const host = sandbox.Host("10.0.0.5-19100") const result = host.Exec(["nvidia-smi"]) console.log(result.stdout) -host.Exec(["python3", "train.py", "--epochs=10"], { +const train = host.Exec(["python3", "train.py", "--epochs=10"], { workdir: "/workspace/ml", env: { CUDA_VISIBLE_DEVICES: "0,1" }, timeout: 3600000 }) +if (train.exit_code !== 0) throw new Error("training failed: " + train.stderr) ``` ### Uniform interface — same code for box and host @@ -534,7 +514,7 @@ function runTask(pc, cmd, opts) { // Works the same for both const box = sandbox.Create({ image: "node:20", owner: "u1" }) -const host = sandbox.Host("gpu") +const host = sandbox.Host("10.0.0.5-19100") runTask(box, ["node", "-e", "console.log('hi')"]) runTask(host, ["echo", "hello"]) @@ -558,7 +538,7 @@ const appURL = pc.Proxy(3000) // "http://tai-host:8099/container-id:3000/" // Same methods work on host -const host = sandbox.Host() +const host = sandbox.Host("192.168.1.10-19100") const hostVNC = host.VNC() // "ws://tai-host:16080/vnc/__host__/ws" ``` diff --git a/sandbox/v2/jsapi/computer.go b/sandbox/v2/jsapi/computer.go index b0be6de9..27c95a7a 100644 --- a/sandbox/v2/jsapi/computer.go +++ b/sandbox/v2/jsapi/computer.go @@ -1,138 +1,471 @@ package jsapi import ( + "context" + "encoding/json" + "sync" + "time" + + sandbox "github.com/yaoapp/yao/sandbox/v2" + wsjsapi "github.com/yaoapp/yao/workspace/jsapi" "rogchap.com/v8go" ) -// sbHost: `sandbox.Host(pool?)` → Computer (kind="host") -// -// Go: Manager.Host(ctx, pool) (*Host, error) -// -// Args: -// -// pool: string (optional) — pool name; empty = default pool -// -// Returns: Computer object (kind="host") if the pool has host_exec capability, otherwise throws. -func sbHost(info *v8go.FunctionCallbackInfo) *v8go.Value { - // TODO: Phase 2 - // 1. pool := ""; if len(info.Args()) > 0 && info.Args()[0].IsString() { pool = info.Args()[0].String() } - // 2. host, err := sandbox.M().Host(ctx, pool) - // 3. if err != nil { throw in V8 } - // 4. Return NewComputerObject(v8ctx, "host", pool) - return v8go.Undefined(info.Context().Isolate()) +// --------------------------------------------------------------------------- +// Helpers — shared across jsapi files +// --------------------------------------------------------------------------- + +func throwError(info *v8go.FunctionCallbackInfo, msg string) *v8go.Value { + iso := info.Context().Isolate() + e, _ := v8go.NewValue(iso, msg) + iso.ThrowException(e) + return v8go.Undefined(iso) } -// NewComputerObject creates a unified JS Computer object backed by either a Box or Host. -// The `kind` field ("box" or "host") determines which methods are available at runtime. -// Box-only methods (Attach, Info, Start, Stop, Remove) throw an error when called on a host. -// -// # Properties (read-only) -// -// pc.kind → string // "box" | "host" ← ComputerInfo().Kind -// pc.id → string // sandbox ID ← Box.ID() (empty for host) -// pc.owner → string // owner ← Box.Owner() (empty for host) -// pc.pool → string // pool name ← ComputerInfo().Pool -// -// # Methods — Computer interface (both box and host) -// -// pc.Exec(cmd, options?) → ExecResult -// -// Go: Computer.Exec(ctx, cmd []string, opts ...ExecOption) (*ExecResult, error) -// -// JS args: -// cmd: string[] → cmd []string -// options: { → ExecOption -// workdir: string, → WithWorkDir(dir) -// env: object, → WithEnv(map[string]string) -// stdin: string, → WithStdin([]byte) -// timeout: number, → WithTimeout(ms → time.Duration) -// max_output: number → WithMaxOutput(bytes int64) -// } -// JS returns: { -// exit_code: number, ← ExecResult.ExitCode -// stdout: string, ← ExecResult.Stdout -// stderr: string, ← ExecResult.Stderr -// duration_ms: number, ← ExecResult.DurationMs -// error: string, ← ExecResult.Error -// truncated: boolean ← ExecResult.Truncated -// } -// -// pc.Stream(cmd, callback) / pc.Stream(cmd, options, callback) -// -// Go: Computer.Stream(ctx, cmd []string, opts ...ExecOption) (*ExecStream, error) -// -// Blocks until the process exits. The last argument must be a JS function. -// Callback signature: function(type, data) -// type = "stdout" → data is string (chunk) -// type = "stderr" → data is string (chunk) -// type = "exit" → data is number (exit code) -// -// pc.VNC() → string -// -// Go: Computer.VNC(ctx) (string, error) -// Box: returns ws://host:port/vnc/{containerID}/ws -// Host: returns ws://host:port/vnc/__host__/ws -// -// pc.Proxy(port, path?) → string -// -// Go: Computer.Proxy(ctx, port int, path string) (string, error) -// Box: returns http://host:port/{containerID}:{port}/{path} -// Host: returns http://host:port/__host__:{port}/{path} -// -// pc.ComputerInfo() → ComputerInfo -// -// Go: Computer.ComputerInfo() ComputerInfo -// JS returns: { kind, pool, tai_id, machine_id, version, mode, status, capabilities, -// system: { os, arch, hostname, num_cpu, total_mem }, -// box_id, container_id, owner, image, policy, labels } -// -// pc.BindWorkplace(workspaceID) → void -// -// Go: Computer.BindWorkplace(workspaceID string) -// -// pc.Workplace() → WorkspaceFS | null -// -// Go: Computer.Workplace() workspace.FS -// Returns WorkspaceFS if a workplace is bound, null otherwise. -// -// # Methods — Box-only (throw on host) -// -// pc.Attach(port, options?) → string -// -// Gets a WebSocket/SSE endpoint URL for a container service. -// JS args: -// port: number -// options: { protocol: "ws"|"sse", path: string } -// JS returns: string (URL) -// -// pc.Info() → BoxInfo -// -// Go: Box.Info(ctx) (*BoxInfo, error) -// JS returns: { id, container_id, pool, owner, status, image, vnc, policy, -// labels, created_at, last_active, process_count } -// -// pc.Start() → void -// -// Go: Box.Start(ctx) error -// -// pc.Stop() → void -// -// Go: Box.Stop(ctx) error -// -// pc.Remove() → void -// -// Go: Box.Remove(ctx) error -func NewComputerObject(v8ctx *v8go.Context, kind string, id string) (*v8go.Value, error) { - // TODO: Phase 2 implementation - // 1. Create JS object via v8go.NewObjectTemplate - // 2. Set read-only properties: kind, id, owner, pool - // - kind: "box" or "host" - // - id/owner: from sandbox.M().Get(id) for box; empty for host - // - pool: from ComputerInfo().Pool - // 3. Bind Computer interface methods: - // - Exec, Stream, VNC, Proxy, ComputerInfo, BindWorkplace, Workplace - // 4. Bind box-only methods with kind guard: - // - Attach, Info, Start, Stop, Remove - // - If kind == "host", these throw: "not supported: {method}() requires a box computer" - return nil, nil +func parseStringArray(val *v8go.Value) []string { + obj, err := val.AsObject() + if err != nil { + return nil + } + lenVal, err := obj.Get("length") + if err != nil { + return nil + } + length := int(lenVal.Int32()) + result := make([]string, 0, length) + for i := 0; i < length; i++ { + item, err := obj.GetIdx(uint32(i)) + if err != nil || !item.IsString() { + continue + } + result = append(result, item.String()) + } + return result +} + +func parseStringMap(v8ctx *v8go.Context, val *v8go.Value) map[string]string { + result := make(map[string]string) + if !val.IsObject() { + return result + } + jsonStr, err := v8go.JSONStringify(v8ctx, val) + if err != nil { + return result + } + _ = json.Unmarshal([]byte(jsonStr), &result) + return result +} + +func parseExecOptions(v8ctx *v8go.Context, args []*v8go.Value) ([]string, []sandbox.ExecOption, *v8go.Value) { + if len(args) < 1 || !args[0].IsObject() { + return nil, nil, nil + } + cmd := parseStringArray(args[0]) + if len(cmd) == 0 { + return nil, nil, nil + } + var opts []sandbox.ExecOption + var callback *v8go.Value + for i := 1; i < len(args); i++ { + v := args[i] + if v.IsFunction() { + callback = v + break + } + if v.IsObject() { + optsObj, err := v.AsObject() + if err != nil { + continue + } + if wd, e := optsObj.Get("workdir"); e == nil && wd.IsString() { + opts = append(opts, sandbox.WithWorkDir(wd.String())) + } + if env, e := optsObj.Get("env"); e == nil && env.IsObject() { + envMap := parseStringMap(v8ctx, env) + if len(envMap) > 0 { + opts = append(opts, sandbox.WithEnv(envMap)) + } + } + if stdin, e := optsObj.Get("stdin"); e == nil && stdin.IsString() { + opts = append(opts, sandbox.WithStdin([]byte(stdin.String()))) + } + if t, e := optsObj.Get("timeout"); e == nil && t.IsNumber() { + opts = append(opts, sandbox.WithTimeout(time.Duration(t.Number())*time.Millisecond)) + } + if mo, e := optsObj.Get("max_output"); e == nil && mo.IsNumber() { + opts = append(opts, sandbox.WithMaxOutput(int64(mo.Number()))) + } + } + } + return cmd, opts, callback +} + +func execResultToJS(v8ctx *v8go.Context, r *sandbox.ExecResult) *v8go.Value { + data, _ := json.Marshal(map[string]interface{}{ + "exit_code": r.ExitCode, + "stdout": r.Stdout, + "stderr": r.Stderr, + "duration_ms": r.DurationMs, + "error": r.Error, + "truncated": r.Truncated, + }) + val, _ := v8go.JSONParse(v8ctx, string(data)) + return val +} + +func boxInfoToJS(v8ctx *v8go.Context, b *sandbox.BoxInfo) *v8go.Value { + data, _ := json.Marshal(map[string]interface{}{ + "id": b.ID, + "container_id": b.ContainerID, + "pool": b.Pool, + "owner": b.Owner, + "status": b.Status, + "image": b.Image, + "vnc": b.VNC, + "policy": string(b.Policy), + "labels": b.Labels, + "created_at": b.CreatedAt.Format(time.RFC3339), + "last_active": b.LastActive.Format(time.RFC3339), + "process_count": b.ProcessCount, + }) + val, _ := v8go.JSONParse(v8ctx, string(data)) + return val +} + +func computerInfoToJS(v8ctx *v8go.Context, c sandbox.ComputerInfo) *v8go.Value { + data, _ := json.Marshal(map[string]interface{}{ + "kind": c.Kind, + "pool": c.Pool, + "tai_id": c.TaiID, + "machine_id": c.MachineID, + "version": c.Version, + "mode": c.Mode, + "status": c.Status, + "capabilities": c.Capabilities, + "system": map[string]interface{}{ + "os": c.System.OS, + "arch": c.System.Arch, + "hostname": c.System.Hostname, + "num_cpu": c.System.NumCPU, + "total_mem": c.System.TotalMem, + }, + "box_id": c.BoxID, + "container_id": c.ContainerID, + "owner": c.Owner, + "image": c.Image, + "policy": string(c.Policy), + "labels": c.Labels, + }) + val, _ := v8go.JSONParse(v8ctx, string(data)) + return val +} + +// getComputer re-fetches a Computer from the Manager by kind + identifier. +// kind="box" → identifier is boxID, kind="host" → identifier is pool name. +func getComputer(ctx context.Context, kind, identifier string) (sandbox.Computer, error) { + m := sandbox.M() + if kind == "box" { + return m.Get(ctx, identifier) + } + return m.Host(ctx, identifier) +} + +// --------------------------------------------------------------------------- +// sbHost — sandbox.Host(pool?) +// --------------------------------------------------------------------------- + +func sbHost(info *v8go.FunctionCallbackInfo) *v8go.Value { + ctx := context.Background() + v8ctx := info.Context() + + pool := "" + args := info.Args() + if len(args) > 0 && args[0].IsString() { + pool = args[0].String() + } + + if _, err := sandbox.M().Host(ctx, pool); err != nil { + return throwError(info, err.Error()) + } + + val, err := NewComputerObject(v8ctx, "host", pool) + if err != nil { + return throwError(info, err.Error()) + } + return val +} + +// --------------------------------------------------------------------------- +// NewComputerObject — unified JS Computer object factory +// --------------------------------------------------------------------------- + +// NewComputerObject creates a JS Computer object. Closures capture only +// kind (string) and identifier (string) — no Go objects cross into V8. +func NewComputerObject(v8ctx *v8go.Context, kind string, identifier string) (*v8go.Value, error) { + iso := v8ctx.Isolate() + ctx := context.Background() + + // Mutable workplace binding lives in closure, not in V8 heap. + var workplaceID string + + tpl := v8go.NewObjectTemplate(iso) + + // -- Exec -- + tpl.Set("Exec", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + cmd, opts, _ := parseExecOptions(info.Context(), info.Args()) + if len(cmd) == 0 { + return throwError(info, "Exec requires cmd (string[])") + } + comp, err := getComputer(ctx, kind, identifier) + if err != nil { + return throwError(info, err.Error()) + } + result, err := comp.Exec(ctx, cmd, opts...) + if err != nil { + return throwError(info, err.Error()) + } + return execResultToJS(info.Context(), result) + })) + + // -- Stream -- + tpl.Set("Stream", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + cmd, opts, cbVal := parseExecOptions(info.Context(), info.Args()) + if len(cmd) == 0 { + return throwError(info, "Stream requires cmd (string[]) and callback") + } + if cbVal == nil || !cbVal.IsFunction() { + return throwError(info, "Stream requires a callback function as last argument") + } + cbFn, err := cbVal.AsFunction() + if err != nil { + return throwError(info, "Stream callback is not a function") + } + comp, err := getComputer(ctx, kind, identifier) + if err != nil { + return throwError(info, err.Error()) + } + stream, err := comp.Stream(ctx, cmd, opts...) + if err != nil { + return throwError(info, err.Error()) + } + + type chunk struct { + typ string + data interface{} + } + ch := make(chan chunk, 64) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + buf := make([]byte, 4096) + for { + n, err := stream.Stdout.Read(buf) + if n > 0 { + ch <- chunk{"stdout", string(buf[:n])} + } + if err != nil { + break + } + } + }() + go func() { + defer wg.Done() + buf := make([]byte, 4096) + for { + n, err := stream.Stderr.Read(buf) + if n > 0 { + ch <- chunk{"stderr", string(buf[:n])} + } + if err != nil { + break + } + } + }() + go func() { + code, _ := stream.Wait() + wg.Wait() + ch <- chunk{"exit", code} + close(ch) + }() + + v8c := info.Context() + global := v8c.Global() + for c := range ch { + var dataVal *v8go.Value + switch v := c.data.(type) { + case string: + dataVal, _ = v8go.NewValue(iso, v) + case int: + dataVal, _ = v8go.NewValue(iso, int32(v)) + } + typeVal, _ := v8go.NewValue(iso, c.typ) + if typeVal != nil && dataVal != nil { + _, _ = cbFn.Call(global, typeVal, dataVal) + } + } + return v8go.Undefined(iso) + })) + + // -- VNC -- + tpl.Set("VNC", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + comp, err := getComputer(ctx, kind, identifier) + if err != nil { + return throwError(info, err.Error()) + } + url, err := comp.VNC(ctx) + if err != nil { + return throwError(info, err.Error()) + } + val, _ := v8go.NewValue(iso, url) + return val + })) + + // -- Proxy -- + tpl.Set("Proxy", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) < 1 || !args[0].IsNumber() { + return throwError(info, "Proxy requires port (number)") + } + port := int(args[0].Int32()) + path := "/" + if len(args) > 1 && args[1].IsString() { + path = args[1].String() + } + comp, err := getComputer(ctx, kind, identifier) + if err != nil { + return throwError(info, err.Error()) + } + url, err := comp.Proxy(ctx, port, path) + if err != nil { + return throwError(info, err.Error()) + } + val, _ := v8go.NewValue(iso, url) + return val + })) + + // -- ComputerInfo -- + tpl.Set("ComputerInfo", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + comp, err := getComputer(ctx, kind, identifier) + if err != nil { + return throwError(info, err.Error()) + } + return computerInfoToJS(info.Context(), comp.ComputerInfo()) + })) + + // -- BindWorkplace -- + tpl.Set("BindWorkplace", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) < 1 || !args[0].IsString() { + return throwError(info, "BindWorkplace requires workspaceID (string)") + } + workplaceID = args[0].String() + comp, err := getComputer(ctx, kind, identifier) + if err != nil { + return throwError(info, err.Error()) + } + comp.BindWorkplace(workplaceID) + return v8go.Undefined(iso) + })) + + // -- Workplace → reuse workspace JSAPI NewFSObject -- + tpl.Set("Workplace", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + if workplaceID == "" { + return v8go.Null(iso) + } + val, err := wsjsapi.NewFSObject(info.Context(), workplaceID) + if err != nil { + return throwError(info, err.Error()) + } + return val + })) + + // -- Box-only: Info -- + tpl.Set("Info", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + if kind == "host" { + return throwError(info, "not supported: Info() requires a box computer") + } + comp, err := getComputer(ctx, kind, identifier) + if err != nil { + return throwError(info, err.Error()) + } + box := comp.(*sandbox.Box) + bi, err := box.Info(ctx) + if err != nil { + return throwError(info, err.Error()) + } + return boxInfoToJS(info.Context(), bi) + })) + + // -- Box-only: Start -- + tpl.Set("Start", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + if kind == "host" { + return throwError(info, "not supported: Start() requires a box computer") + } + comp, err := getComputer(ctx, kind, identifier) + if err != nil { + return throwError(info, err.Error()) + } + if err := comp.(*sandbox.Box).Start(ctx); err != nil { + return throwError(info, err.Error()) + } + return v8go.Undefined(iso) + })) + + // -- Box-only: Stop -- + tpl.Set("Stop", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + if kind == "host" { + return throwError(info, "not supported: Stop() requires a box computer") + } + comp, err := getComputer(ctx, kind, identifier) + if err != nil { + return throwError(info, err.Error()) + } + if err := comp.(*sandbox.Box).Stop(ctx); err != nil { + return throwError(info, err.Error()) + } + return v8go.Undefined(iso) + })) + + // -- Box-only: Remove -- + tpl.Set("Remove", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + if kind == "host" { + return throwError(info, "not supported: Remove() requires a box computer") + } + comp, err := getComputer(ctx, kind, identifier) + if err != nil { + return throwError(info, err.Error()) + } + if err := comp.(*sandbox.Box).Remove(ctx); err != nil { + return throwError(info, err.Error()) + } + return v8go.Undefined(iso) + })) + + // Instantiate and set read-only properties + obj, err := tpl.NewInstance(v8ctx) + if err != nil { + return nil, err + } + + obj.Set("kind", kind) + + idStr := "" + ownerStr := "" + poolStr := identifier + if kind == "box" { + if comp, err := getComputer(ctx, kind, identifier); err == nil { + box := comp.(*sandbox.Box) + idStr = box.ID() + ownerStr = box.Owner() + poolStr = box.Pool() + } else { + idStr = identifier + } + } + obj.Set("id", idStr) + obj.Set("owner", ownerStr) + obj.Set("pool", poolStr) + + return obj.Value, nil } diff --git a/sandbox/v2/jsapi/jsapi.go b/sandbox/v2/jsapi/jsapi.go index 13e0b1e6..e73e6e74 100644 --- a/sandbox/v2/jsapi/jsapi.go +++ b/sandbox/v2/jsapi/jsapi.go @@ -32,7 +32,12 @@ package jsapi import ( + "context" + "encoding/json" + "time" + v8 "github.com/yaoapp/gou/runtime/v8" + sandbox "github.com/yaoapp/yao/sandbox/v2" "rogchap.com/v8go" ) @@ -54,112 +59,227 @@ func ExportObject(iso *v8go.Isolate) *v8go.ObjectTemplate { return obj } -// sbCreate: `sandbox.Create(options)` → Box -// -// Go: Manager.Create(ctx, CreateOptions) (*Box, error) -// -// Manager.GetOrCreate(ctx, CreateOptions) (*Box, error) — when opts.id is set -// -// JS options → Go CreateOptions mapping: -// -// { -// id: string → CreateOptions.ID // optional; triggers GetOrCreate -// owner: string → CreateOptions.Owner // required -// pool: string → CreateOptions.Pool // default: first pool -// image: string → CreateOptions.Image // required -// workdir: string → CreateOptions.WorkDir -// user: string → CreateOptions.User // e.g. "1000:1000" -// env: object → CreateOptions.Env // map[string]string -// memory: number → CreateOptions.Memory // bytes (int64) -// cpus: number → CreateOptions.CPUs // float64 e.g. 1.5 -// vnc: boolean → CreateOptions.VNC -// ports: array → CreateOptions.Ports // [{container_port, host_port, host_ip, protocol}] → []PortMapping -// policy: string → CreateOptions.Policy // "oneshot"|"session"|"longrunning"|"persistent" -// idle_timeout: number → CreateOptions.IdleTimeout // ms → time.Duration -// stop_timeout: number → CreateOptions.StopTimeout // ms → time.Duration -// workspace_id: string → CreateOptions.WorkspaceID -// mount_mode: string → CreateOptions.MountMode // "rw"|"ro" -// mount_path: string → CreateOptions.MountPath -// labels: object → CreateOptions.Labels // map[string]string -// } -// -// Returns: Computer object (kind="box") — see computer.go +// sbCreate: `sandbox.Create(options)` → Computer (kind="box") func sbCreate(info *v8go.FunctionCallbackInfo) *v8go.Value { - // TODO: Phase 2 - // 1. Parse options from info.Args()[0] - // 2. Validate required fields (image, owner) - // 3. If opts.id != "" → sandbox.M().GetOrCreate(ctx, opts) - // else → sandbox.M().Create(ctx, opts) - // 4. Return NewComputerObject(v8ctx, "box", box.ID()) - return v8go.Undefined(info.Context().Isolate()) + v8ctx := info.Context() + ctx := context.Background() + args := info.Args() + if len(args) < 1 || !args[0].IsObject() { + return throwError(info, "Create requires options object") + } + + optsVal := args[0] + jsonStr, err := v8go.JSONStringify(v8ctx, optsVal) + if err != nil { + return throwError(info, "Create: invalid options: "+err.Error()) + } + + var raw map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { + return throwError(info, "Create: invalid options JSON: "+err.Error()) + } + + opts := sandbox.CreateOptions{} + if v, ok := raw["id"].(string); ok { + opts.ID = v + } + if v, ok := raw["owner"].(string); ok { + opts.Owner = v + } + if v, ok := raw["pool"].(string); ok { + opts.Pool = v + } + if v, ok := raw["image"].(string); ok { + opts.Image = v + } + if v, ok := raw["workdir"].(string); ok { + opts.WorkDir = v + } + if v, ok := raw["user"].(string); ok { + opts.User = v + } + if v, ok := raw["env"].(map[string]interface{}); ok { + env := make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + env[k] = s + } + } + opts.Env = env + } + if v, ok := raw["memory"].(float64); ok { + opts.Memory = int64(v) + } + if v, ok := raw["cpus"].(float64); ok { + opts.CPUs = v + } + if v, ok := raw["vnc"].(bool); ok { + opts.VNC = v + } + if v, ok := raw["policy"].(string); ok { + opts.Policy = sandbox.LifecyclePolicy(v) + } + if v, ok := raw["idle_timeout"].(float64); ok { + opts.IdleTimeout = time.Duration(v) * time.Millisecond + } + if v, ok := raw["stop_timeout"].(float64); ok { + opts.StopTimeout = time.Duration(v) * time.Millisecond + } + if v, ok := raw["workspace_id"].(string); ok { + opts.WorkspaceID = v + } + if v, ok := raw["mount_mode"].(string); ok { + opts.MountMode = v + } + if v, ok := raw["mount_path"].(string); ok { + opts.MountPath = v + } + if v, ok := raw["labels"].(map[string]interface{}); ok { + labels := make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + labels[k] = s + } + } + opts.Labels = labels + } + if v, ok := raw["ports"].([]interface{}); ok { + for _, p := range v { + pm, ok := p.(map[string]interface{}) + if !ok { + continue + } + mapping := sandbox.PortMapping{} + if cp, ok := pm["container_port"].(float64); ok { + mapping.ContainerPort = int(cp) + } + if hp, ok := pm["host_port"].(float64); ok { + mapping.HostPort = int(hp) + } + if hi, ok := pm["host_ip"].(string); ok { + mapping.HostIP = hi + } + if pr, ok := pm["protocol"].(string); ok { + mapping.Protocol = pr + } + opts.Ports = append(opts.Ports, mapping) + } + } + + m := sandbox.M() + var box *sandbox.Box + if opts.ID != "" { + box, err = m.GetOrCreate(ctx, opts) + } else { + box, err = m.Create(ctx, opts) + } + if err != nil { + return throwError(info, err.Error()) + } + + val, err := NewComputerObject(v8ctx, "box", box.ID()) + if err != nil { + return throwError(info, err.Error()) + } + return val } -// sbGet: `sandbox.Get(id)` → Box | null -// -// Go: Manager.Get(ctx, id) (*Box, error) -// -// Args: -// -// id: string — sandbox ID -// -// Returns: Computer object (kind="box") if found, null if not found +// sbGet: `sandbox.Get(id)` → Computer (kind="box") | null func sbGet(info *v8go.FunctionCallbackInfo) *v8go.Value { - // TODO: Phase 2 - // 1. id = info.Args()[0].String() - // 2. box, err := sandbox.M().Get(ctx, id) - // 3. Return NewComputerObject(v8ctx, "box", id) or null - return v8go.Undefined(info.Context().Isolate()) + iso := info.Context().Isolate() + v8ctx := info.Context() + ctx := context.Background() + args := info.Args() + if len(args) < 1 || !args[0].IsString() { + return throwError(info, "Get requires id (string)") + } + id := args[0].String() + + _, err := sandbox.M().Get(ctx, id) + if err != nil { + return v8go.Null(iso) + } + + val, err := NewComputerObject(v8ctx, "box", id) + if err != nil { + return throwError(info, err.Error()) + } + return val } // sbList: `sandbox.List(filter?)` → BoxInfo[] -// -// Go: Manager.List(ctx, ListOptions) ([]*Box, error) -// -// then Box.Info(ctx) for each → BoxInfo -// -// JS filter → Go ListOptions mapping: -// -// { -// owner: string → ListOptions.Owner // filter by owner; empty = all -// pool: string → ListOptions.Pool // filter by pool; empty = all -// labels: object → ListOptions.Labels // filter by labels -// } -// -// Returns: BoxInfo[] — each element: -// -// { -// id: string ← BoxInfo.ID -// container_id: string ← BoxInfo.ContainerID -// pool: string ← BoxInfo.Pool -// owner: string ← BoxInfo.Owner -// status: string ← BoxInfo.Status -// image: string ← BoxInfo.Image -// vnc: boolean ← BoxInfo.VNC -// policy: string ← BoxInfo.Policy -// labels: object ← BoxInfo.Labels -// created_at: string ← BoxInfo.CreatedAt (ISO 8601) -// last_active: string ← BoxInfo.LastActive (ISO 8601) -// process_count: number ← BoxInfo.ProcessCount -// } func sbList(info *v8go.FunctionCallbackInfo) *v8go.Value { - // TODO: Phase 2 - // 1. Parse optional filter from info.Args()[0] - // 2. boxes := sandbox.M().List(ctx, opts) - // 3. For each box: box.Info(ctx) → BoxInfo → JS object - // 4. Return JS array of BoxInfo objects - return v8go.Undefined(info.Context().Isolate()) + v8ctx := info.Context() + ctx := context.Background() + args := info.Args() + + opts := sandbox.ListOptions{} + if len(args) > 0 && args[0].IsObject() { + jsonStr, _ := v8go.JSONStringify(v8ctx, args[0]) + var raw map[string]interface{} + if json.Unmarshal([]byte(jsonStr), &raw) == nil { + if v, ok := raw["owner"].(string); ok { + opts.Owner = v + } + if v, ok := raw["pool"].(string); ok { + opts.Pool = v + } + if v, ok := raw["labels"].(map[string]interface{}); ok { + labels := make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + labels[k] = s + } + } + opts.Labels = labels + } + } + } + + boxes, err := sandbox.M().List(ctx, opts) + if err != nil { + return throwError(info, err.Error()) + } + + items := make([]interface{}, 0, len(boxes)) + for _, b := range boxes { + bi, err := b.Info(ctx) + if err != nil { + continue + } + items = append(items, map[string]interface{}{ + "id": bi.ID, + "container_id": bi.ContainerID, + "pool": bi.Pool, + "owner": bi.Owner, + "status": bi.Status, + "image": bi.Image, + "vnc": bi.VNC, + "policy": string(bi.Policy), + "labels": bi.Labels, + "created_at": bi.CreatedAt.Format(time.RFC3339), + "last_active": bi.LastActive.Format(time.RFC3339), + "process_count": bi.ProcessCount, + }) + } + + data, _ := json.Marshal(items) + val, _ := v8go.JSONParse(v8ctx, string(data)) + return val } // sbDelete: `sandbox.Delete(id)` → void -// -// Go: Manager.Remove(ctx, id) error -// -// Args: -// -// id: string — sandbox ID to remove func sbDelete(info *v8go.FunctionCallbackInfo) *v8go.Value { - // TODO: Phase 2 - // 1. id = info.Args()[0].String() - // 2. sandbox.M().Remove(ctx, id) - return v8go.Undefined(info.Context().Isolate()) + iso := info.Context().Isolate() + args := info.Args() + if len(args) < 1 || !args[0].IsString() { + return throwError(info, "Delete requires id (string)") + } + ctx := context.Background() + id := args[0].String() + + if err := sandbox.M().Remove(ctx, id); err != nil { + return throwError(info, err.Error()) + } + return v8go.Undefined(iso) } diff --git a/sandbox/v2/jsapi/jsapi_test.go b/sandbox/v2/jsapi/jsapi_test.go new file mode 100644 index 00000000..a135b4bf --- /dev/null +++ b/sandbox/v2/jsapi/jsapi_test.go @@ -0,0 +1,418 @@ +package jsapi_test + +import ( + "fmt" + "os" + "strings" + "testing" + "time" + + v8runtime "github.com/yaoapp/gou/runtime/v8" + "github.com/yaoapp/yao/config" + sandbox "github.com/yaoapp/yao/sandbox/v2" + "github.com/yaoapp/yao/tai" + "github.com/yaoapp/yao/tai/registry" + "github.com/yaoapp/yao/test" + + _ "github.com/yaoapp/yao/sandbox/v2/jsapi" +) + +type testMode struct { + Name string + Addr string + TaiID string // filled by setupSandbox + Options []tai.Option +} + +func testModes() []testMode { + modes := []testMode{{Name: "local", Addr: "local"}} + if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" { + modes = append(modes, testMode{Name: "remote", Addr: addr}) + } + return modes +} + +func testImage() string { + if img := os.Getenv("SANDBOX_TEST_IMAGE"); img != "" { + return img + } + return "alpine:latest" +} + +func setupSandbox(t *testing.T, m *testMode) { + t.Helper() + test.Prepare(t, config.Conf) + + reg := registry.Global() + if reg == nil { + registry.Init(nil) + } + + client, err := tai.New(m.Addr, m.Options...) + if err != nil { + t.Fatalf("tai.New: %v", err) + } + m.TaiID = client.TaiID() + + sandbox.Init() + mgr := sandbox.M() + t.Cleanup(func() { mgr.Close() }) +} + +func runJS(t *testing.T, source string) interface{} { + t.Helper() + res, err := v8runtime.Call(v8runtime.CallOptions{ + Sid: "test", + Timeout: 60 * time.Second, + }, source) + if err != nil { + t.Fatalf("JS error: %v", err) + } + return res +} + +func runJSExpectError(t *testing.T, source string) string { + t.Helper() + _, err := v8runtime.Call(v8runtime.CallOptions{ + Sid: "test", + Timeout: 30 * time.Second, + }, source) + if err == nil { + t.Fatal("expected JS error, got nil") + } + return err.Error() +} + +func skipIfNoDocker(t *testing.T) { + t.Helper() + addr := os.Getenv("SANDBOX_TEST_LOCAL_ADDR") + if addr == "" { + addr = "local" + } + _ = addr +} + +// --------------------------------------------------------------------------- +// sandbox.Create / sandbox.Get / sandbox.Delete +// --------------------------------------------------------------------------- + +func TestCreate(t *testing.T) { + skipIfNoDocker(t) + for _, m := range testModes() { + t.Run(m.Name, func(t *testing.T) { + setupSandbox(t, &m) + img := testImage() + res := runJS(t, fmt.Sprintf(`function TestCreate() { + var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" }); + if (pc.kind !== "box") throw new Error("kind=" + pc.kind); + if (!pc.id) throw new Error("no id"); + var id = pc.id; + sandbox.Delete(id); + return id; + }`, img, m.TaiID)) + if res == nil || res == "" { + t.Error("expected box id") + } + }) + } +} + +func TestGet(t *testing.T) { + skipIfNoDocker(t) + for _, m := range testModes() { + t.Run(m.Name, func(t *testing.T) { + setupSandbox(t, &m) + img := testImage() + res := runJS(t, fmt.Sprintf(`function TestGet() { + var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" }); + var id = pc.id; + var got = sandbox.Get(id); + if (!got) throw new Error("Get returned null"); + if (got.kind !== "box") throw new Error("kind=" + got.kind); + sandbox.Delete(id); + return id; + }`, img, m.TaiID)) + if res == nil || res == "" { + t.Error("expected box id") + } + }) + } +} + +func TestGetNotFound(t *testing.T) { + for _, m := range testModes() { + t.Run(m.Name, func(t *testing.T) { + setupSandbox(t, &m) + res := runJS(t, `function TestGetNotFound() { + var got = sandbox.Get("sb-nonexistent-id"); + return got === null ? "null" : "found"; + }`) + if res != "null" { + t.Errorf("expected null, got %v", res) + } + }) + } +} + +func TestDelete(t *testing.T) { + skipIfNoDocker(t) + for _, m := range testModes() { + t.Run(m.Name, func(t *testing.T) { + setupSandbox(t, &m) + img := testImage() + res := runJS(t, fmt.Sprintf(`function TestDelete() { + var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" }); + var id = pc.id; + sandbox.Delete(id); + var got = sandbox.Get(id); + return got === null ? "deleted" : "still exists"; + }`, img, m.TaiID)) + if res != "deleted" { + t.Errorf("expected deleted, got %v", res) + } + }) + } +} + +// --------------------------------------------------------------------------- +// sandbox.List +// --------------------------------------------------------------------------- + +func TestList(t *testing.T) { + skipIfNoDocker(t) + for _, m := range testModes() { + t.Run(m.Name, func(t *testing.T) { + setupSandbox(t, &m) + img := testImage() + res := runJS(t, fmt.Sprintf(`function TestList() { + var a = sandbox.Create({ image: "%s", owner: "list-user", pool: "%s" }); + var b = sandbox.Create({ image: "%s", owner: "list-user", pool: "%s" }); + var list = sandbox.List({ owner: "list-user" }); + var count = list.length; + sandbox.Delete(a.id); + sandbox.Delete(b.id); + return count; + }`, img, m.TaiID, img, m.TaiID)) + n := toInt(res) + if n < 2 { + t.Errorf("expected >= 2, got %d", n) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Computer.Exec +// --------------------------------------------------------------------------- + +func TestExec(t *testing.T) { + skipIfNoDocker(t) + for _, m := range testModes() { + t.Run(m.Name, func(t *testing.T) { + setupSandbox(t, &m) + img := testImage() + res := runJS(t, fmt.Sprintf(`function TestExec() { + var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" }); + var r = pc.Exec(["echo", "hello-jsapi"]); + sandbox.Delete(pc.id); + return r.stdout; + }`, img, m.TaiID)) + s := fmt.Sprintf("%v", res) + if !strings.Contains(s, "hello-jsapi") { + t.Errorf("stdout = %q, want contain 'hello-jsapi'", s) + } + }) + } +} + +func TestExecWithOptions(t *testing.T) { + skipIfNoDocker(t) + for _, m := range testModes() { + t.Run(m.Name, func(t *testing.T) { + setupSandbox(t, &m) + img := testImage() + res := runJS(t, fmt.Sprintf(`function TestExecWithOptions() { + var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" }); + var r = pc.Exec(["pwd"], { workdir: "/tmp" }); + sandbox.Delete(pc.id); + return r.stdout; + }`, img, m.TaiID)) + s := fmt.Sprintf("%v", res) + if !strings.Contains(s, "/tmp") { + t.Errorf("stdout = %q, want contain '/tmp'", s) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Computer.Stream +// --------------------------------------------------------------------------- + +func TestStream(t *testing.T) { + skipIfNoDocker(t) + for _, m := range testModes() { + t.Run(m.Name, func(t *testing.T) { + setupSandbox(t, &m) + img := testImage() + res := runJS(t, fmt.Sprintf(`function TestStream() { + var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" }); + var chunks = []; + var exitCode = -1; + pc.Stream(["echo", "streaming"], function(type, data) { + if (type === "stdout") chunks.push(data); + if (type === "exit") exitCode = data; + }); + sandbox.Delete(pc.id); + return chunks.join("").trim() + "|" + exitCode; + }`, img, m.TaiID)) + s := fmt.Sprintf("%v", res) + if !strings.Contains(s, "streaming|0") { + t.Errorf("result = %q, want contain 'streaming|0'", s) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Computer.ComputerInfo +// --------------------------------------------------------------------------- + +func TestComputerInfo(t *testing.T) { + skipIfNoDocker(t) + for _, m := range testModes() { + t.Run(m.Name, func(t *testing.T) { + setupSandbox(t, &m) + img := testImage() + res := runJS(t, fmt.Sprintf(`function TestComputerInfo() { + var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" }); + var info = pc.ComputerInfo(); + sandbox.Delete(pc.id); + return info.kind; + }`, img, m.TaiID)) + if res != "box" { + t.Errorf("kind = %q, want 'box'", res) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Computer.Info (box-only) +// --------------------------------------------------------------------------- + +func TestBoxInfo(t *testing.T) { + skipIfNoDocker(t) + for _, m := range testModes() { + t.Run(m.Name, func(t *testing.T) { + setupSandbox(t, &m) + img := testImage() + res := runJS(t, fmt.Sprintf(`function TestBoxInfo() { + var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" }); + var info = pc.Info(); + sandbox.Delete(pc.id); + return info.id ? "ok" : "no-id"; + }`, img, m.TaiID)) + if res != "ok" { + t.Errorf("expected ok, got %v", res) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Box-only method on host → error +// --------------------------------------------------------------------------- + +func TestHostBoxMethodsThrow(t *testing.T) { + if os.Getenv("SANDBOX_TEST_REMOTE_ADDR") == "" { + t.Skip("no remote host configured") + } + for _, m := range testModes() { + if m.Name == "local" { + continue + } + t.Run(m.Name, func(t *testing.T) { + setupSandbox(t, &m) + errMsg := runJSExpectError(t, fmt.Sprintf(`function TestHostBoxMethodsThrow() { + var host = sandbox.Host("%s"); + host.Info(); + }`, m.TaiID)) + if !strings.Contains(errMsg, "not supported") { + t.Errorf("expected 'not supported' error, got: %s", errMsg) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Computer.kind property +// --------------------------------------------------------------------------- + +func TestComputerKind(t *testing.T) { + skipIfNoDocker(t) + for _, m := range testModes() { + t.Run(m.Name, func(t *testing.T) { + setupSandbox(t, &m) + img := testImage() + res := runJS(t, fmt.Sprintf(`function TestComputerKind() { + var pc = sandbox.Create({ image: "%s", owner: "test-user", pool: "%s" }); + var k = pc.kind; + sandbox.Delete(pc.id); + return k; + }`, img, m.TaiID)) + if res != "box" { + t.Errorf("kind = %q, want 'box'", res) + } + }) + } +} + +// --------------------------------------------------------------------------- +// sandbox.Nodes (requires registry) +// --------------------------------------------------------------------------- + +func TestNodes(t *testing.T) { + test.Prepare(t, config.Conf) + registry.Init(nil) + res := runJS(t, `function TestNodes() { + var nodes = sandbox.Nodes(); + return Array.isArray(nodes) ? "array" : typeof nodes; + }`) + if res != "array" { + t.Errorf("expected array, got %v", res) + } +} + +func TestGetNodeNotFound(t *testing.T) { + test.Prepare(t, config.Conf) + registry.Init(nil) + res := runJS(t, `function TestGetNodeNotFound() { + var node = sandbox.GetNode("tai-nonexistent"); + return node === null ? "null" : "found"; + }`) + if res != "null" { + t.Errorf("expected null, got %v", res) + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func toInt(v interface{}) int { + switch n := v.(type) { + case int: + return n + case int32: + return int(n) + case int64: + return int(n) + case float64: + return int(n) + case float32: + return int(n) + default: + return 0 + } +} diff --git a/sandbox/v2/jsapi/node.go b/sandbox/v2/jsapi/node.go index 8ecc0c4d..fdeb1cfc 100644 --- a/sandbox/v2/jsapi/node.go +++ b/sandbox/v2/jsapi/node.go @@ -1,105 +1,142 @@ package jsapi import ( + "encoding/json" + "time" + + "github.com/yaoapp/yao/tai/registry" "rogchap.com/v8go" ) // sbGetNode: `sandbox.GetNode(taiID)` → NodeInfo | null -// -// Go: registry.Global().Get(taiID) (*NodeSnapshot, bool) -// -// Args: -// -// taiID: string — Tai node ID -// -// Returns: NodeInfo object if found, null if not found. -// Auth and YaoBase fields are excluded for security. func sbGetNode(info *v8go.FunctionCallbackInfo) *v8go.Value { - // TODO: Phase 2 - // 1. taiID = info.Args()[0].String() - // 2. snap, ok := registry.Global().Get(taiID) - // 3. if !ok { return v8go.Null } - // 4. Return snapshotToJS(v8ctx, snap) - return v8go.Undefined(info.Context().Isolate()) + iso := info.Context().Isolate() + args := info.Args() + if len(args) < 1 || !args[0].IsString() { + return throwError(info, "GetNode requires taiID (string)") + } + + reg := registry.Global() + if reg == nil { + return throwError(info, "registry not initialized") + } + + snap, ok := reg.Get(args[0].String()) + if !ok { + return v8go.Null(iso) + } + + val, err := snapshotToJS(info.Context(), snap) + if err != nil { + return throwError(info, err.Error()) + } + return val } // sbNodes: `sandbox.Nodes()` → NodeInfo[] -// -// Go: registry.Global().List() []NodeSnapshot -// -// Returns: array of NodeInfo objects for all registered Tai nodes. func sbNodes(info *v8go.FunctionCallbackInfo) *v8go.Value { - // TODO: Phase 2 - // 1. snaps := registry.Global().List() - // 2. Build JS array, for each: snapshotToJS(v8ctx, snap) - // 3. Return JS array - return v8go.Undefined(info.Context().Isolate()) + v8ctx := info.Context() + + reg := registry.Global() + if reg == nil { + return throwError(info, "registry not initialized") + } + + snaps := reg.List() + return snapshotsToJSArray(v8ctx, snaps) } // sbNodesByTeam: `sandbox.NodesByTeam(teamID)` → NodeInfo[] -// -// Go: registry.Global().ListByTeam(teamID) []NodeSnapshot -// -// Args: -// -// teamID: string — team ID to filter by -// -// Returns: array of NodeInfo objects belonging to the given team. func sbNodesByTeam(info *v8go.FunctionCallbackInfo) *v8go.Value { - // TODO: Phase 2 - // 1. teamID = info.Args()[0].String() - // 2. snaps := registry.Global().ListByTeam(teamID) - // 3. Build JS array, for each: snapshotToJS(v8ctx, snap) - // 4. Return JS array - return v8go.Undefined(info.Context().Isolate()) + v8ctx := info.Context() + args := info.Args() + if len(args) < 1 || !args[0].IsString() { + return throwError(info, "NodesByTeam requires teamID (string)") + } + + reg := registry.Global() + if reg == nil { + return throwError(info, "registry not initialized") + } + + snaps := reg.ListByTeam(args[0].String()) + return snapshotsToJSArray(v8ctx, snaps) } // snapshotToJS converts a NodeSnapshot to a JS NodeInfo object. -// -// Excluded: Auth (sensitive), YaoBase (internal URL). -// -// NodeInfo JS object shape: -// -// { -// tai_id: string, ← NodeSnapshot.TaiID -// machine_id: string, ← NodeSnapshot.MachineID -// version: string, ← NodeSnapshot.Version -// mode: string, ← NodeSnapshot.Mode ("direct"|"tunnel") -// addr: string, ← NodeSnapshot.Addr -// status: string, ← NodeSnapshot.Status ("online"|"offline"|"connecting") -// pool: string, ← NodeSnapshot.PoolName -// connected_at: string, ← NodeSnapshot.ConnectedAt (ISO 8601) -// last_ping: string, ← NodeSnapshot.LastPing (ISO 8601) -// ports: { ← NodeSnapshot.Ports -// grpc: number, -// http: number, -// vnc: number, -// docker: number, -// k8s: number, -// }, -// capabilities: { ← NodeSnapshot.Capabilities -// docker: boolean, -// k8s: boolean, -// host_exec: boolean, -// }, -// system: { ← NodeSnapshot.System (SystemInfo) -// os: string, -// arch: string, -// hostname: string, -// num_cpu: number, -// total_mem: number, -// } -// } -// -//nolint:unused // placeholder for Phase 2 -func snapshotToJS(v8ctx *v8go.Context, snap interface{}) (*v8go.Value, error) { - // TODO: Phase 2 implementation - // 1. Create JS object via v8go.NewObjectTemplate - // 2. Set scalar fields: tai_id, machine_id, version, mode, addr, status, pool - // 3. Set time fields: connected_at, last_ping → snap.ConnectedAt.Format(time.RFC3339) - // 4. Build ports sub-object from snap.Ports map - // 5. Build capabilities sub-object from snap.Capabilities map - // 6. Build system sub-object from snap.System (OS, Arch, Hostname, NumCPU, TotalMem) - // 7. Return the JS object - return nil, nil +// 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 + } + + caps := make(map[string]interface{}, len(snap.Capabilities)) + for k, v := range snap.Capabilities { + caps[k] = v + } + + data, err := json.Marshal(map[string]interface{}{ + "tai_id": snap.TaiID, + "machine_id": snap.MachineID, + "version": snap.Version, + "mode": snap.Mode, + "addr": snap.Addr, + "status": snap.Status, + "pool": snap.PoolName, + "connected_at": snap.ConnectedAt.Format(time.RFC3339), + "last_ping": snap.LastPing.Format(time.RFC3339), + "ports": ports, + "capabilities": caps, + "system": map[string]interface{}{ + "os": snap.System.OS, + "arch": snap.System.Arch, + "hostname": snap.System.Hostname, + "num_cpu": snap.System.NumCPU, + "total_mem": snap.System.TotalMem, + }, + }) + if err != nil { + return nil, err + } + + return v8go.JSONParse(v8ctx, string(data)) +} + +func snapshotsToJSArray(v8ctx *v8go.Context, snaps []registry.NodeSnapshot) *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 + } + caps := make(map[string]interface{}, len(snap.Capabilities)) + for k, v := range snap.Capabilities { + caps[k] = v + } + items = append(items, map[string]interface{}{ + "tai_id": snap.TaiID, + "machine_id": snap.MachineID, + "version": snap.Version, + "mode": snap.Mode, + "addr": snap.Addr, + "status": snap.Status, + "pool": snap.PoolName, + "connected_at": snap.ConnectedAt.Format(time.RFC3339), + "last_ping": snap.LastPing.Format(time.RFC3339), + "ports": ports, + "capabilities": caps, + "system": map[string]interface{}{ + "os": snap.System.OS, + "arch": snap.System.Arch, + "hostname": snap.System.Hostname, + "num_cpu": snap.System.NumCPU, + "total_mem": snap.System.TotalMem, + }, + }) + } + data, _ := json.Marshal(items) + val, _ := v8go.JSONParse(v8ctx, string(data)) + return val } diff --git a/sandbox/v2/manager.go b/sandbox/v2/manager.go index 5bd85760..d3c9cd16 100644 --- a/sandbox/v2/manager.go +++ b/sandbox/v2/manager.go @@ -7,49 +7,36 @@ import ( "time" "github.com/yaoapp/yao/tai" + "github.com/yaoapp/yao/tai/registry" taisandbox "github.com/yaoapp/yao/tai/sandbox" "github.com/yaoapp/yao/workspace" ) -// Manager manages a pool of tai.Client connections and sandbox lifecycle. +// Manager manages sandbox lifecycle. Node connections are delegated to tai/registry. type Manager struct { - pool map[string]*tai.Client - poolDefs []Pool - defaultPool string - config Config - boxes sync.Map - mu sync.Mutex - cancel context.CancelFunc - grpcPort int - wsManager *workspace.Manager + boxes sync.Map + mu sync.Mutex + cancel context.CancelFunc } -func newManager(cfg Config) (*Manager, error) { - m := &Manager{ - pool: make(map[string]*tai.Client), - poolDefs: cfg.Pool, - config: cfg, - grpcPort: 9099, - } - if len(cfg.Pool) > 0 { - m.defaultPool = cfg.Pool[0].Name - } - return m, nil +func newManager() *Manager { + return &Manager{} } -// Start discovers existing containers from all pools, rebuilds the boxes map, -// and starts the cleanup loop. +// Start discovers existing containers from all registered nodes, rebuilds +// the boxes map, and starts the cleanup loop. func (m *Manager) Start(ctx context.Context) error { - if len(m.poolDefs) == 0 { + reg := registry.Global() + if reg == nil { return nil } - for _, pd := range m.poolDefs { - client, err := m.getPool(pd.Name) + for _, snap := range reg.List() { + client, err := m.getPool(snap.TaiID) if err != nil { continue } - m.recoverBoxes(ctx, &pd, client) + m.recoverBoxes(ctx, snap.TaiID, client) } loopCtx, cancel := context.WithCancel(ctx) @@ -58,96 +45,13 @@ func (m *Manager) Start(ctx context.Context) error { return nil } -// AddPool registers a new pool at runtime. -func (m *Manager) AddPool(_ context.Context, p Pool) error { - m.mu.Lock() - defer m.mu.Unlock() - - for _, pd := range m.poolDefs { - if pd.Name == p.Name { - return fmt.Errorf("sandbox: pool %q already exists", p.Name) - } +// Pools returns the list of registered Tai nodes from the registry. +func (m *Manager) Pools() []registry.NodeSnapshot { + reg := registry.Global() + if reg == nil { + return nil } - m.poolDefs = append(m.poolDefs, p) - if m.defaultPool == "" { - m.defaultPool = p.Name - } - return nil -} - -// RemovePool removes a pool by name. -func (m *Manager) RemovePool(ctx context.Context, name string, force bool) error { - m.mu.Lock() - defer m.mu.Unlock() - - idx := -1 - for i, pd := range m.poolDefs { - if pd.Name == name { - idx = i - break - } - } - if idx < 0 { - return ErrPoolNotFound - } - - count := 0 - m.boxes.Range(func(_, value any) bool { - if value.(*Box).pool == name { - count++ - } - return true - }) - - if count > 0 && !force { - return ErrPoolInUse - } - - if count > 0 { - m.boxes.Range(func(key, value any) bool { - b := value.(*Box) - if b.pool == name { - b.Remove(ctx) - } - return true - }) - } - - m.poolDefs = append(m.poolDefs[:idx], m.poolDefs[idx+1:]...) - if client, ok := m.pool[name]; ok { - client.Close() - delete(m.pool, name) - } - return nil -} - -// Pools returns all registered pool names and their status. -func (m *Manager) Pools() []PoolInfo { - m.mu.Lock() - defer m.mu.Unlock() - - result := make([]PoolInfo, 0, len(m.poolDefs)) - for _, pd := range m.poolDefs { - _, connected := m.pool[pd.Name] - count := 0 - m.boxes.Range(func(_, value any) bool { - if value.(*Box).pool == pd.Name { - count++ - } - return true - }) - result = append(result, PoolInfo{ - Name: pd.Name, - Addr: pd.Addr, - Connected: connected, - Boxes: count, - MaxPerUser: pd.MaxPerUser, - MaxTotal: pd.MaxTotal, - IdleTimeout: pd.IdleTimeout, - MaxLifetime: pd.MaxLifetime, - }) - } - return result + return reg.List() } // Heartbeat updates the box's last heartbeat timestamp. @@ -165,17 +69,9 @@ func (m *Manager) Heartbeat(sandboxID string, active bool, processCount int) err } // Host returns a Host handle for executing commands on the Tai host machine. -// The pool must be connected to a Tai server with host_exec capability. -// Unlike Create/Box, Host does not create a container — it is available -// immediately as long as the pool is reachable. func (m *Manager) Host(_ context.Context, pool string) (*Host, error) { if pool == "" { - pool = m.defaultPool - } - - pd := m.findPoolDef(pool) - if pd == nil { - return nil, ErrPoolNotFound + return nil, ErrPoolMissing } client, err := m.getPool(pool) @@ -192,35 +88,24 @@ func (m *Manager) Host(_ context.Context, pool string) (*Host, error) { // Create creates and starts a new sandbox. func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) { - if len(m.poolDefs) == 0 { - return nil, ErrNotAvailable - } if opts.Image == "" { return nil, fmt.Errorf("sandbox: image is required") } poolName := opts.Pool - if poolName == "" { - poolName = m.defaultPool - } - // Workspace node binding: when WorkspaceID is set, resolve the workspace's - // bound node and force the container onto that pool. - if opts.WorkspaceID != "" && m.wsManager != nil { - node, err := m.wsManager.NodeForWorkspace(ctx, opts.WorkspaceID) - if err != nil { - return nil, fmt.Errorf("sandbox: resolve workspace %q: %w", opts.WorkspaceID, err) + if opts.WorkspaceID != "" { + if wsm := workspace.M(); wsm != nil { + node, err := wsm.NodeForWorkspace(ctx, opts.WorkspaceID) + if err != nil { + return nil, fmt.Errorf("sandbox: resolve workspace %q: %w", opts.WorkspaceID, err) + } + poolName = node } - poolName = node } - pd := m.findPoolDef(poolName) - if pd == nil { - return nil, ErrPoolNotFound - } - - if err := m.checkLimits(pd, opts.Owner); err != nil { - return nil, err + if poolName == "" { + return nil, ErrPoolMissing } id := opts.ID @@ -237,12 +122,7 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) return nil, fmt.Errorf("sandbox: pool %q has no container runtime", poolName) } - access, refresh, err := CreateContainerTokens(id, opts.Owner, nil) - if err != nil { - return nil, fmt.Errorf("sandbox: create tokens: %w", err) - } - - taiOpts := m.buildTaiCreateOptions(opts, pd, id, access, refresh) + taiOpts := m.buildTaiCreateOptions(opts, poolName, id) containerID, err := client.Sandbox().Create(ctx, taiOpts) if err != nil { @@ -267,9 +147,9 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) policy: policy, labels: opts.Labels, idleTimeoutD: opts.IdleTimeout, + maxLifetimeD: opts.MaxLifetime, stopTimeoutD: opts.StopTimeout, createdAt: time.Now(), - refreshToken: refresh, manager: m, vnc: opts.VNC, image: opts.Image, @@ -337,10 +217,6 @@ func (m *Manager) Remove(ctx context.Context, id string) error { client.Sandbox().Remove(ctx, b.containerID, true) } - if b.refreshToken != "" { - RevokeContainerTokens(b.refreshToken) - } - m.boxes.Delete(id) return nil } @@ -376,32 +252,14 @@ func (m *Manager) Cleanup(ctx context.Context) error { return nil } -// Close stops the cleanup loop and releases all pool connections. +// Close stops the cleanup loop. Node connections are managed by the registry. func (m *Manager) Close() error { if m.cancel != nil { m.cancel() } - m.mu.Lock() - defer m.mu.Unlock() - for name, client := range m.pool { - client.Close() - delete(m.pool, name) - } return nil } -// SetGRPCPort sets the local gRPC port for container env injection. -func (m *Manager) SetGRPCPort(port int) { - m.grpcPort = port -} - -// SetWorkspaceManager links the workspace manager for workspace-aware container creation. -// When CreateOptions.WorkspaceID is set, the sandbox Manager uses the workspace Manager -// to resolve the workspace's bound node and force container routing. -func (m *Manager) SetWorkspaceManager(wm *workspace.Manager) { - m.wsManager = wm -} - func (m *Manager) cleanupLoop(ctx context.Context) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() @@ -416,78 +274,27 @@ func (m *Manager) cleanupLoop(ctx context.Context) { } func (m *Manager) getPool(name string) (*tai.Client, error) { - m.mu.Lock() - defer m.mu.Unlock() - - if client, ok := m.pool[name]; ok { - return client, nil - } - - pd := m.findPoolDefLocked(name) - if pd == nil { + client, ok := tai.GetClient(name) + if !ok { return nil, ErrPoolNotFound } - - client, err := tai.New(pd.Addr, pd.Options...) - if err != nil { - return nil, err - } - m.pool[name] = client return client, nil } -func (m *Manager) findPoolDef(name string) *Pool { - m.mu.Lock() - defer m.mu.Unlock() - return m.findPoolDefLocked(name) -} - -func (m *Manager) findPoolDefLocked(name string) *Pool { - for i := range m.poolDefs { - if m.poolDefs[i].Name == name { - return &m.poolDefs[i] - } - } - return nil -} - -func (m *Manager) checkLimits(pd *Pool, owner string) error { - if pd.MaxTotal > 0 { - count := 0 - m.boxes.Range(func(_, value any) bool { - if value.(*Box).pool == pd.Name { - count++ - } - return true - }) - if count >= pd.MaxTotal { - return ErrLimitExceeded - } - } - - if pd.MaxPerUser > 0 && owner != "" { - count := 0 - m.boxes.Range(func(_, value any) bool { - b := value.(*Box) - if b.pool == pd.Name && b.owner == owner { - count++ - } - return true - }) - if count >= pd.MaxPerUser { - return ErrLimitExceeded - } - } - return nil -} - -func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID, access, refresh string) taisandbox.CreateOptions { +func (m *Manager) buildTaiCreateOptions(opts CreateOptions, poolName, sandboxID string) taisandbox.CreateOptions { env := make(map[string]string) - for k, v := range opts.Env { - env[k] = v + + reg := registry.Global() + if reg != nil { + if snap, ok := reg.Get(poolName); ok { + grpcEnv := BuildGRPCEnv(snap.Mode, snap.Addr, sandboxID) + for k, v := range grpcEnv { + env[k] = v + } + } } - grpcEnv := BuildGRPCEnv(pd, sandboxID, access, refresh, m.grpcPort) - for k, v := range grpcEnv { + + for k, v := range opts.Env { env[k] = v } @@ -495,7 +302,7 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID, "managed-by": "yao-sandbox", "sandbox-id": sandboxID, "sandbox-owner": opts.Owner, - "sandbox-pool": pd.Name, + "sandbox-pool": poolName, "sandbox-policy": string(opts.Policy), } if opts.WorkspaceID != "" { @@ -522,20 +329,21 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID, }) } - // Workspace bind mount var binds []string - if opts.WorkspaceID != "" && m.wsManager != nil { - mountPath := opts.MountPath - if mountPath == "" { - mountPath = "/workspace" - } - mode := opts.MountMode - if mode == "" { - mode = "rw" - } - hostPath, _ := m.wsManager.MountPath(context.Background(), opts.WorkspaceID) - if hostPath != "" { - binds = append(binds, fmt.Sprintf("%s:%s:%s", hostPath, mountPath, mode)) + if opts.WorkspaceID != "" { + if wsm := workspace.M(); wsm != nil { + mountPath := opts.MountPath + if mountPath == "" { + mountPath = "/workspace" + } + mode := opts.MountMode + if mode == "" { + mode = "rw" + } + hostPath, _ := wsm.MountPath(context.Background(), opts.WorkspaceID) + if hostPath != "" { + binds = append(binds, fmt.Sprintf("%s:%s:%s", hostPath, mountPath, mode)) + } } } @@ -555,7 +363,7 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID, } } -func (m *Manager) recoverBoxes(ctx context.Context, pd *Pool, client *tai.Client) { +func (m *Manager) recoverBoxes(ctx context.Context, poolName string, client *tai.Client) { if client.Sandbox() == nil { return } @@ -598,8 +406,6 @@ func (m *Manager) recoverBoxes(ctx context.Context, pd *Pool, client *tai.Client } // ImageExists reports whether the given image ref exists on the target pool node. -// Returns (true, nil) when the pool has no image service (e.g. K8s — kubelet -// handles image pulls transparently). func (m *Manager) ImageExists(ctx context.Context, pool, ref string) (bool, error) { client, err := m.getPool(pool) if err != nil { @@ -613,7 +419,7 @@ func (m *Manager) ImageExists(ctx context.Context, pool, ref string) (bool, erro } // PullImage pulls an image to the target pool node, returning a channel of -// real-time progress events. The channel is nil when no pull is needed (e.g. K8s mode). +// real-time progress events. func (m *Manager) PullImage(ctx context.Context, pool, ref string, opts ImagePullOptions) (<-chan taisandbox.PullProgress, error) { client, err := m.getPool(pool) if err != nil { @@ -635,8 +441,7 @@ func (m *Manager) PullImage(ctx context.Context, pool, ref string, opts ImagePul } // EnsureImage checks whether the image exists on the pool node; if not, it -// pulls the image and blocks until the pull completes. Returns the first -// error encountered during pull. For K8s pools this is a no-op. +// pulls the image and blocks until the pull completes. func (m *Manager) EnsureImage(ctx context.Context, pool, ref string, opts ImagePullOptions) error { exists, err := m.ImageExists(ctx, pool, ref) if err != nil { diff --git a/sandbox/v2/manager_lifecycle_test.go b/sandbox/v2/manager_lifecycle_test.go index 06233edf..385c2d04 100644 --- a/sandbox/v2/manager_lifecycle_test.go +++ b/sandbox/v2/manager_lifecycle_test.go @@ -12,9 +12,10 @@ func TestHeartbeatUpdates(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m) + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc) err := m.Heartbeat(box.ID(), true, 5) if err != nil { @@ -34,8 +35,9 @@ func TestHeartbeatUpdates(t *testing.T) { func TestHeartbeatUnknownBox(t *testing.T) { for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) + m := setupManagerForPool(t, &pc) err := m.Heartbeat("nonexistent", true, 1) if err != sandbox.ErrNotFound { t.Errorf("err = %v, want ErrNotFound", err) @@ -48,17 +50,18 @@ func TestIdleCleanup(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc, func(p *sandbox.Pool) { - p.IdleTimeout = 1 * time.Second - }) - ensureTestImage(t, m, pc.Name) + m := setupManagerForPool(t, &pc) + ensureTestImage(t, m, pc.TaiID) ctx := context.Background() box, err := m.Create(ctx, sandbox.CreateOptions{ - Image: testImage(), - Owner: "test-user", - Policy: sandbox.Session, + Image: testImage(), + Owner: "test-user", + Pool: pc.TaiID, + Policy: sandbox.Session, + IdleTimeout: 1 * time.Second, }) if err != nil { t.Fatalf("Create: %v", err) @@ -83,17 +86,13 @@ func TestStartRecovery(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options} - - m1 := setupManager(t, pool) - box := createTestBox(t, m1) + m1 := setupManagerForPool(t, &pc) + box := createTestBox(t, m1, pc) boxID := box.ID() - cfg := sandbox.Config{Pool: []sandbox.Pool{pool}} - if err := sandbox.Init(cfg); err != nil { - t.Fatalf("Init2: %v", err) - } + sandbox.Init() m2 := sandbox.M() defer m2.Close() @@ -119,13 +118,13 @@ func TestPersistentNotCleaned(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc, func(p *sandbox.Pool) { - p.IdleTimeout = 1 * time.Second - }) + m := setupManagerForPool(t, &pc) - box := createTestBox(t, m, func(co *sandbox.CreateOptions) { + box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) { co.Policy = sandbox.Persistent + co.IdleTimeout = 1 * time.Second }) time.Sleep(2 * time.Second) diff --git a/sandbox/v2/manager_test.go b/sandbox/v2/manager_test.go index a6e6be5f..b5863b2b 100644 --- a/sandbox/v2/manager_test.go +++ b/sandbox/v2/manager_test.go @@ -12,9 +12,10 @@ func TestCreateAndExec(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m) + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -37,9 +38,10 @@ func TestCreateWithLabels(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m, func(co *sandbox.CreateOptions) { + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) { co.Labels = map[string]string{"app": "test-app"} }) @@ -59,9 +61,10 @@ func TestGet(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m) + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc) got, err := m.Get(context.Background(), box.ID()) if err != nil { @@ -76,8 +79,9 @@ func TestGet(t *testing.T) { func TestGetNotFound(t *testing.T) { for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) + m := setupManagerForPool(t, &pc) _, err := m.Get(context.Background(), "nonexistent") if err != sandbox.ErrNotFound { t.Errorf("err = %v, want ErrNotFound", err) @@ -90,9 +94,10 @@ func TestList(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - box := createTestBox(t, m, func(co *sandbox.CreateOptions) { + m := setupManagerForPool(t, &pc) + box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) { co.Owner = "user-list" }) @@ -125,13 +130,15 @@ func TestRemove(t *testing.T) { skipIfNoDocker(t) for _, pc := range testPools() { + pc := pc t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - ensureTestImage(t, m, pc.Name) + m := setupManagerForPool(t, &pc) + ensureTestImage(t, m, pc.TaiID) ctx := context.Background() box, err := m.Create(ctx, sandbox.CreateOptions{ Image: testImage(), Owner: "test-user", + Pool: pc.TaiID, }) if err != nil { t.Fatalf("Create: %v", err) @@ -149,81 +156,26 @@ func TestRemove(t *testing.T) { } } -func TestPoolLimits_MaxTotal(t *testing.T) { - skipIfNoDocker(t) - - for _, pc := range testPools() { - t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc, func(p *sandbox.Pool) { - p.MaxTotal = 1 - }) - ensureTestImage(t, m, pc.Name) - - box1 := createTestBox(t, m) - _ = box1 - - ctx := context.Background() - _, err := m.Create(ctx, sandbox.CreateOptions{ - Image: testImage(), - Owner: "test-user", - }) - if err != sandbox.ErrLimitExceeded { - t.Errorf("second Create err = %v, want ErrLimitExceeded", err) - } - }) - } -} - -func TestAddPool(t *testing.T) { - m := setupManager(t, sandbox.Pool{ - Name: "default", - Addr: testLocalAddr(), - }) - - err := m.AddPool(context.Background(), sandbox.Pool{ - Name: "extra", - Addr: testLocalAddr(), - }) - if err != nil { - t.Fatalf("AddPool: %v", err) - } - - pools := m.Pools() - if len(pools) != 2 { - t.Fatalf("Pools() = %d, want 2", len(pools)) - } - - err = m.AddPool(context.Background(), sandbox.Pool{ - Name: "extra", - Addr: testLocalAddr(), - }) - if err == nil { - t.Error("expected error for duplicate pool name") - } -} - func TestCreateNoImage(t *testing.T) { - m := setupManager(t, sandbox.Pool{ - Name: "local", - Addr: testLocalAddr(), - }) + m, pools := setupManager(t, poolConfig{Name: "local", Addr: testLocalAddr()}) _, err := m.Create(context.Background(), sandbox.CreateOptions{ Owner: "test", + Pool: pools[0].TaiID, }) if err == nil { t.Error("expected error for missing image") } } -func TestCreateNoPools(t *testing.T) { - m := setupManager(t) +func TestCreateNoPool(t *testing.T) { + m, _ := setupManager(t, poolConfig{Name: "local", Addr: testLocalAddr()}) _, err := m.Create(context.Background(), sandbox.CreateOptions{ Image: testImage(), }) - if err != sandbox.ErrNotAvailable { - t.Errorf("err = %v, want ErrNotAvailable", err) + if err != sandbox.ErrPoolMissing { + t.Errorf("err = %v, want ErrPoolMissing", err) } } @@ -236,14 +188,10 @@ func TestMultiPool(t *testing.T) { t.Skip("need at least 2 pools (local + remote) for multi-pool test") } - var sps []sandbox.Pool - for _, pc := range pools { - sps = append(sps, sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options}) - } - m := setupManager(t, sps...) + m, registered := setupManager(t, pools...) - for _, pc := range pools { - ensureTestImage(t, m, pc.Name) + for _, pc := range registered { + ensureTestImage(t, m, pc.TaiID) } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) @@ -252,7 +200,7 @@ func TestMultiPool(t *testing.T) { localBox, err := m.Create(ctx, sandbox.CreateOptions{ Image: testImage(), Owner: "test-user", - Pool: "local", + Pool: registered[0].TaiID, }) if err != nil { t.Fatalf("Create on local: %v", err) @@ -262,7 +210,7 @@ func TestMultiPool(t *testing.T) { remoteBox, err := m.Create(ctx, sandbox.CreateOptions{ Image: testImage(), Owner: "test-user", - Pool: "remote", + Pool: registered[1].TaiID, }) if err != nil { t.Fatalf("Create on remote: %v", err) diff --git a/sandbox/v2/sandbox.go b/sandbox/v2/sandbox.go index 46be58ea..4c321bb7 100644 --- a/sandbox/v2/sandbox.go +++ b/sandbox/v2/sandbox.go @@ -3,15 +3,9 @@ package sandbox var mgr *Manager // Init initializes the global sandbox Manager. -// Config contains pool definitions. At least one Pool entry is required. -// Pass empty Pool list to disable sandbox (methods return ErrNotAvailable). -func Init(cfg Config) error { - m, err := newManager(cfg) - if err != nil { - return err - } - mgr = m - return nil +// Node discovery is handled by the tai/registry; no configuration is needed. +func Init() { + mgr = newManager() } // M returns the global Manager. Panics if Init was not called. diff --git a/sandbox/v2/sandbox_test.go b/sandbox/v2/sandbox_test.go index 7ef408e5..a4ea649d 100644 --- a/sandbox/v2/sandbox_test.go +++ b/sandbox/v2/sandbox_test.go @@ -7,14 +7,7 @@ import ( ) func TestInit(t *testing.T) { - cfg := sandbox.Config{ - Pool: []sandbox.Pool{ - {Name: "test", Addr: "local"}, - }, - } - if err := sandbox.Init(cfg); err != nil { - t.Fatalf("Init: %v", err) - } + sandbox.Init() m := sandbox.M() if m == nil { t.Fatal("M() returned nil") @@ -22,14 +15,6 @@ func TestInit(t *testing.T) { m.Close() } -func TestInitEmpty(t *testing.T) { - cfg := sandbox.Config{} - if err := sandbox.Init(cfg); err != nil { - t.Fatalf("Init with empty config: %v", err) - } - sandbox.M().Close() -} - func TestMPanicWithoutInit(t *testing.T) { sandbox.ResetForTest() defer func() { diff --git a/sandbox/v2/testutils_test.go b/sandbox/v2/testutils_test.go index 4234542f..94b120e4 100644 --- a/sandbox/v2/testutils_test.go +++ b/sandbox/v2/testutils_test.go @@ -13,8 +13,8 @@ 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" - "github.com/yaoapp/yao/tai/volume" "github.com/yaoapp/yao/workspace" ) @@ -99,16 +99,13 @@ func purgeStaleContainers() { } type poolConfig struct { - Name string + Name string // human-readable label for t.Run (e.g. "remote", "k8s") Addr string + TaiID string // actual registry key, filled after tai.New Options []tai.Option } // testPools returns all available pool configurations for multi-mode testing. -// - local: always present (direct Docker daemon) -// - remote: when SANDBOX_TEST_REMOTE_ADDR is set (Tai on host → Docker) -// - containerized: when TAI_TEST_CONTAINERIZED_HOST is set (Tai in container → Docker) -// - k8s: when TAI_TEST_K8S_HOST + TAI_TEST_KUBECONFIG are set (Tai → K8s) func testPools() []poolConfig { pools := []poolConfig{ {Name: "local", Addr: testLocalAddr()}, @@ -119,8 +116,6 @@ func testPools() []poolConfig { 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) - // No WithPorts for HTTP/VNC — Tai self-inspects its container - // and returns host-mapped ports via ServerInfo automatically. pools = append(pools, poolConfig{Name: "containerized", Addr: addr}) } if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" { @@ -164,11 +159,10 @@ func skipIfNoTai(t *testing.T) { type hostExecTarget struct { Name string Addr string // host:port (without tai:// prefix) + TaiID string // filled after registration IsWinNative bool } -// hostExecTargets returns all Tai instances that support HostExec gRPC. -// No container creation needed — these are direct gRPC connections. func hostExecTargets() []hostExecTarget { var targets []hostExecTarget if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" { @@ -195,8 +189,6 @@ func skipIfNoHostExec(t *testing.T) { } } -// linuxCmd adapts a Linux command to the equivalent Windows command for -// Windows native Tai targets. func linuxCmd(tgt hostExecTarget, cmd string, args ...string) (string, []string) { if tgt.IsWinNative { switch cmd { @@ -245,55 +237,64 @@ func envPort(key string, fallback int) int { return fallback } -func setupManager(t *testing.T, pools ...sandbox.Pool) *sandbox.Manager { +// registerPool 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 registerPool(t *testing.T, pc *poolConfig) { t.Helper() - cfg := sandbox.Config{Pool: pools} - if err := sandbox.Init(cfg); err != nil { - t.Fatalf("Init: %v", err) + + reg := registry.Global() + if reg == nil { + registry.Init(nil) } + + client, err := tai.New(pc.Addr, pc.Options...) + if err != nil { + t.Fatalf("tai.New(%s): %v", pc.Addr, err) + } + pc.TaiID = client.TaiID() + t.Cleanup(func() { client.Close() }) +} + +func setupManager(t *testing.T, pools ...poolConfig) (*sandbox.Manager, []poolConfig) { + t.Helper() + + reg := registry.Global() + if reg == nil { + registry.Init(nil) + } + _ = reg + + out := make([]poolConfig, len(pools)) + copy(out, pools) + 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() + } + + sandbox.Init() m := sandbox.M() - t.Cleanup(func() { - m.Close() - }) + t.Cleanup(func() { m.Close() }) + return m, out +} + +func setupManagerForPool(t *testing.T, pc *poolConfig) *sandbox.Manager { + t.Helper() + m, registered := setupManager(t, *pc) + *pc = registered[0] return m } -func setupManagerForPool(t *testing.T, pc poolConfig, mutators ...func(*sandbox.Pool)) *sandbox.Manager { - t.Helper() - pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options} - for _, fn := range mutators { - fn(&pool) - } - return setupManager(t, pool) -} - -// setupManagerWithWorkspace creates a sandbox Manager with a linked workspace Manager. -// Returns both managers and a helper to create workspaces on the given pool's node. -func setupManagerWithWorkspace(t *testing.T, pc poolConfig) (*sandbox.Manager, *workspace.Manager) { +// setupManagerWithWorkspace creates a sandbox Manager and returns +// the global workspace.Manager (which uses the registry for client lookups). +func setupManagerWithWorkspace(t *testing.T, pc *poolConfig) (*sandbox.Manager, *workspace.Manager) { t.Helper() sbm := setupManagerForPool(t, pc) - - var wsClient *tai.Client - var err error - if pc.Addr == "local" || pc.Addr == "" { - dataDir := t.TempDir() - vol := volume.NewLocal(dataDir) - wsClient, err = tai.New("local", tai.WithVolume(vol), tai.WithDataDir(dataDir)) - } else { - wsClient, err = tai.New(pc.Addr, pc.Options...) - } - if err != nil { - t.Fatalf("tai.New for workspace: %v", err) - } - t.Cleanup(func() { wsClient.Close() }) - - wsm := workspace.NewManager(map[string]*tai.Client{pc.Name: wsClient}) - sbm.SetWorkspaceManager(wsm) - return sbm, wsm + return sbm, workspace.M() } -// ensureTestImage guarantees testImage() is available on the given pool before -// container creation. Safe for all modes (Docker pull; K8s no-op). func ensureTestImage(t *testing.T, m *sandbox.Manager, pool string) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) @@ -303,11 +304,12 @@ func ensureTestImage(t *testing.T, m *sandbox.Manager, pool string) { } } -func createTestBox(t *testing.T, m *sandbox.Manager, opts ...func(*sandbox.CreateOptions)) *sandbox.Box { +func createTestBox(t *testing.T, m *sandbox.Manager, pc poolConfig, opts ...func(*sandbox.CreateOptions)) *sandbox.Box { t.Helper() co := sandbox.CreateOptions{ Image: testImage(), Owner: "test-user", + Pool: pc.TaiID, } for _, fn := range opts { fn(&co) @@ -317,11 +319,12 @@ func createTestBox(t *testing.T, m *sandbox.Manager, opts ...func(*sandbox.Creat if pool == "" { pools := m.Pools() if len(pools) > 0 { - pool = pools[0].Name + pool = pools[0].TaiID + co.Pool = pool } } - isK8s := pool == "k8s" + isK8s := pc.Name == "k8s" if isK8s { k8sSem <- struct{}{} } diff --git a/sandbox/v2/types.go b/sandbox/v2/types.go index 67f9a46f..94f23333 100644 --- a/sandbox/v2/types.go +++ b/sandbox/v2/types.go @@ -5,7 +5,6 @@ import ( "io" "time" - "github.com/yaoapp/yao/tai" "github.com/yaoapp/yao/tai/workspace" ) @@ -56,7 +55,7 @@ type SystemInfo struct { } // --------------------------------------------------------------------------- -// Lifecycle & Pool +// Lifecycle // --------------------------------------------------------------------------- type LifecyclePolicy string @@ -70,28 +69,6 @@ const ( const DefaultStopTimeout = 2 * time.Second -type Pool struct { - Name string - Addr string - Options []tai.Option - MaxPerUser int - MaxTotal int - IdleTimeout time.Duration - MaxLifetime time.Duration - StopTimeout time.Duration -} - -type PoolInfo struct { - Name string - Addr string - Connected bool - Boxes int - MaxPerUser int - MaxTotal int - IdleTimeout time.Duration - MaxLifetime time.Duration -} - // --------------------------------------------------------------------------- // Create / List options // --------------------------------------------------------------------------- @@ -118,6 +95,7 @@ type CreateOptions struct { Ports []PortMapping Policy LifecyclePolicy IdleTimeout time.Duration + MaxLifetime time.Duration StopTimeout time.Duration WorkspaceID string diff --git a/tai/registry/registry.go b/tai/registry/registry.go index 7386b720..ffd2cb4e 100644 --- a/tai/registry/registry.go +++ b/tai/registry/registry.go @@ -44,6 +44,8 @@ type TaiNode struct { LastPing time.Time PoolName string + client any // *tai.Client; stored as any to avoid import cycle + localListeners map[int]*tunnelListener } @@ -63,6 +65,7 @@ type NodeSnapshot struct { ConnectedAt time.Time LastPing time.Time PoolName string + client any } func (n *TaiNode) snapshot() NodeSnapshot { @@ -81,9 +84,14 @@ func (n *TaiNode) snapshot() NodeSnapshot { Ports: ports, Capabilities: caps, Status: n.Status, ConnectedAt: n.ConnectedAt, LastPing: n.LastPing, PoolName: n.PoolName, + 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 @@ -234,6 +242,16 @@ 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) { + r.mu.Lock() + defer r.mu.Unlock() + if n, ok := r.nodes[taiID]; ok { + n.client = c + } +} + // ListByTeam returns snapshots of all nodes belonging to the given team. func (r *Registry) ListByTeam(teamID string) []NodeSnapshot { r.mu.RLock() diff --git a/tai/tai.go b/tai/tai.go index 39d2a13d..0c2a1534 100644 --- a/tai/tai.go +++ b/tai/tai.go @@ -130,6 +130,7 @@ 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 @@ -209,6 +210,23 @@ func (c *Client) initLocal(cfg *config) (*Client, error) { 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(®istry.TaiNode{ + TaiID: id, + Mode: "local", + Addr: c.addr, + }) + reg.SetClient(id, c) + } return c, nil } @@ -276,10 +294,12 @@ func (c *Client) initRemote(cfg *config) (*Client, error) { } if reg := registry.Global(); reg != nil { + id := fmt.Sprintf("%s-%d", c.host, c.ports.GRPC) + c.taiID = id reg.Register(®istry.TaiNode{ - TaiID: c.host, + TaiID: id, Mode: "direct", - Addr: c.host, + Addr: fmt.Sprintf("tai://%s:%d", c.host, c.ports.GRPC), Ports: map[string]int{ "grpc": c.ports.GRPC, "http": c.ports.HTTP, @@ -288,6 +308,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) { "k8s": c.ports.K8s, }, }) + reg.SetClient(id, c) } return c, nil @@ -300,6 +321,7 @@ func (c *Client) initTunnel(cfg *config) (*Client, error) { } 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) @@ -360,6 +382,7 @@ func (c *Client) initTunnel(cfg *config) (*Client, error) { c.prx = proxy.NewTunnel(taiID, node.YaoBase) c.vc = vnc.NewTunnel(taiID, node.YaoBase) } + reg.SetClient(taiID, c) return c, nil } @@ -396,9 +419,9 @@ func (c *Client) Close() error { } } c.closeTunnelListeners() - if c.scheme == "tai" { + if c.taiID != "" { if reg := registry.Global(); reg != nil { - reg.Unregister(c.host) + reg.Unregister(c.taiID) } } if len(errs) > 0 { @@ -414,6 +437,12 @@ func (c *Client) Volume() volume.Volume { return c.vol } // 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) @@ -549,3 +578,20 @@ func (c *Client) discoverServerInfo(conn *grpc.ClientConn, cfg *config) (map[str } return caps, nil } + +// GetClient returns a registered *Client by taiID from the global registry. +func GetClient(taiID string) (*Client, bool) { + reg := registry.Global() + if reg == nil { + return nil, false + } + snap, ok := reg.Get(taiID) + if !ok { + return nil, false + } + c, ok := snap.Client().(*Client) + if !ok || c == nil { + return nil, false + } + return c, true +} diff --git a/workspace/jsapi/jsapi_test.go b/workspace/jsapi/jsapi_test.go index 07844720..63bd786f 100644 --- a/workspace/jsapi/jsapi_test.go +++ b/workspace/jsapi/jsapi_test.go @@ -9,9 +9,9 @@ import ( v8runtime "github.com/yaoapp/gou/runtime/v8" "github.com/yaoapp/yao/config" "github.com/yaoapp/yao/tai" + "github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/volume" "github.com/yaoapp/yao/test" - "github.com/yaoapp/yao/workspace" _ "github.com/yaoapp/yao/workspace/jsapi" ) @@ -32,6 +32,8 @@ func testModes() []testMode { func setupForMode(t *testing.T, m testMode) { t.Helper() test.Prepare(t, config.Conf) + registry.Init(nil) + var client *tai.Client var err error if m.Addr == "local" { @@ -45,7 +47,6 @@ func setupForMode(t *testing.T, m testMode) { t.Fatalf("tai.New(%s): %v", m.Addr, err) } t.Cleanup(func() { client.Close() }) - workspace.Init(map[string]*tai.Client{"default": client}) } func setupGlobal(t *testing.T) { @@ -88,7 +89,7 @@ func TestWSCreateAndDelete(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSCreateAndDelete() { - var ws = workspace.Create({ name: "test-proj", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "test-proj", owner: "u1", node: "local" }); var id = ws.id; workspace.Delete(id); return id; @@ -105,7 +106,7 @@ func TestWSGet(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSGet() { - var ws = workspace.Create({ name: "get-test", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "get-test", owner: "u1", node: "local" }); var got = workspace.Get(ws.id); var result = got ? got.id : "null"; workspace.Delete(ws.id); @@ -138,8 +139,8 @@ func TestWSList(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSList() { - var ws1 = workspace.Create({ name: "list-a", owner: "u1", node: "default" }); - var ws2 = workspace.Create({ name: "list-b", owner: "u1", node: "default" }); + var ws1 = workspace.Create({ name: "list-a", owner: "u1", node: "local" }); + var ws2 = workspace.Create({ name: "list-b", owner: "u1", node: "local" }); var list = workspace.List({ owner: "u1" }); var count = list.length; workspace.Delete(ws1.id); @@ -158,7 +159,7 @@ func TestWSReadWriteFile(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSReadWriteFile() { - var ws = workspace.Create({ name: "rw-test", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "rw-test", owner: "u1", node: "local" }); ws.WriteFile("hello.txt", "Hello, World!"); var content = ws.ReadFile("hello.txt"); workspace.Delete(ws.id); @@ -176,7 +177,7 @@ func TestWSReadDir(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSReadDir() { - var ws = workspace.Create({ name: "readdir", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "readdir", owner: "u1", node: "local" }); ws.WriteFile("a.txt", "aaa"); ws.MkdirAll("sub"); ws.WriteFile("sub/b.txt", "bbb"); @@ -196,7 +197,7 @@ func TestWSReadDirRecursive(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSReadDirRecursive() { - var ws = workspace.Create({ name: "readdir-r", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "readdir-r", owner: "u1", node: "local" }); ws.WriteFile("a.txt", "aaa"); ws.MkdirAll("sub/deep"); ws.WriteFile("sub/b.txt", "bbb"); @@ -217,7 +218,7 @@ func TestWSStat(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSStat() { - var ws = workspace.Create({ name: "stat-test", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "stat-test", owner: "u1", node: "local" }); ws.WriteFile("file.txt", "12345"); var info = ws.Stat("file.txt"); workspace.Delete(ws.id); @@ -235,7 +236,7 @@ func TestWSExistsIsDirIsFile(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSExistsIsDirIsFile() { - var ws = workspace.Create({ name: "checks", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "checks", owner: "u1", node: "local" }); ws.WriteFile("f.txt", "data"); ws.MkdirAll("d"); var r = [ @@ -261,7 +262,7 @@ func TestWSRemoveAndRename(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSRemoveAndRename() { - var ws = workspace.Create({ name: "ops", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "ops", owner: "u1", node: "local" }); ws.WriteFile("del.txt", "x"); ws.Remove("del.txt"); var a = ws.Exists("del.txt"); @@ -291,7 +292,7 @@ func TestWSBase64(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSBase64() { - var ws = workspace.Create({ name: "b64", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "b64", owner: "u1", node: "local" }); ws.WriteFile("src.txt", "base64 test"); var b64 = ws.ReadFileBase64("src.txt"); ws.WriteFileBase64("dst.txt", b64); @@ -311,7 +312,7 @@ func TestWSCopyInternal(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSCopyInternal() { - var ws = workspace.Create({ name: "copy", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "copy", owner: "u1", node: "local" }); ws.WriteFile("src.txt", "copy me"); ws.Copy("src.txt", "dst.txt"); var content = ws.ReadFile("dst.txt"); @@ -336,7 +337,7 @@ func TestWSCopyLocalToLocal(t *testing.T) { dstRel := dstDir[len(os.TempDir()):] runJS(t, `function TestWSCopyLocalToLocal() { - var ws = workspace.Create({ name: "l2l", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "l2l", owner: "u1", node: "local" }); ws.Copy("tmp://`+srcRel+`", "tmp://`+dstRel+`"); workspace.Delete(ws.id); return "ok"; @@ -356,7 +357,7 @@ func TestWSZipUnzip(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSZipUnzip() { - var ws = workspace.Create({ name: "zip", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "zip", owner: "u1", node: "local" }); ws.MkdirAll("src"); ws.WriteFile("src/a.txt", "zip content"); ws.WriteFile("src/b.txt", "more"); @@ -378,7 +379,7 @@ func TestWSGzipGunzip(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSGzipGunzip() { - var ws = workspace.Create({ name: "gzip", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "gzip", owner: "u1", node: "local" }); ws.WriteFile("data.txt", "gzip test"); ws.Gzip("data.txt", "data.txt.gz"); ws.Gunzip("data.txt.gz", "restored.txt"); @@ -398,7 +399,7 @@ func TestWSTarUntar(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSTarUntar() { - var ws = workspace.Create({ name: "tar", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "tar", owner: "u1", node: "local" }); ws.MkdirAll("src"); ws.WriteFile("src/a.txt", "tar a"); ws.Tar("src", "out.tar"); @@ -419,7 +420,7 @@ func TestWSTgzUntgz(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSTgzUntgz() { - var ws = workspace.Create({ name: "tgz", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "tgz", owner: "u1", node: "local" }); ws.MkdirAll("src"); ws.WriteFile("src/x.txt", "tgz x"); ws.Tgz("src", "out.tgz"); @@ -440,7 +441,7 @@ func TestWSZipExcludes(t *testing.T) { t.Run(m.Name, func(t *testing.T) { setupForMode(t, m) res := runJS(t, `function TestWSZipExcludes() { - var ws = workspace.Create({ name: "zip-exc", owner: "u1", node: "default" }); + var ws = workspace.Create({ name: "zip-exc", owner: "u1", node: "local" }); ws.MkdirAll("src"); ws.WriteFile("src/keep.txt", "keep"); ws.WriteFile("src/skip.log", "skip"); diff --git a/workspace/manager.go b/workspace/manager.go index 8d3b2082..da56a902 100644 --- a/workspace/manager.go +++ b/workspace/manager.go @@ -4,42 +4,28 @@ import ( "context" "fmt" "os" - "sync" "time" "github.com/yaoapp/yao/tai" + "github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/volume" taiworkspace "github.com/yaoapp/yao/tai/workspace" ) -var mgr *Manager +var mgr = NewManager() -// Init initializes the global workspace Manager with the given pools. -func Init(pools map[string]*tai.Client) { - mgr = NewManager(pools) -} - -// M returns the global Manager. Panics if Init was not called. +// M returns the global Manager. func M() *Manager { - if mgr == nil { - panic("workspace.Init not called") - } return mgr } // Manager owns workspace CRUD, file I/O, and node management. -// Pools are shared with sandbox.Manager — both reference the same tai.Client instances. -type Manager struct { - pools map[string]*tai.Client - mu sync.RWMutex -} +// All node/client lookups go through tai.GetClient → registry. +type Manager struct{} -// NewManager creates a workspace manager with the given pools. -func NewManager(pools map[string]*tai.Client) *Manager { - if pools == nil { - pools = make(map[string]*tai.Client) - } - return &Manager{pools: pools} +// NewManager creates a workspace manager. +func NewManager() *Manager { + return &Manager{} } // Create allocates storage on the target node and persists metadata. @@ -48,9 +34,9 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, e return nil, ErrNodeMissing } - client, err := m.getClient(opts.Node) - if err != nil { - return nil, err + client, ok := tai.GetClient(opts.Node) + if !ok { + return nil, ErrNodeOffline } id := opts.ID @@ -87,18 +73,19 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, e } // Get returns a workspace by ID. -// If the node is unknown, scans all pools. +// Scans all registered nodes. func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - for nodeName, client := range m.pools { - ws, err := m.readMeta(ctx, client, id) + for _, snap := range listNodes() { + client, ok := tai.GetClient(snap.TaiID) + if !ok { + continue + } + ws, err := readMeta(ctx, client, id) if err != nil { continue } if ws.Node == "" { - ws.Node = nodeName + ws.Node = snap.TaiID } return ws, nil } @@ -107,12 +94,13 @@ func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error) { // List returns workspaces, optionally filtered by owner and/or node. func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, error) { - m.mu.RLock() - defer m.mu.RUnlock() - var result []*Workspace - for nodeName, client := range m.pools { - if opts.Node != "" && nodeName != opts.Node { + for _, snap := range listNodes() { + if opts.Node != "" && snap.TaiID != opts.Node { + continue + } + client, ok := tai.GetClient(snap.TaiID) + if !ok { continue } entries, err := client.Volume().ListDir(ctx, "", ".") @@ -123,12 +111,12 @@ func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, err if !e.IsDir { continue } - ws, err := m.readMeta(ctx, client, e.Path) + ws, err := readMeta(ctx, client, e.Path) if err != nil { continue } if ws.Node == "" { - ws.Node = nodeName + ws.Node = snap.TaiID } if opts.Owner != "" && ws.Owner != opts.Owner { continue @@ -179,28 +167,25 @@ func (m *Manager) Delete(ctx context.Context, id string, force bool) error { return nil } -// Nodes returns all configured Tai nodes with their online status. +// Nodes returns all registered Tai nodes with their online status. func (m *Manager) Nodes() []NodeInfo { - m.mu.RLock() - defer m.mu.RUnlock() - - nodes := make([]NodeInfo, 0, len(m.pools)) - for name := range m.pools { - nodes = append(nodes, NodeInfo{ - Name: name, - Online: true, + nodes := listNodes() + result := make([]NodeInfo, 0, len(nodes)) + for _, snap := range nodes { + result = append(result, NodeInfo{ + Name: snap.TaiID, + Online: snap.Status == "online" || snap.Status == "", }) } - return nodes + return result } // FS returns an fs.FS-compatible filesystem for the given workspace. func (m *Manager) FS(ctx context.Context, id string) (taiworkspace.FS, error) { - ws, client, err := m.resolve(ctx, id) + _, client, err := m.resolve(ctx, id) if err != nil { return nil, err } - _ = ws return client.Workspace(id), nil } @@ -280,20 +265,6 @@ func (m *Manager) Volume(ctx context.Context, id string) (volume.Volume, string, return client.Volume(), id, nil } -// AddPool registers a new Tai node. -func (m *Manager) AddPool(name string, client *tai.Client) { - m.mu.Lock() - defer m.mu.Unlock() - m.pools[name] = client -} - -// RemovePool unregisters a Tai node. -func (m *Manager) RemovePool(name string) { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.pools, name) -} - // 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) { @@ -306,7 +277,6 @@ func (m *Manager) NodeForWorkspace(ctx context.Context, id string) (string, erro // MountPath returns the host-side directory path for a workspace, // suitable for use as a Docker bind mount source. -// For local volumes this is dataDir/{id}; for remote (Tai) the server handles mounts. func (m *Manager) MountPath(ctx context.Context, id string) (string, error) { _, client, err := m.resolve(ctx, id) if err != nil { @@ -321,23 +291,14 @@ func (m *Manager) MountPath(ctx context.Context, id string) (string, error) { // --- internal --- -func (m *Manager) getClient(node string) (*tai.Client, error) { - m.mu.RLock() - defer m.mu.RUnlock() - client, ok := m.pools[node] - if !ok { - return nil, ErrNodeOffline - } - return client, nil -} - -// resolve finds the workspace and its tai.Client by scanning pools. +// 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) { - m.mu.RLock() - defer m.mu.RUnlock() - - for _, client := range m.pools { - ws, err := m.readMeta(ctx, client, id) + for _, snap := range listNodes() { + client, ok := tai.GetClient(snap.TaiID) + if !ok { + continue + } + ws, err := readMeta(ctx, client, id) if err != nil { continue } @@ -346,7 +307,7 @@ func (m *Manager) resolve(ctx context.Context, id string) (*Workspace, *tai.Clie return nil, nil, ErrNotFound } -func (m *Manager) readMeta(ctx context.Context, client *tai.Client, id string) (*Workspace, error) { +func readMeta(ctx context.Context, client *tai.Client, id string) (*Workspace, error) { data, _, err := client.Volume().ReadFile(ctx, id, metadataFile) if err != nil { return nil, err @@ -354,6 +315,14 @@ func (m *Manager) readMeta(ctx context.Context, client *tai.Client, id string) ( return unmarshalMeta(data) } +func listNodes() []registry.NodeSnapshot { + reg := registry.Global() + if reg == nil { + return nil + } + return reg.List() +} + // DirEntry represents a file or directory entry in a workspace listing. type DirEntry struct { Name string `json:"name"` diff --git a/workspace/testutils_test.go b/workspace/testutils_test.go index 99b37a4f..591d3d44 100644 --- a/workspace/testutils_test.go +++ b/workspace/testutils_test.go @@ -2,11 +2,14 @@ package workspace_test import ( "context" + "net/url" "os" + "strings" "testing" "time" "github.com/yaoapp/yao/tai" + "github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/volume" "github.com/yaoapp/yao/workspace" ) @@ -21,19 +24,47 @@ func testPools() []poolConfig { {Name: "local", Addr: "local"}, } if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" { - pools = append(pools, poolConfig{Name: "remote", Addr: addr}) + name := taiIDFromAddr(addr) + pools = append(pools, poolConfig{Name: name, Addr: addr}) } return pools } -func setupManagerForPool(tb testing.TB, pc poolConfig) *workspace.Manager { - tb.Helper() - client := clientForPool(tb, pc) - pools := map[string]*tai.Client{pc.Name: client} - return workspace.NewManager(pools) +func taiIDFromAddr(addr string) string { + addr = strings.TrimSpace(addr) + if addr == "local" || addr == "" { + return "local" + } + if !strings.Contains(addr, "://") { + addr = "tai://" + addr + } + u, err := url.Parse(addr) + if err != nil { + return addr + } + h := u.Hostname() + if h == "" { + return addr + } + if p := u.Port(); p != "" { + return h + "-" + p + } + return h } -func clientForPool(tb testing.TB, pc poolConfig) *tai.Client { +func ensureRegistry(tb testing.TB) { + tb.Helper() + registry.Init(nil) +} + +func setupManagerForPool(tb testing.TB, pc poolConfig) *workspace.Manager { + tb.Helper() + ensureRegistry(tb) + registerClient(tb, pc) + return workspace.NewManager() +} + +func registerClient(tb testing.TB, pc poolConfig) *tai.Client { tb.Helper() if pc.Addr == "local" { return localClient(tb, tb.TempDir()) @@ -57,13 +88,25 @@ func localClient(tb testing.TB, dataDir string) *tai.Client { return client } -func setupManagerMultiNode(t *testing.T) *workspace.Manager { +func setupManagerMultiNode(t *testing.T) (*workspace.Manager, string, string) { t.Helper() - pools := map[string]*tai.Client{ - "node-a": localClient(t, t.TempDir()), - "node-b": localClient(t, t.TempDir()), + ensureRegistry(t) + + dir1 := t.TempDir() + vol1 := volume.NewLocal(dir1) + _, err := tai.New("docker://node-a", tai.WithVolume(vol1), tai.WithDataDir(dir1)) + if err != nil { + t.Fatalf("tai.New node-a: %v", err) } - return workspace.NewManager(pools) + + dir2 := t.TempDir() + vol2 := volume.NewLocal(dir2) + _, err = tai.New("docker://node-b", tai.WithVolume(vol2), tai.WithDataDir(dir2)) + if err != nil { + t.Fatalf("tai.New node-b: %v", err) + } + + return workspace.NewManager(), "docker://node-a", "docker://node-b" } func createWorkspace(tb testing.TB, m *workspace.Manager, node string, opts ...func(*workspace.CreateOptions)) *workspace.Workspace { diff --git a/workspace/workspace_test.go b/workspace/workspace_test.go index f5949cfd..f09e4451 100644 --- a/workspace/workspace_test.go +++ b/workspace/workspace_test.go @@ -7,8 +7,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/yaoapp/yao/tai" - "github.com/yaoapp/yao/tai/volume" "github.com/yaoapp/yao/workspace" ) @@ -157,18 +155,18 @@ func TestList_FilterOwner(t *testing.T) { } func TestList_FilterNode(t *testing.T) { - m := setupManagerMultiNode(t) + m, nodeA, nodeB := setupManagerMultiNode(t) ctx := context.Background() - _, err := m.Create(ctx, workspace.CreateOptions{Name: "a", Owner: "u", Node: "node-a"}) + _, err := m.Create(ctx, workspace.CreateOptions{Name: "a", Owner: "u", Node: nodeA}) require.NoError(t, err) - _, err = m.Create(ctx, workspace.CreateOptions{Name: "b", Owner: "u", Node: "node-b"}) + _, err = m.Create(ctx, workspace.CreateOptions{Name: "b", Owner: "u", Node: nodeB}) require.NoError(t, err) - list, err := m.List(ctx, workspace.ListOptions{Node: "node-a"}) + list, err := m.List(ctx, workspace.ListOptions{Node: nodeA}) require.NoError(t, err) assert.Len(t, list, 1) - assert.Equal(t, "node-a", list[0].Node) + assert.Equal(t, nodeA, list[0].Node) } func TestUpdate_Name(t *testing.T) { @@ -246,17 +244,16 @@ func TestDelete_NotFound(t *testing.T) { } func TestNodes(t *testing.T) { - m := setupManagerMultiNode(t) + m, nodeA, nodeB := setupManagerMultiNode(t) nodes := m.Nodes() - assert.Len(t, nodes, 2) + assert.GreaterOrEqual(t, len(nodes), 2) names := make(map[string]bool) for _, n := range nodes { names[n.Name] = true - assert.True(t, n.Online) } - assert.True(t, names["node-a"]) - assert.True(t, names["node-b"]) + assert.True(t, names[nodeA]) + assert.True(t, names[nodeB]) } func TestNodeForWorkspace(t *testing.T) { @@ -282,29 +279,17 @@ func TestNodeForWorkspace_NotFound(t *testing.T) { } } -func TestAddPool(t *testing.T) { - for _, pc := range testPools() { - t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForPool(t, pc) - assert.Len(t, m.Nodes(), 1) +func TestRegistryDrivenNodes(t *testing.T) { + m, nodeA, nodeB := setupManagerMultiNode(t) + nodes := m.Nodes() + assert.GreaterOrEqual(t, len(nodes), 2) - vol := volume.NewLocal(t.TempDir()) - client, err := tai.New("local", tai.WithVolume(vol)) - require.NoError(t, err) - defer client.Close() - - m.AddPool("new-node", client) - assert.Len(t, m.Nodes(), 2) - }) + names := make(map[string]bool) + for _, n := range nodes { + names[n.Name] = true } -} - -func TestRemovePool(t *testing.T) { - m := setupManagerMultiNode(t) - assert.Len(t, m.Nodes(), 2) - - m.RemovePool("node-b") - assert.Len(t, m.Nodes(), 1) + assert.True(t, names[nodeA]) + assert.True(t, names[nodeB]) } func TestMountPath(t *testing.T) {