feat(sandbox/v2): refactor benchmarks and tests to use TaiID

- Updated benchmark functions to utilize TaiID instead of pool names for improved consistency and accuracy in tests.
- Refactored test cases across various files to ensure compatibility with the new TaiID structure.
- Enhanced setup functions to accept pointers to poolConfig for better memory management.
- Removed deprecated config struct and adjusted related documentation to reflect the changes in the sandbox architecture.

Made-with: Cursor
This commit is contained in:
Max 2026-03-09 02:50:28 +08:00
parent 43fd532357
commit ce0a97c0af
32 changed files with 2007 additions and 1394 deletions

View file

@ -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. 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 ```go
func CreateContainerTokens(sandboxID, owner string, scopes []string) (access, refresh string, err error) func BuildGRPCEnv(pool *Pool, sandboxID string, grpcPort int) map[string]string
func RevokeContainerTokens(refresh string) error
func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh 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=<sandbox_id> YAO_SANDBOX_ID=<sandbox_id>
YAO_GRPC_ADDR=127.0.0.1:9099 # local / tunnel mode
YAO_GRPC_ADDR=<tai-host>:19100 # remote mode (tai://)
# Set by caller via CreateOptions.Env (OAuth is caller's responsibility)
YAO_TOKEN=<access_token> YAO_TOKEN=<access_token>
YAO_REFRESH_TOKEN=<refresh_token> YAO_REFRESH_TOKEN=<refresh_token>
YAO_GRPC_ADDR=127.0.0.1:9099
# Remote mode (tai://)
YAO_GRPC_ADDR=<tai-host>:19100
``` ```
`CreateOptions.Env` is merged **after** `BuildGRPCEnv`, so the caller can override any variable including `YAO_GRPC_ADDR`.
## Errors ## Errors
```go ```go

View file

@ -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 | | `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 | | `config.go` | Config struct | DONE |
| `errors.go` | ErrNotAvailable, ErrNotFound, ErrLimitExceeded, ErrPoolNotFound, ErrPoolInUse | 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 ### 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 | ### Unified Computer Interface — DONE
|------|---------|--------|
| `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()` |
### 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 ```javascript
// Sandbox // Unified Computer — same API for box and host
var box = sandbox.Create({ const pc = sandbox.Create({ image: "node:20", owner: "user-123" })
image: "yaoapp/workspace:latest", pc.Exec(["node", "-e", "console.log('hello')"])
owner: "user-123", pc.Stream(["npm", "run", "dev"], function(type, data) {
workspace_id: "my-workspace"
})
box.Exec(["go", "build", "./..."])
box.Stream(["npm", "run", "dev"], function(type, data) {
if (type === "stdout") console.log(data) if (type === "stdout") console.log(data)
if (type === "exit") console.log("exited:", data) if (type === "exit") console.log("exited:", data)
}) })
var url = box.Attach(3000, { protocol: "ws", path: "/ws" }) pc.VNC() // → "ws://host:port/vnc/{id}/ws"
box.Info() pc.Proxy(3000, "/api") // → "http://host:port/{id}:3000/api"
box.Stop() pc.ComputerInfo() // → { kind, pool, system, ... }
box.Start() pc.BindWorkplace("ws-abc")
box.Remove() pc.Workplace().ReadFile("main.go")
pc.Info() // box-only
pc.Remove() // box-only
// Box workspace file I/O // Host — same interface, no container
var ws = box.Workspace() const host = sandbox.Host("gpu")
ws.ReadFile("src/main.go") host.Exec(["nvidia-smi"])
ws.WriteFile("src/main.go", "package main\n...") host.VNC() // → "ws://host:port/vnc/__host__/ws"
ws.ReadDir("src/") host.Proxy(8080) // → "http://host:port/__host__:8080/"
ws.Remove("tmp.txt") host.kind // "host"
host.Info() // throws: "not supported: Info() requires a box computer"
// 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")
// Nodes (registry read-only query) // Nodes (registry read-only query)
var nodes = sandbox.Nodes() const nodes = sandbox.Nodes()
nodes.forEach(function(n) { console.log(n.tai_id, n.status, n.system.hostname) }) const node = sandbox.GetNode("tai-abc123")
const team = sandbox.NodesByTeam("team-001")
var node = sandbox.GetNode("tai-abc123")
if (node) { console.log(node.pool, node.ports.grpc, node.capabilities) }
var teamNodes = 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 ## Phase 3: Agent Integration — PENDING
@ -223,9 +265,10 @@ Every test iterates over all available pools:
```go ```go
func TestSomething(t *testing.T) { func TestSomething(t *testing.T) {
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
// test logic // test logic — use pc.TaiID as pool identifier
}) })
} }
} }
@ -247,18 +290,20 @@ K8s-specific behavior:
## File Inventory ## File Inventory
### sandbox/v2 (7 source + 10 test = 17 files) ### sandbox/v2 (9 source + 10 test = 19 files)
| File | Lines | Purpose | | File | Lines | Purpose |
|------|-------|---------| |------|-------|---------|
| `sandbox.go` | ~25 | Global singleton | | `sandbox.go` | ~25 | Global singleton |
| `manager.go` | ~620 | Manager implementation | | `manager.go` | ~620 | Manager implementation |
| `box.go` | ~230 | Box implementation | | `box.go` | ~317 | Box implementation (Computer interface) |
| `types.go` | ~170 | Type definitions | | `host.go` | ~232 | Host implementation (Computer interface) |
| `types.go` | ~247 | Type definitions (Computer, ComputerInfo, ExecOption, etc.) |
| `config.go` | ~5 | Config struct | | `config.go` | ~5 | Config struct |
| `errors.go` | ~10 | Error definitions | | `errors.go` | ~10 | Error definitions |
| `grpc.go` | ~55 | Token/env injection | | `grpc.go` | ~50 | BuildGRPCEnv (sandbox ID + addr) |
| `testutils_test.go` | ~130 | Test helpers | | `export_test.go` | ~6 | ResetForTest |
| `testutils_test.go` | ~364 | Test helpers (multi-pool, host exec targets) |
| `sandbox_test.go` | ~30 | Singleton tests | | `sandbox_test.go` | ~30 | Singleton tests |
| `manager_test.go` | ~250 | CRUD tests | | `manager_test.go` | ~250 | CRUD tests |
| `manager_lifecycle_test.go` | ~120 | Lifecycle tests | | `manager_lifecycle_test.go` | ~120 | Lifecycle tests |
@ -266,9 +311,19 @@ K8s-specific behavior:
| `box_attach_test.go` | ~260 | Attach/VNC tests | | `box_attach_test.go` | ~260 | Attach/VNC tests |
| `box_workspace_test.go` | ~285 | Workspace tests | | `box_workspace_test.go` | ~285 | Workspace tests |
| `box_image_test.go` | ~120 | Image 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 | | `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) ### workspace (3 source + 4 test = 7 files)
| File | Lines | Purpose | | File | Lines | Purpose |
@ -280,3 +335,12 @@ K8s-specific behavior:
| `workspace_test.go` | ~325 | CRUD tests | | `workspace_test.go` | ~325 | CRUD tests |
| `fileio_test.go` | ~235 | File I/O tests | | `fileio_test.go` | ~235 | File I/O tests |
| `bench_test.go` | ~150 | Benchmarks | | `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 |

View file

@ -7,14 +7,17 @@ import (
"time" "time"
sandbox "github.com/yaoapp/yao/sandbox/v2" sandbox "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
) )
// BenchmarkContainerLifecycle measures the full Create → Exec → Remove cycle. // BenchmarkContainerLifecycle measures the full Create → Exec → Remove cycle.
func BenchmarkContainerLifecycle(b *testing.B) { func BenchmarkContainerLifecycle(b *testing.B) {
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) { b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc) m := setupManagerForBench(b, &pc)
ensureTestImageBench(b, m, pc.Name) ensureTestImageBench(b, m, pc.TaiID)
b.ResetTimer() b.ResetTimer()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
@ -42,9 +45,10 @@ func BenchmarkContainerLifecycle(b *testing.B) {
// BenchmarkCreate measures container creation time only. // BenchmarkCreate measures container creation time only.
func BenchmarkCreate(b *testing.B) { func BenchmarkCreate(b *testing.B) {
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) { b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc) m := setupManagerForBench(b, &pc)
ensureTestImageBench(b, m, pc.Name) ensureTestImageBench(b, m, pc.TaiID)
ids := make([]string, 0, b.N) ids := make([]string, 0, b.N)
b.ResetTimer() b.ResetTimer()
@ -70,8 +74,9 @@ func BenchmarkCreate(b *testing.B) {
// BenchmarkExec measures command execution latency on a pre-created container. // BenchmarkExec measures command execution latency on a pre-created container.
func BenchmarkExec(b *testing.B) { func BenchmarkExec(b *testing.B) {
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) { b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc) m := setupManagerForBench(b, &pc)
box := createBoxForBench(b, m) box := createBoxForBench(b, m)
b.ResetTimer() b.ResetTimer()
@ -91,8 +96,9 @@ func BenchmarkExec(b *testing.B) {
// BenchmarkExecHeavy measures execution of a heavier command (write + read file). // BenchmarkExecHeavy measures execution of a heavier command (write + read file).
func BenchmarkExecHeavy(b *testing.B) { func BenchmarkExecHeavy(b *testing.B) {
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) { b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc) m := setupManagerForBench(b, &pc)
box := createBoxForBench(b, m) box := createBoxForBench(b, m)
b.ResetTimer() b.ResetTimer()
@ -113,9 +119,10 @@ func BenchmarkExecHeavy(b *testing.B) {
// BenchmarkRemove measures container removal time. // BenchmarkRemove measures container removal time.
func BenchmarkRemove(b *testing.B) { func BenchmarkRemove(b *testing.B) {
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) { b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc) m := setupManagerForBench(b, &pc)
ensureTestImageBench(b, m, pc.Name) ensureTestImageBench(b, m, pc.TaiID)
boxes := make([]*sandbox.Box, b.N) boxes := make([]*sandbox.Box, b.N)
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
@ -142,8 +149,9 @@ func BenchmarkRemove(b *testing.B) {
// BenchmarkInfo measures Info() latency on a running container. // BenchmarkInfo measures Info() latency on a running container.
func BenchmarkInfo(b *testing.B) { func BenchmarkInfo(b *testing.B) {
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) { b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc) m := setupManagerForBench(b, &pc)
box := createBoxForBench(b, m) box := createBoxForBench(b, m)
b.ResetTimer() b.ResetTimer()
@ -160,11 +168,12 @@ func BenchmarkInfo(b *testing.B) {
// BenchmarkStopStart measures Stop → Start cycle time. // BenchmarkStopStart measures Stop → Start cycle time.
func BenchmarkStopStart(b *testing.B) { func BenchmarkStopStart(b *testing.B) {
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) { b.Run(pc.Name, func(b *testing.B) {
if pc.Name == "k8s" { if pc.Name == "k8s" {
b.Skip("K8s Stop deletes Pod; Stop→Start cycle not applicable") b.Skip("K8s Stop deletes Pod; Stop→Start cycle not applicable")
} }
m := setupManagerForBench(b, pc) m := setupManagerForBench(b, &pc)
box := createBoxForBench(b, m) box := createBoxForBench(b, m)
b.ResetTimer() b.ResetTimer()
@ -183,8 +192,9 @@ func BenchmarkStopStart(b *testing.B) {
// BenchmarkWorkspaceReadWrite measures workspace file read/write via container Box. // BenchmarkWorkspaceReadWrite measures workspace file read/write via container Box.
func BenchmarkWorkspaceReadWrite(b *testing.B) { func BenchmarkWorkspaceReadWrite(b *testing.B) {
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
b.Run(pc.Name, func(b *testing.B) { b.Run(pc.Name, func(b *testing.B) {
m := setupManagerForBench(b, pc) m := setupManagerForBench(b, &pc)
box := createBoxForBench(b, m) box := createBoxForBench(b, m)
ws := box.Workspace() ws := box.Workspace()
if ws == nil { if ws == nil {
@ -213,13 +223,18 @@ func BenchmarkWorkspaceReadWrite(b *testing.B) {
// --- helpers --- // --- helpers ---
func setupManagerForBench(b *testing.B, pc poolConfig) *sandbox.Manager { func setupManagerForBench(b *testing.B, pc *poolConfig) *sandbox.Manager {
b.Helper() b.Helper()
pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options} reg := registry.Global()
cfg := sandbox.Config{Pool: []sandbox.Pool{pool}} if reg == nil {
if err := sandbox.Init(cfg); err != nil { registry.Init(nil)
b.Fatalf("Init: %v", err)
} }
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() m := sandbox.M()
b.Cleanup(func() { m.Close() }) b.Cleanup(func() { m.Close() })
return m 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 { func createBoxForBench(b *testing.B, m *sandbox.Manager) *sandbox.Box {
b.Helper() b.Helper()
pools := m.Pools() pools := m.Pools()
var poolName string
if len(pools) > 0 { 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) ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel() defer cancel()
box, err := m.Create(ctx, sandbox.CreateOptions{ box, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(), Image: testImage(),
Owner: "bench", Owner: "bench",
Pool: poolName,
}) })
if err != nil { if err != nil {
b.Fatalf("Create: %v", err) b.Fatalf("Create: %v", err)

View file

@ -23,9 +23,9 @@ type Box struct {
lastHeartbeat atomic.Int64 lastHeartbeat atomic.Int64
processCount atomic.Int32 processCount atomic.Int32
idleTimeoutD time.Duration idleTimeoutD time.Duration
maxLifetimeD time.Duration
stopTimeoutD time.Duration stopTimeoutD time.Duration
createdAt time.Time createdAt time.Time
refreshToken string
vnc bool vnc bool
image string image string
workspaceID string workspaceID string
@ -286,31 +286,16 @@ func (b *Box) lastActiveTime() time.Time {
} }
func (b *Box) idleTimeout() time.Duration { func (b *Box) idleTimeout() time.Duration {
if b.idleTimeoutD > 0 { return b.idleTimeoutD
return b.idleTimeoutD
}
pd := b.manager.findPoolDef(b.pool)
if pd != nil {
return pd.IdleTimeout
}
return 0
} }
func (b *Box) maxLifetime() time.Duration { func (b *Box) maxLifetime() time.Duration {
pd := b.manager.findPoolDef(b.pool) return b.maxLifetimeD
if pd != nil {
return pd.MaxLifetime
}
return 0
} }
func (b *Box) stopTimeout() time.Duration { func (b *Box) stopTimeout() time.Duration {
if b.stopTimeoutD > 0 { if b.stopTimeoutD > 0 {
return b.stopTimeoutD return b.stopTimeoutD
} }
pd := b.manager.findPoolDef(b.pool)
if pd != nil && pd.StopTimeout > 0 {
return pd.StopTimeout
}
return DefaultStopTimeout return DefaultStopTimeout
} }

View file

@ -66,9 +66,10 @@ func TestAttachWS(t *testing.T) {
} }
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) { box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) {
co.Ports = []sandbox.PortMapping{ co.Ports = []sandbox.PortMapping{
{ContainerPort: 9800, HostPort: 0, Protocol: "tcp"}, {ContainerPort: 9800, HostPort: 0, Protocol: "tcp"},
} }
@ -114,9 +115,10 @@ func TestAttachSSE(t *testing.T) {
} }
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) { box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) {
co.Ports = []sandbox.PortMapping{ co.Ports = []sandbox.PortMapping{
{ContainerPort: 9801, HostPort: 0, Protocol: "tcp"}, {ContainerPort: 9801, HostPort: 0, Protocol: "tcp"},
} }
@ -163,9 +165,10 @@ func TestVNCURL(t *testing.T) {
} }
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) { box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) {
co.VNC = true co.VNC = true
}) })
@ -193,9 +196,10 @@ func TestVNCConnect(t *testing.T) {
} }
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) { box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) {
co.VNC = true co.VNC = true
}) })

View file

@ -16,28 +16,28 @@ func TestImageExists(t *testing.T) {
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
if pc.Name == "k8s" { if pc.Name == "k8s" {
t.Run("always_true", func(t *testing.T) { 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) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
exists, err := m.ImageExists(ctx, pc.Name, "anything:nonexistent") exists, err := m.ImageExists(ctx, pc.TaiID, "anything:nonexistent")
require.NoError(t, err) require.NoError(t, err)
assert.True(t, exists, "k8s mode should always return true") assert.True(t, exists, "k8s mode should always return true")
}) })
return return
} }
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
t.Run("existing", func(t *testing.T) { 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) require.NoError(t, err)
assert.True(t, exists) assert.True(t, exists)
}) })
t.Run("missing", func(t *testing.T) { 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) require.NoError(t, err)
assert.False(t, exists) assert.False(t, exists)
}) })
@ -51,22 +51,22 @@ func TestImagePull(t *testing.T) {
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
if pc.Name == "k8s" { if pc.Name == "k8s" {
t.Run("noop", func(t *testing.T) { t.Run("noop", func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() 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) require.NoError(t, err)
assert.Nil(t, ch, "k8s mode should return nil channel") assert.Nil(t, ch, "k8s mode should return nil channel")
}) })
return return
} }
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel() defer cancel()
t.Run("pull_with_progress", func(t *testing.T) { 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.NoError(t, err)
require.NotNil(t, ch) require.NotNil(t, ch)
@ -87,15 +87,15 @@ func TestEnsureImage(t *testing.T) {
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc pc := pc
t.Run(pc.Name, func(t *testing.T) { 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) ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel() 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) require.NoError(t, err)
if pc.Name != "k8s" { 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) require.NoError(t, err)
assert.True(t, exists) assert.True(t, exists)
} }
@ -110,11 +110,11 @@ func TestEnsureImage_BadRef(t *testing.T) {
continue continue
} }
t.Run(pc.Name, func(t *testing.T) { 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) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() 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) assert.Error(t, err)
}) })
} }

View file

@ -14,9 +14,10 @@ func TestBoxExec(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m) box := createTestBox(t, m, pc)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
@ -36,9 +37,10 @@ func TestBoxExecWithOptions(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m) box := createTestBox(t, m, pc)
ctx := context.Background() ctx := context.Background()
result, err := box.Exec(ctx, []string{"pwd"}, result, err := box.Exec(ctx, []string{"pwd"},
@ -58,9 +60,10 @@ func TestBoxStream(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m) box := createTestBox(t, m, pc)
ctx := context.Background() ctx := context.Background()
stream, err := box.Stream(ctx, []string{"sh", "-c", "echo line1; echo line2"}) stream, err := box.Stream(ctx, []string{"sh", "-c", "echo line1; echo line2"})
@ -91,9 +94,10 @@ func TestBoxWorkspace(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m) box := createTestBox(t, m, pc)
ws := box.Workspace() ws := box.Workspace()
if ws == nil { if ws == nil {
@ -132,9 +136,10 @@ func TestBoxInfo(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m) box := createTestBox(t, m, pc)
ctx := context.Background() ctx := context.Background()
info, err := box.Info(ctx) info, err := box.Info(ctx)
@ -158,9 +163,10 @@ func TestBoxStopStart(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m) box := createTestBox(t, m, pc)
ctx := context.Background() ctx := context.Background()
if err := box.Stop(ctx); err != nil { if err := box.Stop(ctx); err != nil {
@ -186,13 +192,15 @@ func TestBoxGetOrCreate(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
ctx := context.Background() ctx := context.Background()
box1, err := m.GetOrCreate(ctx, sandbox.CreateOptions{ box1, err := m.GetOrCreate(ctx, sandbox.CreateOptions{
ID: "goc-" + pc.Name, ID: "goc-" + pc.Name,
Image: testImage(), Image: testImage(),
Owner: "test-user", Owner: "test-user",
Pool: pc.TaiID,
}) })
if err != nil { if err != nil {
t.Fatalf("GetOrCreate first: %v", err) t.Fatalf("GetOrCreate first: %v", err)
@ -203,6 +211,7 @@ func TestBoxGetOrCreate(t *testing.T) {
ID: "goc-" + pc.Name, ID: "goc-" + pc.Name,
Image: testImage(), Image: testImage(),
Owner: "test-user", Owner: "test-user",
Pool: pc.TaiID,
}) })
if err != nil { if err != nil {
t.Fatalf("GetOrCreate second: %v", err) t.Fatalf("GetOrCreate second: %v", err)

View file

@ -16,19 +16,20 @@ func TestWorkspaceID_Set(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { 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) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{ 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) require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true) 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 co.WorkspaceID = ws.ID
}) })
@ -41,9 +42,10 @@ func TestWorkspaceID_Empty(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m) box := createTestBox(t, m, pc)
assert.Empty(t, box.WorkspaceID()) assert.Empty(t, box.WorkspaceID())
}) })
} }
@ -53,23 +55,24 @@ func TestWorkspace_NodeRouting(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { 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) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{ 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) require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true) 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 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) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
sbm, _ := setupManagerWithWorkspace(t, pc) sbm, _ := setupManagerWithWorkspace(t, &pc)
ensureTestImage(t, sbm, pc.Name) ensureTestImage(t, sbm, pc.TaiID)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
@ -100,20 +104,20 @@ func TestWorkspace_BindMountLocal(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()} 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) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{ 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) require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true) defer wsm.Delete(context.Background(), ws.ID, true)
require.NoError(t, wsm.WriteFile(ctx, ws.ID, "seed.txt", []byte("hello from workspace"), 0644)) 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 co.WorkspaceID = ws.ID
}) })
@ -126,18 +130,18 @@ func TestWorkspace_ContainerWriteBack(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()} 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) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{ 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) require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true) 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 co.WorkspaceID = ws.ID
}) })
@ -153,20 +157,20 @@ func TestWorkspace_ReadOnlyMount(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()} 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) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{ 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) require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true) defer wsm.Delete(context.Background(), ws.ID, true)
require.NoError(t, wsm.WriteFile(ctx, ws.ID, "readonly.txt", []byte("immutable"), 0644)) 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.WorkspaceID = ws.ID
co.MountMode = "ro" co.MountMode = "ro"
}) })
@ -186,20 +190,20 @@ func TestWorkspace_CustomMountPath(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
pc := poolConfig{Name: "local", Addr: testLocalAddr()} 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) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{ 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) require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true) defer wsm.Delete(context.Background(), ws.ID, true)
require.NoError(t, wsm.WriteFile(ctx, ws.ID, "data.json", []byte(`{"ok":true}`), 0644)) 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.WorkspaceID = ws.ID
co.MountPath = "/data" co.MountPath = "/data"
}) })
@ -214,25 +218,22 @@ func TestWorkspace_BoxWorkspaceFS(t *testing.T) {
for _, pc := range testPools() { for _, pc := range testPools() {
if pc.Name == "local" { 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 continue
} }
pc := pc
t.Run(pc.Name, func(t *testing.T) { 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) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{ 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) require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true) 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 co.WorkspaceID = ws.ID
}) })
@ -254,23 +255,23 @@ func TestWorkspace_LabelPersistence(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { 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) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
ws, err := wsm.Create(ctx, workspace.CreateOptions{ 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) require.NoError(t, err)
defer wsm.Delete(context.Background(), ws.ID, true) 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 co.WorkspaceID = ws.ID
}) })
// WorkspaceID getter should reflect what was set
assert.Equal(t, ws.ID, box.WorkspaceID()) assert.Equal(t, ws.ID, box.WorkspaceID())
// Container should also carry the label (verify via exec reading env or // Container should also carry the label (verify via exec reading env or

View file

@ -1,5 +0,0 @@
package sandbox
type Config struct {
Pool []Pool
}

View file

@ -16,25 +16,14 @@ Supports workspace mounting, VNC, WebSocket proxying, and HostExec.
### Init ### Init
```go ```go
func Init(cfg Config) error func Init()
``` ```
Initializes the global Manager singleton. Must be called once at startup. Initializes the global Manager singleton. Must be called once at startup.
No configuration is needed — node discovery is handled by `tai/registry`.
```go ```go
err := sandbox.Init(sandbox.Config{ sandbox.Init()
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,
},
},
})
``` ```
### M ### M
@ -51,28 +40,12 @@ mgr := sandbox.M()
--- ---
## Config ## Node Discovery
```go Sandbox V2 no longer uses a static pool configuration. Nodes are discovered dynamically
type Config struct { through `tai/registry`. Each Tai node registers itself with a unique **TaiID** (e.g.
Pool []Pool `"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.
```
### 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)
}
```
--- ---
@ -126,7 +99,7 @@ Creates and starts a new sandbox container. Returns a `Box` handle.
box, err := sandbox.M().Create(ctx, sandbox.CreateOptions{ box, err := sandbox.M().Create(ctx, sandbox.CreateOptions{
Image: "alpine:latest", Image: "alpine:latest",
Owner: "user-123", Owner: "user-123",
Pool: "docker", Pool: "192.168.1.10-19100", // TaiID from registry
Policy: sandbox.Session, Policy: sandbox.Session,
WorkDir: "/workspace", WorkDir: "/workspace",
Env: map[string]string{"LANG": "en_US.UTF-8"}, 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) 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 — Returns a `Host` handle for the given pool (identified by TaiID). Unlike `Create`, no
the Host is available as long as the pool's Tai server reports `host_exec` capability. container is provisioned — the Host is available as long as the Tai server reports
Returns `ErrPoolNotFound` if the pool does not exist, or an error if the pool has no `host_exec`. `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 ```go
host, err := sandbox.M().Host(ctx, "remote") host, err := sandbox.M().Host(ctx, "192.168.1.10-19100")
``` ```
### Get ### Get
@ -198,7 +172,7 @@ Returns all sandboxes matching the given filters. Empty fields = no filter.
```go ```go
boxes, err := sandbox.M().List(ctx, sandbox.ListOptions{ boxes, err := sandbox.M().List(ctx, sandbox.ListOptions{
Owner: "user-123", Owner: "user-123",
Pool: "docker", Pool: "192.168.1.10-19100",
Labels: map[string]string{"project": "demo"}, 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 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 ```go
err := sandbox.M().Remove(ctx, "sb-12345") 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) 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 ### Pools
```go ```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 ```go
for _, p := range sandbox.M().Pools() { for _, n := range sandbox.M().Pools() {
fmt.Printf("pool=%s addr=%s connected=%v boxes=%d\n", fmt.Printf("tai_id=%s mode=%s addr=%s status=%s\n",
p.Name, p.Addr, p.Connected, p.Boxes) 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 ### ImageExists
```go ```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). Returns `(true, nil)` when the pool has no image service (e.g. K8s — kubelet handles pulls).
```go ```go
exists, err := sandbox.M().ImageExists(ctx, "docker", "alpine:latest") exists, err := sandbox.M().ImageExists(ctx, "192.168.1.10-19100", "alpine:latest")
``` ```
### PullImage ### PullImage
@ -319,7 +251,7 @@ service (e.g. K8s).
`PullProgress` fields: `Status string`, `Layer string`, `Current int64`, `Total int64`, `Error string`. `PullProgress` fields: `Status string`, `Layer string`, `Current int64`, `Total int64`, `Error string`.
```go ```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{ Auth: &sandbox.RegistryAuth{
Username: "user", Username: "user",
Password: "pass", 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. Checks if the image exists; if not, pulls it and blocks until complete.
```go ```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. Runs a command directly on the Tai host machine via HostExec gRPC.
```go ```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"}, result, err := host.Exec(ctx, "git", []string{"status"},
sandbox.WithHostWorkDir("/data/repos/project"), sandbox.WithHostWorkDir("/data/repos/project"),
sandbox.WithHostEnv(map[string]string{"GIT_AUTHOR_NAME": "bot"}), 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. ExecStream. Returns a `HostExecStream` with separate channels for stdout and stderr.
```go ```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"}, stream, err := host.Stream(ctx, "tail", []string{"-f", "/var/log/app.log"},
sandbox.WithHostWorkDir("/data"), sandbox.WithHostWorkDir("/data"),
sandbox.WithHostTimeout(60000), sandbox.WithHostTimeout(60000),
@ -607,7 +539,7 @@ type CreateOptions struct {
ID string ID string
Owner string Owner string
Labels map[string]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 Image string // required
WorkDir string // default "/workspace" WorkDir string // default "/workspace"
User string // container user User string // container user
@ -617,8 +549,9 @@ type CreateOptions struct {
VNC bool VNC bool
Ports []PortMapping Ports []PortMapping
Policy LifecyclePolicy // default Session Policy LifecyclePolicy // default Session
IdleTimeout time.Duration // overrides pool default IdleTimeout time.Duration // 0 = no idle cleanup
StopTimeout time.Duration // overrides pool default MaxLifetime time.Duration // 0 = no max lifetime
StopTimeout time.Duration // SIGTERM grace period; 0 = DefaultStopTimeout (2s)
WorkspaceID string // workspace to mount; empty = none WorkspaceID string // workspace to mount; empty = none
MountMode string // "rw" (default) or "ro" MountMode string // "rw" (default) or "ro"
MountPath string // default "/workspace" 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 ### ImagePullOptions / RegistryAuth
```go ```go
@ -758,11 +676,10 @@ type HostExecStream struct {
```go ```go
var ( var (
ErrNotAvailable = errors.New("sandbox: not available (no pools configured)") ErrNotAvailable = errors.New("sandbox: not available (no pools configured)")
ErrNotFound = errors.New("sandbox: not found") ErrNotFound = errors.New("sandbox: not found")
ErrLimitExceeded = errors.New("sandbox: limit exceeded") ErrPoolNotFound = errors.New("sandbox: pool not found")
ErrPoolNotFound = errors.New("sandbox: pool not found") ErrPoolMissing = errors.New("sandbox: pool name is required")
ErrPoolInUse = errors.New("sandbox: pool has running boxes")
) )
``` ```
@ -770,38 +687,28 @@ var (
## Helper Functions ## 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 ### BuildGRPCEnv
```go ```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 | - `mode` — the `TaiNode.Mode` (`"local"`, `"direct"`, `"tunnel"`)
|--------------------|--------------------------------------| - `addr` — the `TaiNode.Addr` (e.g. `"tai://192.168.1.10:19100"` for direct mode)
| `YAO_SANDBOX_ID` | Sandbox identifier | - `sandboxID` — the container's sandbox identifier
| `YAO_TOKEN` | Access token for gRPC auth |
| `YAO_REFRESH_TOKEN` | Refresh token for token rotation | | Variable | Description |
| `YAO_GRPC_ADDR` | gRPC server address (auto-derived) | |------------------|------------------------------------|
| `YAO_SANDBOX_ID` | Sandbox identifier |
| `YAO_GRPC_ADDR` | gRPC server address (auto-derived) |
Address derivation logic: Address derivation logic:
- `tai://host:port``host:port` (default port 19100 when omitted) - `local``host.docker.internal:<grpcPort>`
- `tunnel://...``127.0.0.1:<grpcPort>` - `direct` with `tai://host:port``host:port`
- Local/default → `127.0.0.1:<grpcPort>` - `tunnel``127.0.0.1:<grpcPort>`
Token injection (`YAO_TOKEN`, `YAO_REFRESH_TOKEN`) is the **caller's responsibility** via
`CreateOptions.Env`. See IMPL.md "OAuth Decoupling" for details.

View file

@ -3,9 +3,8 @@ package sandbox
import "errors" import "errors"
var ( var (
ErrNotAvailable = errors.New("sandbox: not available (no pools configured)") ErrNotAvailable = errors.New("sandbox: not available (no pools configured)")
ErrNotFound = errors.New("sandbox: not found") ErrNotFound = errors.New("sandbox: not found")
ErrLimitExceeded = errors.New("sandbox: limit exceeded") ErrPoolNotFound = errors.New("sandbox: pool not found")
ErrPoolNotFound = errors.New("sandbox: pool not found") ErrPoolMissing = errors.New("sandbox: pool name is required")
ErrPoolInUse = errors.New("sandbox: pool has running boxes")
) )

View file

@ -1,63 +1,43 @@
package sandbox package sandbox
import ( import (
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"github.com/yaoapp/yao/config"
) )
func createToken() (string, error) { // BuildGRPCEnv builds the gRPC environment variables for a sandbox container
b := make([]byte, 32) // based on the Tai node's mode and address from the registry.
if _, err := rand.Read(b); err != nil { //
return "", err // 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) portStr := strconv.Itoa(grpcPort)
env := map[string]string{ env := map[string]string{
"YAO_SANDBOX_ID": sandboxID, "YAO_SANDBOX_ID": sandboxID,
"YAO_TOKEN": access,
"YAO_REFRESH_TOKEN": refresh,
} }
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) env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%s", portStr)
return env
}
switch { case "direct":
case strings.HasPrefix(pool.Addr, "tunnel://"): u, err := url.Parse(addr)
env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%d", grpcPort) if err != nil || u.Hostname() == "" {
env["YAO_GRPC_ADDR"] = fmt.Sprintf("host.docker.internal:%s", portStr)
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)
return env return env
} }
taiHost := u.Hostname() 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) env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:%s", taiHost, taiPort)
default: 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 return env
} }

View file

@ -3,27 +3,28 @@ package sandbox_test
import ( import (
"testing" "testing"
"github.com/yaoapp/yao/config"
sandbox "github.com/yaoapp/yao/sandbox/v2" sandbox "github.com/yaoapp/yao/sandbox/v2"
) )
func TestBuildGRPCEnvLocal(t *testing.T) { func TestBuildGRPCEnvLocal(t *testing.T) {
pool := &sandbox.Pool{Name: "local", Addr: "local"} config.Conf.GRPC.Port = 9099
env := sandbox.BuildGRPCEnv(pool, "sb-001", "access-tok", "refresh-tok", 9099) env := sandbox.BuildGRPCEnv("local", "", "sb-001")
if env["YAO_SANDBOX_ID"] != "sb-001" { if env["YAO_SANDBOX_ID"] != "sb-001" {
t.Errorf("YAO_SANDBOX_ID = %q", env["YAO_SANDBOX_ID"]) t.Errorf("YAO_SANDBOX_ID = %q", env["YAO_SANDBOX_ID"])
} }
if env["YAO_TOKEN"] != "access-tok" { if _, ok := env["YAO_TOKEN"]; ok {
t.Errorf("YAO_TOKEN = %q", env["YAO_TOKEN"]) t.Error("YAO_TOKEN should not be set by BuildGRPCEnv")
} }
if env["YAO_GRPC_ADDR"] != "127.0.0.1:9099" { if env["YAO_GRPC_ADDR"] != "host.docker.internal:9099" {
t.Errorf("YAO_GRPC_ADDR = %q", env["YAO_GRPC_ADDR"]) t.Errorf("YAO_GRPC_ADDR = %q, want host.docker.internal:9099", env["YAO_GRPC_ADDR"])
} }
} }
func TestBuildGRPCEnvRemote(t *testing.T) { func TestBuildGRPCEnvDirect(t *testing.T) {
pool := &sandbox.Pool{Name: "gpu", Addr: "tai://gpu-server"} config.Conf.GRPC.Port = 9099
env := sandbox.BuildGRPCEnv(pool, "sb-002", "access", "refresh", 9099) env := sandbox.BuildGRPCEnv("direct", "tai://gpu-server", "sb-002")
if env["YAO_GRPC_ADDR"] != "gpu-server:19100" { if env["YAO_GRPC_ADDR"] != "gpu-server:19100" {
t.Errorf("YAO_GRPC_ADDR = %q, want gpu-server:19100", env["YAO_GRPC_ADDR"]) 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) { func TestBuildGRPCEnvTunnel(t *testing.T) {
pool := &sandbox.Pool{Name: "tunnel", Addr: "tunnel://relay.example.com"} config.Conf.GRPC.Port = 9099
env := sandbox.BuildGRPCEnv(pool, "sb-003", "access", "refresh", 9099) env := sandbox.BuildGRPCEnv("tunnel", "tunnel://relay.example.com", "sb-003")
if env["YAO_GRPC_ADDR"] != "127.0.0.1:9099" { 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"]) 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")
}
}

View file

@ -12,16 +12,11 @@ import (
"github.com/yaoapp/yao/tai" "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() t.Helper()
addr := fmt.Sprintf("tai://%s", tgt.Addr) addr := fmt.Sprintf("tai://%s", tgt.Addr)
pool := sandbox.Pool{Name: tgt.Name, Addr: addr} m, pools := setupManager(t, poolConfig{Name: tgt.Name, Addr: addr})
cfg := sandbox.Config{Pool: []sandbox.Pool{pool}} tgt.TaiID = pools[0].TaiID
if err := sandbox.Init(cfg); err != nil {
t.Fatalf("Init: %v", err)
}
m := sandbox.M()
t.Cleanup(func() { m.Close() })
return m return m
} }
@ -29,10 +24,11 @@ func TestHost_Exec_Echo(t *testing.T) {
skipIfNoHostExec(t) skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() { for _, tgt := range hostExecTargets() {
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) { 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 { if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err) t.Skipf("Host(%s): %v", tgt.Name, err)
} }
@ -66,10 +62,11 @@ func TestHost_Exec_Env(t *testing.T) {
skipIfNoHostExec(t) skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() { for _, tgt := range hostExecTargets() {
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) { 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 { if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err) t.Skipf("Host(%s): %v", tgt.Name, err)
} }
@ -106,10 +103,11 @@ func TestHost_Workplace(t *testing.T) {
skipIfNoHostExec(t) skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() { for _, tgt := range hostExecTargets() {
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) { 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 { if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err) t.Skipf("Host(%s): %v", tgt.Name, err)
} }
@ -163,10 +161,11 @@ func TestHost_Stream_Incremental(t *testing.T) {
if tgt.IsWinNative { if tgt.IsWinNative {
continue continue
} }
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) { 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 { if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err) t.Skipf("Host(%s): %v", tgt.Name, err)
} }
@ -225,10 +224,11 @@ func TestHost_Stream_MultiLine(t *testing.T) {
if tgt.IsWinNative { if tgt.IsWinNative {
continue continue
} }
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) { 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 { if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err) t.Skipf("Host(%s): %v", tgt.Name, err)
} }
@ -270,10 +270,11 @@ func TestHost_Stream_Stderr(t *testing.T) {
if tgt.IsWinNative { if tgt.IsWinNative {
continue continue
} }
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) { 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 { if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err) t.Skipf("Host(%s): %v", tgt.Name, err)
} }
@ -319,10 +320,11 @@ func TestHost_Stream_Cancel(t *testing.T) {
if tgt.IsWinNative { if tgt.IsWinNative {
continue continue
} }
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) { 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 { if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err) t.Skipf("Host(%s): %v", tgt.Name, err)
} }
@ -369,10 +371,11 @@ func TestHost_ComputerInfo(t *testing.T) {
skipIfNoHostExec(t) skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() { for _, tgt := range hostExecTargets() {
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) { 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 { if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err) t.Skipf("Host(%s): %v", tgt.Name, err)
} }
@ -381,8 +384,8 @@ func TestHost_ComputerInfo(t *testing.T) {
if info.Kind != "host" { if info.Kind != "host" {
t.Errorf("Kind = %q, want 'host'", info.Kind) t.Errorf("Kind = %q, want 'host'", info.Kind)
} }
if info.Pool != tgt.Name { if info.Pool != tgt.TaiID {
t.Errorf("Pool = %q, want %q", info.Pool, tgt.Name) t.Errorf("Pool = %q, want %q", info.Pool, tgt.TaiID)
} }
}) })
} }
@ -392,10 +395,11 @@ func TestHost_ComputerInterface(t *testing.T) {
skipIfNoHostExec(t) skipIfNoHostExec(t)
for _, tgt := range hostExecTargets() { for _, tgt := range hostExecTargets() {
tgt := tgt
t.Run(tgt.Name, func(t *testing.T) { 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 { if err != nil {
t.Skipf("Host(%s): %v", tgt.Name, err) 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") 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) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
@ -424,7 +428,7 @@ func TestHost_CreateRejectsNoContainerPool(t *testing.T) {
_, err := m.Create(ctx, sandbox.CreateOptions{ _, err := m.Create(ctx, sandbox.CreateOptions{
Image: "alpine:latest", Image: "alpine:latest",
Owner: "test", Owner: "test",
Pool: tgt.Name, Pool: tgt.TaiID,
}) })
if err == nil { if err == nil {
t.Fatal("expected error for Create on host-exec-only pool, got 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) { func TestHost_PoolNotFound(t *testing.T) {
skipIfNoHostExec(t) skipIfNoHostExec(t)
tgt := hostExecTargets()[0] tgt := hostExecTargets()[0]
m := setupHostManager(t, tgt) m := setupHostManager(t, &tgt)
_, err := m.Host(context.Background(), "nonexistent-pool") _, err := m.Host(context.Background(), "nonexistent-pool")
if err == nil { if err == nil {

View file

@ -13,7 +13,8 @@ pc.Remove()
// Or use the host directly (no container) // Or use the host directly (no container)
const host = sandbox.Host() 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. 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({ const pc = sandbox.Create({
image: "node:20", // required — container image image: "node:20", // required — container image
owner: "user-123", // required — owner identifier 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 id: "my-sandbox", // optional — if set, uses GetOrCreate
workdir: "/app", // optional — working directory workdir: "/app", // optional — working directory
user: "1000:1000", // optional — UID:GID user: "1000:1000", // optional — UID:GID
@ -73,8 +74,8 @@ const all = sandbox.List()
// Filter by owner // Filter by owner
const mine = sandbox.List({ owner: "user-123" }) const mine = sandbox.List({ owner: "user-123" })
// Filter by pool and labels // Filter by pool (TaiID) and labels
const gpu = sandbox.List({ pool: "gpu", labels: { team: "ml" } }) const gpu = sandbox.List({ pool: "10.0.0.5-19100", labels: { team: "ml" } })
``` ```
Each element in the returned array: Each element in the returned array:
@ -83,7 +84,7 @@ Each element in the returned array:
{ {
id: "sb-xxx", id: "sb-xxx",
container_id: "abc123...", container_id: "abc123...",
pool: "default", pool: "192.168.1.10-19100",
owner: "user-123", owner: "user-123",
status: "running", // "running"|"stopped"|"creating"|... status: "running", // "running"|"stopped"|"creating"|...
image: "node:20", image: "node:20",
@ -104,13 +105,12 @@ Remove a sandbox and its container.
sandbox.Delete("my-sandbox") 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 ```javascript
const host = sandbox.Host() // default pool const host = sandbox.Host("192.168.1.10-19100")
const gpu = sandbox.Host("gpu") // specific pool
``` ```
### sandbox.GetNode(taiID) → NodeInfo | null ### 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. 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) ### 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.kind` | string | `"box"` or `"host"` |
| `pc.id` | string | Sandbox ID (box-only; empty for host) | | `pc.id` | string | Sandbox ID (box-only; empty for host) |
| `pc.owner` | string | Owner identifier (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 ### 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 ### 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}` - **Box**: routes to `container-ip:{port}`
- **Host**: routes to `127.0.0.1:{port}` on the Tai machine via `__host__` - **Host**: routes to `127.0.0.1:{port}` on the Tai machine via `__host__`
@ -260,7 +260,7 @@ Get identity and registry information.
```javascript ```javascript
const info = pc.ComputerInfo() const info = pc.ComputerInfo()
console.log(info.kind) // "box" or "host" 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.system.os) // "linux" | "windows" | "darwin"
console.log(info.status) // "running" | "stopped" | ... console.log(info.status) // "running" | "stopped" | ...
``` ```
@ -269,7 +269,7 @@ Returns a [ComputerInfo](#computerinfo-object) object.
### pc.BindWorkplace(workspaceID) → void ### 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 ```javascript
pc.BindWorkplace("ws-project-abc") pc.BindWorkplace("ws-project-abc")
@ -277,7 +277,7 @@ pc.BindWorkplace("ws-project-abc")
### pc.Workplace() → WorkspaceFS | null ### 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 ```javascript
pc.BindWorkplace("ws-project-abc") 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. 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 ### 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 ```javascript
const info = pc.Info() const info = pc.Info()
@ -353,7 +332,7 @@ Returned by `pc.ComputerInfo()`. Read-only snapshot of a Computer's identity and
```javascript ```javascript
{ {
kind: "box", // "box" | "host" kind: "box", // "box" | "host"
pool: "default", pool: "192.168.1.10-19100", // TaiID
tai_id: "tai-abc123", tai_id: "tai-abc123",
machine_id: "m-xyz", machine_id: "m-xyz",
version: "1.2.3", version: "1.2.3",
@ -390,7 +369,7 @@ Returned by `sandbox.GetNode()`, `sandbox.Nodes()`, `sandbox.NodesByTeam()`. Rea
machine_id: "m-xyz", machine_id: "m-xyz",
version: "1.2.3", version: "1.2.3",
mode: "direct", // "direct" | "tunnel" mode: "direct", // "direct" | "tunnel"
addr: "192.168.1.100", addr: "tai://192.168.1.100:19100",
status: "online", // "online" | "offline" | "connecting" status: "online", // "online" | "offline" | "connecting"
pool: "gpu", pool: "gpu",
connected_at: "2026-03-07T08:00:00Z", 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 ### Host execution for GPU workloads
```javascript ```javascript
const host = sandbox.Host("gpu") const host = sandbox.Host("10.0.0.5-19100")
const result = host.Exec(["nvidia-smi"]) const result = host.Exec(["nvidia-smi"])
console.log(result.stdout) console.log(result.stdout)
host.Exec(["python3", "train.py", "--epochs=10"], { const train = host.Exec(["python3", "train.py", "--epochs=10"], {
workdir: "/workspace/ml", workdir: "/workspace/ml",
env: { CUDA_VISIBLE_DEVICES: "0,1" }, env: { CUDA_VISIBLE_DEVICES: "0,1" },
timeout: 3600000 timeout: 3600000
}) })
if (train.exit_code !== 0) throw new Error("training failed: " + train.stderr)
``` ```
### Uniform interface — same code for box and host ### Uniform interface — same code for box and host
@ -534,7 +514,7 @@ function runTask(pc, cmd, opts) {
// Works the same for both // Works the same for both
const box = sandbox.Create({ image: "node:20", owner: "u1" }) 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(box, ["node", "-e", "console.log('hi')"])
runTask(host, ["echo", "hello"]) runTask(host, ["echo", "hello"])
@ -558,7 +538,7 @@ const appURL = pc.Proxy(3000)
// "http://tai-host:8099/container-id:3000/" // "http://tai-host:8099/container-id:3000/"
// Same methods work on host // Same methods work on host
const host = sandbox.Host() const host = sandbox.Host("192.168.1.10-19100")
const hostVNC = host.VNC() const hostVNC = host.VNC()
// "ws://tai-host:16080/vnc/__host__/ws" // "ws://tai-host:16080/vnc/__host__/ws"
``` ```

View file

@ -1,138 +1,471 @@
package jsapi package jsapi
import ( import (
"context"
"encoding/json"
"sync"
"time"
sandbox "github.com/yaoapp/yao/sandbox/v2"
wsjsapi "github.com/yaoapp/yao/workspace/jsapi"
"rogchap.com/v8go" "rogchap.com/v8go"
) )
// sbHost: `sandbox.Host(pool?)` → Computer (kind="host") // ---------------------------------------------------------------------------
// // Helpers — shared across jsapi files
// Go: Manager.Host(ctx, pool) (*Host, error) // ---------------------------------------------------------------------------
//
// Args: func throwError(info *v8go.FunctionCallbackInfo, msg string) *v8go.Value {
// iso := info.Context().Isolate()
// pool: string (optional) — pool name; empty = default pool e, _ := v8go.NewValue(iso, msg)
// iso.ThrowException(e)
// Returns: Computer object (kind="host") if the pool has host_exec capability, otherwise throws. return v8go.Undefined(iso)
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())
} }
// NewComputerObject creates a unified JS Computer object backed by either a Box or Host. func parseStringArray(val *v8go.Value) []string {
// The `kind` field ("box" or "host") determines which methods are available at runtime. obj, err := val.AsObject()
// Box-only methods (Attach, Info, Start, Stop, Remove) throw an error when called on a host. if err != nil {
// return nil
// # Properties (read-only) }
// lenVal, err := obj.Get("length")
// pc.kind → string // "box" | "host" ← ComputerInfo().Kind if err != nil {
// pc.id → string // sandbox ID ← Box.ID() (empty for host) return nil
// pc.owner → string // owner ← Box.Owner() (empty for host) }
// pc.pool → string // pool name ← ComputerInfo().Pool length := int(lenVal.Int32())
// result := make([]string, 0, length)
// # Methods — Computer interface (both box and host) for i := 0; i < length; i++ {
// item, err := obj.GetIdx(uint32(i))
// pc.Exec(cmd, options?) → ExecResult if err != nil || !item.IsString() {
// continue
// Go: Computer.Exec(ctx, cmd []string, opts ...ExecOption) (*ExecResult, error) }
// result = append(result, item.String())
// JS args: }
// cmd: string[] → cmd []string return result
// options: { → ExecOption }
// workdir: string, → WithWorkDir(dir)
// env: object, → WithEnv(map[string]string) func parseStringMap(v8ctx *v8go.Context, val *v8go.Value) map[string]string {
// stdin: string, → WithStdin([]byte) result := make(map[string]string)
// timeout: number, → WithTimeout(ms → time.Duration) if !val.IsObject() {
// max_output: number → WithMaxOutput(bytes int64) return result
// } }
// JS returns: { jsonStr, err := v8go.JSONStringify(v8ctx, val)
// exit_code: number, ← ExecResult.ExitCode if err != nil {
// stdout: string, ← ExecResult.Stdout return result
// stderr: string, ← ExecResult.Stderr }
// duration_ms: number, ← ExecResult.DurationMs _ = json.Unmarshal([]byte(jsonStr), &result)
// error: string, ← ExecResult.Error return result
// truncated: boolean ← ExecResult.Truncated }
// }
// func parseExecOptions(v8ctx *v8go.Context, args []*v8go.Value) ([]string, []sandbox.ExecOption, *v8go.Value) {
// pc.Stream(cmd, callback) / pc.Stream(cmd, options, callback) if len(args) < 1 || !args[0].IsObject() {
// return nil, nil, nil
// Go: Computer.Stream(ctx, cmd []string, opts ...ExecOption) (*ExecStream, error) }
// cmd := parseStringArray(args[0])
// Blocks until the process exits. The last argument must be a JS function. if len(cmd) == 0 {
// Callback signature: function(type, data) return nil, nil, nil
// type = "stdout" → data is string (chunk) }
// type = "stderr" → data is string (chunk) var opts []sandbox.ExecOption
// type = "exit" → data is number (exit code) var callback *v8go.Value
// for i := 1; i < len(args); i++ {
// pc.VNC() → string v := args[i]
// if v.IsFunction() {
// Go: Computer.VNC(ctx) (string, error) callback = v
// Box: returns ws://host:port/vnc/{containerID}/ws break
// Host: returns ws://host:port/vnc/__host__/ws }
// if v.IsObject() {
// pc.Proxy(port, path?) → string optsObj, err := v.AsObject()
// if err != nil {
// Go: Computer.Proxy(ctx, port int, path string) (string, error) continue
// Box: returns http://host:port/{containerID}:{port}/{path} }
// Host: returns http://host:port/__host__:{port}/{path} if wd, e := optsObj.Get("workdir"); e == nil && wd.IsString() {
// opts = append(opts, sandbox.WithWorkDir(wd.String()))
// pc.ComputerInfo() → ComputerInfo }
// if env, e := optsObj.Get("env"); e == nil && env.IsObject() {
// Go: Computer.ComputerInfo() ComputerInfo envMap := parseStringMap(v8ctx, env)
// JS returns: { kind, pool, tai_id, machine_id, version, mode, status, capabilities, if len(envMap) > 0 {
// system: { os, arch, hostname, num_cpu, total_mem }, opts = append(opts, sandbox.WithEnv(envMap))
// box_id, container_id, owner, image, policy, labels } }
// }
// pc.BindWorkplace(workspaceID) → void if stdin, e := optsObj.Get("stdin"); e == nil && stdin.IsString() {
// opts = append(opts, sandbox.WithStdin([]byte(stdin.String())))
// Go: Computer.BindWorkplace(workspaceID string) }
// if t, e := optsObj.Get("timeout"); e == nil && t.IsNumber() {
// pc.Workplace() → WorkspaceFS | null opts = append(opts, sandbox.WithTimeout(time.Duration(t.Number())*time.Millisecond))
// }
// Go: Computer.Workplace() workspace.FS if mo, e := optsObj.Get("max_output"); e == nil && mo.IsNumber() {
// Returns WorkspaceFS if a workplace is bound, null otherwise. opts = append(opts, sandbox.WithMaxOutput(int64(mo.Number())))
// }
// # Methods — Box-only (throw on host) }
// }
// pc.Attach(port, options?) → string return cmd, opts, callback
// }
// Gets a WebSocket/SSE endpoint URL for a container service.
// JS args: func execResultToJS(v8ctx *v8go.Context, r *sandbox.ExecResult) *v8go.Value {
// port: number data, _ := json.Marshal(map[string]interface{}{
// options: { protocol: "ws"|"sse", path: string } "exit_code": r.ExitCode,
// JS returns: string (URL) "stdout": r.Stdout,
// "stderr": r.Stderr,
// pc.Info() → BoxInfo "duration_ms": r.DurationMs,
// "error": r.Error,
// Go: Box.Info(ctx) (*BoxInfo, error) "truncated": r.Truncated,
// JS returns: { id, container_id, pool, owner, status, image, vnc, policy, })
// labels, created_at, last_active, process_count } val, _ := v8go.JSONParse(v8ctx, string(data))
// return val
// pc.Start() → void }
//
// Go: Box.Start(ctx) error func boxInfoToJS(v8ctx *v8go.Context, b *sandbox.BoxInfo) *v8go.Value {
// data, _ := json.Marshal(map[string]interface{}{
// pc.Stop() → void "id": b.ID,
// "container_id": b.ContainerID,
// Go: Box.Stop(ctx) error "pool": b.Pool,
// "owner": b.Owner,
// pc.Remove() → void "status": b.Status,
// "image": b.Image,
// Go: Box.Remove(ctx) error "vnc": b.VNC,
func NewComputerObject(v8ctx *v8go.Context, kind string, id string) (*v8go.Value, error) { "policy": string(b.Policy),
// TODO: Phase 2 implementation "labels": b.Labels,
// 1. Create JS object via v8go.NewObjectTemplate "created_at": b.CreatedAt.Format(time.RFC3339),
// 2. Set read-only properties: kind, id, owner, pool "last_active": b.LastActive.Format(time.RFC3339),
// - kind: "box" or "host" "process_count": b.ProcessCount,
// - id/owner: from sandbox.M().Get(id) for box; empty for host })
// - pool: from ComputerInfo().Pool val, _ := v8go.JSONParse(v8ctx, string(data))
// 3. Bind Computer interface methods: return val
// - Exec, Stream, VNC, Proxy, ComputerInfo, BindWorkplace, Workplace }
// 4. Bind box-only methods with kind guard:
// - Attach, Info, Start, Stop, Remove func computerInfoToJS(v8ctx *v8go.Context, c sandbox.ComputerInfo) *v8go.Value {
// - If kind == "host", these throw: "not supported: {method}() requires a box computer" data, _ := json.Marshal(map[string]interface{}{
return nil, nil "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
} }

View file

@ -32,7 +32,12 @@
package jsapi package jsapi
import ( import (
"context"
"encoding/json"
"time"
v8 "github.com/yaoapp/gou/runtime/v8" v8 "github.com/yaoapp/gou/runtime/v8"
sandbox "github.com/yaoapp/yao/sandbox/v2"
"rogchap.com/v8go" "rogchap.com/v8go"
) )
@ -54,112 +59,227 @@ func ExportObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
return obj return obj
} }
// sbCreate: `sandbox.Create(options)` → Box // sbCreate: `sandbox.Create(options)` → Computer (kind="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
func sbCreate(info *v8go.FunctionCallbackInfo) *v8go.Value { func sbCreate(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2 v8ctx := info.Context()
// 1. Parse options from info.Args()[0] ctx := context.Background()
// 2. Validate required fields (image, owner) args := info.Args()
// 3. If opts.id != "" → sandbox.M().GetOrCreate(ctx, opts) if len(args) < 1 || !args[0].IsObject() {
// else → sandbox.M().Create(ctx, opts) return throwError(info, "Create requires options object")
// 4. Return NewComputerObject(v8ctx, "box", box.ID()) }
return v8go.Undefined(info.Context().Isolate())
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 // sbGet: `sandbox.Get(id)` → Computer (kind="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
func sbGet(info *v8go.FunctionCallbackInfo) *v8go.Value { func sbGet(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2 iso := info.Context().Isolate()
// 1. id = info.Args()[0].String() v8ctx := info.Context()
// 2. box, err := sandbox.M().Get(ctx, id) ctx := context.Background()
// 3. Return NewComputerObject(v8ctx, "box", id) or null args := info.Args()
return v8go.Undefined(info.Context().Isolate()) 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[] // 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 { func sbList(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2 v8ctx := info.Context()
// 1. Parse optional filter from info.Args()[0] ctx := context.Background()
// 2. boxes := sandbox.M().List(ctx, opts) args := info.Args()
// 3. For each box: box.Info(ctx) → BoxInfo → JS object
// 4. Return JS array of BoxInfo objects opts := sandbox.ListOptions{}
return v8go.Undefined(info.Context().Isolate()) 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 // 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 { func sbDelete(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2 iso := info.Context().Isolate()
// 1. id = info.Args()[0].String() args := info.Args()
// 2. sandbox.M().Remove(ctx, id) if len(args) < 1 || !args[0].IsString() {
return v8go.Undefined(info.Context().Isolate()) 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)
} }

View file

@ -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
}
}

View file

@ -1,105 +1,142 @@
package jsapi package jsapi
import ( import (
"encoding/json"
"time"
"github.com/yaoapp/yao/tai/registry"
"rogchap.com/v8go" "rogchap.com/v8go"
) )
// sbGetNode: `sandbox.GetNode(taiID)` → NodeInfo | null // 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 { func sbGetNode(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2 iso := info.Context().Isolate()
// 1. taiID = info.Args()[0].String() args := info.Args()
// 2. snap, ok := registry.Global().Get(taiID) if len(args) < 1 || !args[0].IsString() {
// 3. if !ok { return v8go.Null } return throwError(info, "GetNode requires taiID (string)")
// 4. Return snapshotToJS(v8ctx, snap) }
return v8go.Undefined(info.Context().Isolate())
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[] // 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 { func sbNodes(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2 v8ctx := info.Context()
// 1. snaps := registry.Global().List()
// 2. Build JS array, for each: snapshotToJS(v8ctx, snap) reg := registry.Global()
// 3. Return JS array if reg == nil {
return v8go.Undefined(info.Context().Isolate()) return throwError(info, "registry not initialized")
}
snaps := reg.List()
return snapshotsToJSArray(v8ctx, snaps)
} }
// sbNodesByTeam: `sandbox.NodesByTeam(teamID)` → NodeInfo[] // 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 { func sbNodesByTeam(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2 v8ctx := info.Context()
// 1. teamID = info.Args()[0].String() args := info.Args()
// 2. snaps := registry.Global().ListByTeam(teamID) if len(args) < 1 || !args[0].IsString() {
// 3. Build JS array, for each: snapshotToJS(v8ctx, snap) return throwError(info, "NodesByTeam requires teamID (string)")
// 4. Return JS array }
return v8go.Undefined(info.Context().Isolate())
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. // snapshotToJS converts a NodeSnapshot to a JS NodeInfo object.
// // Auth and YaoBase are excluded for security.
// Excluded: Auth (sensitive), YaoBase (internal URL). func snapshotToJS(v8ctx *v8go.Context, snap *registry.NodeSnapshot) (*v8go.Value, error) {
// ports := make(map[string]interface{}, len(snap.Ports))
// NodeInfo JS object shape: for k, v := range snap.Ports {
// ports[k] = v
// { }
// tai_id: string, ← NodeSnapshot.TaiID
// machine_id: string, ← NodeSnapshot.MachineID caps := make(map[string]interface{}, len(snap.Capabilities))
// version: string, ← NodeSnapshot.Version for k, v := range snap.Capabilities {
// mode: string, ← NodeSnapshot.Mode ("direct"|"tunnel") caps[k] = v
// addr: string, ← NodeSnapshot.Addr }
// status: string, ← NodeSnapshot.Status ("online"|"offline"|"connecting")
// pool: string, ← NodeSnapshot.PoolName data, err := json.Marshal(map[string]interface{}{
// connected_at: string, ← NodeSnapshot.ConnectedAt (ISO 8601) "tai_id": snap.TaiID,
// last_ping: string, ← NodeSnapshot.LastPing (ISO 8601) "machine_id": snap.MachineID,
// ports: { ← NodeSnapshot.Ports "version": snap.Version,
// grpc: number, "mode": snap.Mode,
// http: number, "addr": snap.Addr,
// vnc: number, "status": snap.Status,
// docker: number, "pool": snap.PoolName,
// k8s: number, "connected_at": snap.ConnectedAt.Format(time.RFC3339),
// }, "last_ping": snap.LastPing.Format(time.RFC3339),
// capabilities: { ← NodeSnapshot.Capabilities "ports": ports,
// docker: boolean, "capabilities": caps,
// k8s: boolean, "system": map[string]interface{}{
// host_exec: boolean, "os": snap.System.OS,
// }, "arch": snap.System.Arch,
// system: { ← NodeSnapshot.System (SystemInfo) "hostname": snap.System.Hostname,
// os: string, "num_cpu": snap.System.NumCPU,
// arch: string, "total_mem": snap.System.TotalMem,
// hostname: string, },
// num_cpu: number, })
// total_mem: number, if err != nil {
// } return nil, err
// } }
//
//nolint:unused // placeholder for Phase 2 return v8go.JSONParse(v8ctx, string(data))
func snapshotToJS(v8ctx *v8go.Context, snap interface{}) (*v8go.Value, error) { }
// TODO: Phase 2 implementation
// 1. Create JS object via v8go.NewObjectTemplate func snapshotsToJSArray(v8ctx *v8go.Context, snaps []registry.NodeSnapshot) *v8go.Value {
// 2. Set scalar fields: tai_id, machine_id, version, mode, addr, status, pool items := make([]interface{}, 0, len(snaps))
// 3. Set time fields: connected_at, last_ping → snap.ConnectedAt.Format(time.RFC3339) for i := range snaps {
// 4. Build ports sub-object from snap.Ports map snap := &snaps[i]
// 5. Build capabilities sub-object from snap.Capabilities map ports := make(map[string]interface{}, len(snap.Ports))
// 6. Build system sub-object from snap.System (OS, Arch, Hostname, NumCPU, TotalMem) for k, v := range snap.Ports {
// 7. Return the JS object ports[k] = v
return nil, nil }
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
} }

View file

@ -7,49 +7,36 @@ import (
"time" "time"
"github.com/yaoapp/yao/tai" "github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
taisandbox "github.com/yaoapp/yao/tai/sandbox" taisandbox "github.com/yaoapp/yao/tai/sandbox"
"github.com/yaoapp/yao/workspace" "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 { type Manager struct {
pool map[string]*tai.Client boxes sync.Map
poolDefs []Pool mu sync.Mutex
defaultPool string cancel context.CancelFunc
config Config
boxes sync.Map
mu sync.Mutex
cancel context.CancelFunc
grpcPort int
wsManager *workspace.Manager
} }
func newManager(cfg Config) (*Manager, error) { func newManager() *Manager {
m := &Manager{ return &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
} }
// Start discovers existing containers from all pools, rebuilds the boxes map, // Start discovers existing containers from all registered nodes, rebuilds
// and starts the cleanup loop. // the boxes map, and starts the cleanup loop.
func (m *Manager) Start(ctx context.Context) error { func (m *Manager) Start(ctx context.Context) error {
if len(m.poolDefs) == 0 { reg := registry.Global()
if reg == nil {
return nil return nil
} }
for _, pd := range m.poolDefs { for _, snap := range reg.List() {
client, err := m.getPool(pd.Name) client, err := m.getPool(snap.TaiID)
if err != nil { if err != nil {
continue continue
} }
m.recoverBoxes(ctx, &pd, client) m.recoverBoxes(ctx, snap.TaiID, client)
} }
loopCtx, cancel := context.WithCancel(ctx) loopCtx, cancel := context.WithCancel(ctx)
@ -58,96 +45,13 @@ func (m *Manager) Start(ctx context.Context) error {
return nil return nil
} }
// AddPool registers a new pool at runtime. // Pools returns the list of registered Tai nodes from the registry.
func (m *Manager) AddPool(_ context.Context, p Pool) error { func (m *Manager) Pools() []registry.NodeSnapshot {
m.mu.Lock() reg := registry.Global()
defer m.mu.Unlock() if reg == nil {
return nil
for _, pd := range m.poolDefs {
if pd.Name == p.Name {
return fmt.Errorf("sandbox: pool %q already exists", p.Name)
}
} }
m.poolDefs = append(m.poolDefs, p) return reg.List()
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
} }
// Heartbeat updates the box's last heartbeat timestamp. // 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. // 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) { func (m *Manager) Host(_ context.Context, pool string) (*Host, error) {
if pool == "" { if pool == "" {
pool = m.defaultPool return nil, ErrPoolMissing
}
pd := m.findPoolDef(pool)
if pd == nil {
return nil, ErrPoolNotFound
} }
client, err := m.getPool(pool) 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. // Create creates and starts a new sandbox.
func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) { func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) {
if len(m.poolDefs) == 0 {
return nil, ErrNotAvailable
}
if opts.Image == "" { if opts.Image == "" {
return nil, fmt.Errorf("sandbox: image is required") return nil, fmt.Errorf("sandbox: image is required")
} }
poolName := opts.Pool poolName := opts.Pool
if poolName == "" {
poolName = m.defaultPool
}
// Workspace node binding: when WorkspaceID is set, resolve the workspace's if opts.WorkspaceID != "" {
// bound node and force the container onto that pool. if wsm := workspace.M(); wsm != nil {
if opts.WorkspaceID != "" && m.wsManager != nil { node, err := wsm.NodeForWorkspace(ctx, opts.WorkspaceID)
node, err := m.wsManager.NodeForWorkspace(ctx, opts.WorkspaceID) if err != nil {
if err != nil { return nil, fmt.Errorf("sandbox: resolve workspace %q: %w", opts.WorkspaceID, err)
return nil, fmt.Errorf("sandbox: resolve workspace %q: %w", opts.WorkspaceID, err) }
poolName = node
} }
poolName = node
} }
pd := m.findPoolDef(poolName) if poolName == "" {
if pd == nil { return nil, ErrPoolMissing
return nil, ErrPoolNotFound
}
if err := m.checkLimits(pd, opts.Owner); err != nil {
return nil, err
} }
id := opts.ID 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) return nil, fmt.Errorf("sandbox: pool %q has no container runtime", poolName)
} }
access, refresh, err := CreateContainerTokens(id, opts.Owner, nil) taiOpts := m.buildTaiCreateOptions(opts, poolName, id)
if err != nil {
return nil, fmt.Errorf("sandbox: create tokens: %w", err)
}
taiOpts := m.buildTaiCreateOptions(opts, pd, id, access, refresh)
containerID, err := client.Sandbox().Create(ctx, taiOpts) containerID, err := client.Sandbox().Create(ctx, taiOpts)
if err != nil { if err != nil {
@ -267,9 +147,9 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
policy: policy, policy: policy,
labels: opts.Labels, labels: opts.Labels,
idleTimeoutD: opts.IdleTimeout, idleTimeoutD: opts.IdleTimeout,
maxLifetimeD: opts.MaxLifetime,
stopTimeoutD: opts.StopTimeout, stopTimeoutD: opts.StopTimeout,
createdAt: time.Now(), createdAt: time.Now(),
refreshToken: refresh,
manager: m, manager: m,
vnc: opts.VNC, vnc: opts.VNC,
image: opts.Image, image: opts.Image,
@ -337,10 +217,6 @@ func (m *Manager) Remove(ctx context.Context, id string) error {
client.Sandbox().Remove(ctx, b.containerID, true) client.Sandbox().Remove(ctx, b.containerID, true)
} }
if b.refreshToken != "" {
RevokeContainerTokens(b.refreshToken)
}
m.boxes.Delete(id) m.boxes.Delete(id)
return nil return nil
} }
@ -376,32 +252,14 @@ func (m *Manager) Cleanup(ctx context.Context) error {
return nil 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 { func (m *Manager) Close() error {
if m.cancel != nil { if m.cancel != nil {
m.cancel() m.cancel()
} }
m.mu.Lock()
defer m.mu.Unlock()
for name, client := range m.pool {
client.Close()
delete(m.pool, name)
}
return nil 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) { func (m *Manager) cleanupLoop(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute) ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop() defer ticker.Stop()
@ -416,78 +274,27 @@ func (m *Manager) cleanupLoop(ctx context.Context) {
} }
func (m *Manager) getPool(name string) (*tai.Client, error) { func (m *Manager) getPool(name string) (*tai.Client, error) {
m.mu.Lock() client, ok := tai.GetClient(name)
defer m.mu.Unlock() if !ok {
if client, ok := m.pool[name]; ok {
return client, nil
}
pd := m.findPoolDefLocked(name)
if pd == nil {
return nil, ErrPoolNotFound return nil, ErrPoolNotFound
} }
client, err := tai.New(pd.Addr, pd.Options...)
if err != nil {
return nil, err
}
m.pool[name] = client
return client, nil return client, nil
} }
func (m *Manager) findPoolDef(name string) *Pool { func (m *Manager) buildTaiCreateOptions(opts CreateOptions, poolName, sandboxID string) taisandbox.CreateOptions {
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 {
env := make(map[string]string) 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 env[k] = v
} }
@ -495,7 +302,7 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID,
"managed-by": "yao-sandbox", "managed-by": "yao-sandbox",
"sandbox-id": sandboxID, "sandbox-id": sandboxID,
"sandbox-owner": opts.Owner, "sandbox-owner": opts.Owner,
"sandbox-pool": pd.Name, "sandbox-pool": poolName,
"sandbox-policy": string(opts.Policy), "sandbox-policy": string(opts.Policy),
} }
if opts.WorkspaceID != "" { if opts.WorkspaceID != "" {
@ -522,20 +329,21 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID,
}) })
} }
// Workspace bind mount
var binds []string var binds []string
if opts.WorkspaceID != "" && m.wsManager != nil { if opts.WorkspaceID != "" {
mountPath := opts.MountPath if wsm := workspace.M(); wsm != nil {
if mountPath == "" { mountPath := opts.MountPath
mountPath = "/workspace" if mountPath == "" {
} mountPath = "/workspace"
mode := opts.MountMode }
if mode == "" { mode := opts.MountMode
mode = "rw" if mode == "" {
} mode = "rw"
hostPath, _ := m.wsManager.MountPath(context.Background(), opts.WorkspaceID) }
if hostPath != "" { hostPath, _ := wsm.MountPath(context.Background(), opts.WorkspaceID)
binds = append(binds, fmt.Sprintf("%s:%s:%s", hostPath, mountPath, mode)) 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 { if client.Sandbox() == nil {
return 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. // 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) { func (m *Manager) ImageExists(ctx context.Context, pool, ref string) (bool, error) {
client, err := m.getPool(pool) client, err := m.getPool(pool)
if err != nil { 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 // 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) { func (m *Manager) PullImage(ctx context.Context, pool, ref string, opts ImagePullOptions) (<-chan taisandbox.PullProgress, error) {
client, err := m.getPool(pool) client, err := m.getPool(pool)
if err != nil { 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 // 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 // pulls the image and blocks until the pull completes.
// error encountered during pull. For K8s pools this is a no-op.
func (m *Manager) EnsureImage(ctx context.Context, pool, ref string, opts ImagePullOptions) error { func (m *Manager) EnsureImage(ctx context.Context, pool, ref string, opts ImagePullOptions) error {
exists, err := m.ImageExists(ctx, pool, ref) exists, err := m.ImageExists(ctx, pool, ref)
if err != nil { if err != nil {

View file

@ -12,9 +12,10 @@ func TestHeartbeatUpdates(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m) box := createTestBox(t, m, pc)
err := m.Heartbeat(box.ID(), true, 5) err := m.Heartbeat(box.ID(), true, 5)
if err != nil { if err != nil {
@ -34,8 +35,9 @@ func TestHeartbeatUpdates(t *testing.T) {
func TestHeartbeatUnknownBox(t *testing.T) { func TestHeartbeatUnknownBox(t *testing.T) {
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
err := m.Heartbeat("nonexistent", true, 1) err := m.Heartbeat("nonexistent", true, 1)
if err != sandbox.ErrNotFound { if err != sandbox.ErrNotFound {
t.Errorf("err = %v, want ErrNotFound", err) t.Errorf("err = %v, want ErrNotFound", err)
@ -48,17 +50,18 @@ func TestIdleCleanup(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc, func(p *sandbox.Pool) { m := setupManagerForPool(t, &pc)
p.IdleTimeout = 1 * time.Second ensureTestImage(t, m, pc.TaiID)
})
ensureTestImage(t, m, pc.Name)
ctx := context.Background() ctx := context.Background()
box, err := m.Create(ctx, sandbox.CreateOptions{ box, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(), Image: testImage(),
Owner: "test-user", Owner: "test-user",
Policy: sandbox.Session, Pool: pc.TaiID,
Policy: sandbox.Session,
IdleTimeout: 1 * time.Second,
}) })
if err != nil { if err != nil {
t.Fatalf("Create: %v", err) t.Fatalf("Create: %v", err)
@ -83,17 +86,13 @@ func TestStartRecovery(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options} m1 := setupManagerForPool(t, &pc)
box := createTestBox(t, m1, pc)
m1 := setupManager(t, pool)
box := createTestBox(t, m1)
boxID := box.ID() boxID := box.ID()
cfg := sandbox.Config{Pool: []sandbox.Pool{pool}} sandbox.Init()
if err := sandbox.Init(cfg); err != nil {
t.Fatalf("Init2: %v", err)
}
m2 := sandbox.M() m2 := sandbox.M()
defer m2.Close() defer m2.Close()
@ -119,13 +118,13 @@ func TestPersistentNotCleaned(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc, func(p *sandbox.Pool) { m := setupManagerForPool(t, &pc)
p.IdleTimeout = 1 * time.Second
})
box := createTestBox(t, m, func(co *sandbox.CreateOptions) { box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) {
co.Policy = sandbox.Persistent co.Policy = sandbox.Persistent
co.IdleTimeout = 1 * time.Second
}) })
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)

View file

@ -12,9 +12,10 @@ func TestCreateAndExec(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m) box := createTestBox(t, m, pc)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
@ -37,9 +38,10 @@ func TestCreateWithLabels(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) { box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) {
co.Labels = map[string]string{"app": "test-app"} co.Labels = map[string]string{"app": "test-app"}
}) })
@ -59,9 +61,10 @@ func TestGet(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m) box := createTestBox(t, m, pc)
got, err := m.Get(context.Background(), box.ID()) got, err := m.Get(context.Background(), box.ID())
if err != nil { if err != nil {
@ -76,8 +79,9 @@ func TestGet(t *testing.T) {
func TestGetNotFound(t *testing.T) { func TestGetNotFound(t *testing.T) {
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
_, err := m.Get(context.Background(), "nonexistent") _, err := m.Get(context.Background(), "nonexistent")
if err != sandbox.ErrNotFound { if err != sandbox.ErrNotFound {
t.Errorf("err = %v, want ErrNotFound", err) t.Errorf("err = %v, want ErrNotFound", err)
@ -90,9 +94,10 @@ func TestList(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
box := createTestBox(t, m, func(co *sandbox.CreateOptions) { box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) {
co.Owner = "user-list" co.Owner = "user-list"
}) })
@ -125,13 +130,15 @@ func TestRemove(t *testing.T) {
skipIfNoDocker(t) skipIfNoDocker(t)
for _, pc := range testPools() { for _, pc := range testPools() {
pc := pc
t.Run(pc.Name, func(t *testing.T) { t.Run(pc.Name, func(t *testing.T) {
m := setupManagerForPool(t, pc) m := setupManagerForPool(t, &pc)
ensureTestImage(t, m, pc.Name) ensureTestImage(t, m, pc.TaiID)
ctx := context.Background() ctx := context.Background()
box, err := m.Create(ctx, sandbox.CreateOptions{ box, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(), Image: testImage(),
Owner: "test-user", Owner: "test-user",
Pool: pc.TaiID,
}) })
if err != nil { if err != nil {
t.Fatalf("Create: %v", err) 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) { func TestCreateNoImage(t *testing.T) {
m := setupManager(t, sandbox.Pool{ m, pools := setupManager(t, poolConfig{Name: "local", Addr: testLocalAddr()})
Name: "local",
Addr: testLocalAddr(),
})
_, err := m.Create(context.Background(), sandbox.CreateOptions{ _, err := m.Create(context.Background(), sandbox.CreateOptions{
Owner: "test", Owner: "test",
Pool: pools[0].TaiID,
}) })
if err == nil { if err == nil {
t.Error("expected error for missing image") t.Error("expected error for missing image")
} }
} }
func TestCreateNoPools(t *testing.T) { func TestCreateNoPool(t *testing.T) {
m := setupManager(t) m, _ := setupManager(t, poolConfig{Name: "local", Addr: testLocalAddr()})
_, err := m.Create(context.Background(), sandbox.CreateOptions{ _, err := m.Create(context.Background(), sandbox.CreateOptions{
Image: testImage(), Image: testImage(),
}) })
if err != sandbox.ErrNotAvailable { if err != sandbox.ErrPoolMissing {
t.Errorf("err = %v, want ErrNotAvailable", err) 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") t.Skip("need at least 2 pools (local + remote) for multi-pool test")
} }
var sps []sandbox.Pool m, registered := setupManager(t, pools...)
for _, pc := range pools {
sps = append(sps, sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options})
}
m := setupManager(t, sps...)
for _, pc := range pools { for _, pc := range registered {
ensureTestImage(t, m, pc.Name) ensureTestImage(t, m, pc.TaiID)
} }
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) 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{ localBox, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(), Image: testImage(),
Owner: "test-user", Owner: "test-user",
Pool: "local", Pool: registered[0].TaiID,
}) })
if err != nil { if err != nil {
t.Fatalf("Create on local: %v", err) t.Fatalf("Create on local: %v", err)
@ -262,7 +210,7 @@ func TestMultiPool(t *testing.T) {
remoteBox, err := m.Create(ctx, sandbox.CreateOptions{ remoteBox, err := m.Create(ctx, sandbox.CreateOptions{
Image: testImage(), Image: testImage(),
Owner: "test-user", Owner: "test-user",
Pool: "remote", Pool: registered[1].TaiID,
}) })
if err != nil { if err != nil {
t.Fatalf("Create on remote: %v", err) t.Fatalf("Create on remote: %v", err)

View file

@ -3,15 +3,9 @@ package sandbox
var mgr *Manager var mgr *Manager
// Init initializes the global sandbox Manager. // Init initializes the global sandbox Manager.
// Config contains pool definitions. At least one Pool entry is required. // Node discovery is handled by the tai/registry; no configuration is needed.
// Pass empty Pool list to disable sandbox (methods return ErrNotAvailable). func Init() {
func Init(cfg Config) error { mgr = newManager()
m, err := newManager(cfg)
if err != nil {
return err
}
mgr = m
return nil
} }
// M returns the global Manager. Panics if Init was not called. // M returns the global Manager. Panics if Init was not called.

View file

@ -7,14 +7,7 @@ import (
) )
func TestInit(t *testing.T) { func TestInit(t *testing.T) {
cfg := sandbox.Config{ sandbox.Init()
Pool: []sandbox.Pool{
{Name: "test", Addr: "local"},
},
}
if err := sandbox.Init(cfg); err != nil {
t.Fatalf("Init: %v", err)
}
m := sandbox.M() m := sandbox.M()
if m == nil { if m == nil {
t.Fatal("M() returned nil") t.Fatal("M() returned nil")
@ -22,14 +15,6 @@ func TestInit(t *testing.T) {
m.Close() 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) { func TestMPanicWithoutInit(t *testing.T) {
sandbox.ResetForTest() sandbox.ResetForTest()
defer func() { defer func() {

View file

@ -13,8 +13,8 @@ import (
sandbox "github.com/yaoapp/yao/sandbox/v2" sandbox "github.com/yaoapp/yao/sandbox/v2"
"github.com/yaoapp/yao/tai" "github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
taisandbox "github.com/yaoapp/yao/tai/sandbox" taisandbox "github.com/yaoapp/yao/tai/sandbox"
"github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/workspace" "github.com/yaoapp/yao/workspace"
) )
@ -99,16 +99,13 @@ func purgeStaleContainers() {
} }
type poolConfig struct { type poolConfig struct {
Name string Name string // human-readable label for t.Run (e.g. "remote", "k8s")
Addr string Addr string
TaiID string // actual registry key, filled after tai.New
Options []tai.Option Options []tai.Option
} }
// testPools returns all available pool configurations for multi-mode testing. // 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 { func testPools() []poolConfig {
pools := []poolConfig{ pools := []poolConfig{
{Name: "local", Addr: testLocalAddr()}, {Name: "local", Addr: testLocalAddr()},
@ -119,8 +116,6 @@ func testPools() []poolConfig {
if host := os.Getenv("TAI_TEST_CONTAINERIZED_HOST"); host != "" { if host := os.Getenv("TAI_TEST_CONTAINERIZED_HOST"); host != "" {
grpcPort := envPort("TAI_TEST_CONTAINERIZED_GRPC_PORT", 9200) grpcPort := envPort("TAI_TEST_CONTAINERIZED_GRPC_PORT", 9200)
addr := fmt.Sprintf("tai://%s:%d", host, grpcPort) 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}) pools = append(pools, poolConfig{Name: "containerized", Addr: addr})
} }
if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" { if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" {
@ -164,11 +159,10 @@ func skipIfNoTai(t *testing.T) {
type hostExecTarget struct { type hostExecTarget struct {
Name string Name string
Addr string // host:port (without tai:// prefix) Addr string // host:port (without tai:// prefix)
TaiID string // filled after registration
IsWinNative bool IsWinNative bool
} }
// hostExecTargets returns all Tai instances that support HostExec gRPC.
// No container creation needed — these are direct gRPC connections.
func hostExecTargets() []hostExecTarget { func hostExecTargets() []hostExecTarget {
var targets []hostExecTarget var targets []hostExecTarget
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" { 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) { func linuxCmd(tgt hostExecTarget, cmd string, args ...string) (string, []string) {
if tgt.IsWinNative { if tgt.IsWinNative {
switch cmd { switch cmd {
@ -245,55 +237,64 @@ func envPort(key string, fallback int) int {
return fallback 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() t.Helper()
cfg := sandbox.Config{Pool: pools}
if err := sandbox.Init(cfg); err != nil { reg := registry.Global()
t.Fatalf("Init: %v", err) 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() m := sandbox.M()
t.Cleanup(func() { t.Cleanup(func() { m.Close() })
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 return m
} }
func setupManagerForPool(t *testing.T, pc poolConfig, mutators ...func(*sandbox.Pool)) *sandbox.Manager { // setupManagerWithWorkspace creates a sandbox Manager and returns
t.Helper() // the global workspace.Manager (which uses the registry for client lookups).
pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options} func setupManagerWithWorkspace(t *testing.T, pc *poolConfig) (*sandbox.Manager, *workspace.Manager) {
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) {
t.Helper() t.Helper()
sbm := setupManagerForPool(t, pc) sbm := setupManagerForPool(t, pc)
return sbm, workspace.M()
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
} }
// 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) { func ensureTestImage(t *testing.T, m *sandbox.Manager, pool string) {
t.Helper() t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) 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() t.Helper()
co := sandbox.CreateOptions{ co := sandbox.CreateOptions{
Image: testImage(), Image: testImage(),
Owner: "test-user", Owner: "test-user",
Pool: pc.TaiID,
} }
for _, fn := range opts { for _, fn := range opts {
fn(&co) fn(&co)
@ -317,11 +319,12 @@ func createTestBox(t *testing.T, m *sandbox.Manager, opts ...func(*sandbox.Creat
if pool == "" { if pool == "" {
pools := m.Pools() pools := m.Pools()
if len(pools) > 0 { if len(pools) > 0 {
pool = pools[0].Name pool = pools[0].TaiID
co.Pool = pool
} }
} }
isK8s := pool == "k8s" isK8s := pc.Name == "k8s"
if isK8s { if isK8s {
k8sSem <- struct{}{} k8sSem <- struct{}{}
} }

View file

@ -5,7 +5,6 @@ import (
"io" "io"
"time" "time"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/workspace" "github.com/yaoapp/yao/tai/workspace"
) )
@ -56,7 +55,7 @@ type SystemInfo struct {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Lifecycle & Pool // Lifecycle
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type LifecyclePolicy string type LifecyclePolicy string
@ -70,28 +69,6 @@ const (
const DefaultStopTimeout = 2 * time.Second 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 // Create / List options
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -118,6 +95,7 @@ type CreateOptions struct {
Ports []PortMapping Ports []PortMapping
Policy LifecyclePolicy Policy LifecyclePolicy
IdleTimeout time.Duration IdleTimeout time.Duration
MaxLifetime time.Duration
StopTimeout time.Duration StopTimeout time.Duration
WorkspaceID string WorkspaceID string

View file

@ -44,6 +44,8 @@ type TaiNode struct {
LastPing time.Time LastPing time.Time
PoolName string PoolName string
client any // *tai.Client; stored as any to avoid import cycle
localListeners map[int]*tunnelListener localListeners map[int]*tunnelListener
} }
@ -63,6 +65,7 @@ type NodeSnapshot struct {
ConnectedAt time.Time ConnectedAt time.Time
LastPing time.Time LastPing time.Time
PoolName string PoolName string
client any
} }
func (n *TaiNode) snapshot() NodeSnapshot { func (n *TaiNode) snapshot() NodeSnapshot {
@ -81,9 +84,14 @@ func (n *TaiNode) snapshot() NodeSnapshot {
Ports: ports, Capabilities: caps, Ports: ports, Capabilities: caps,
Status: n.Status, ConnectedAt: n.ConnectedAt, LastPing: n.LastPing, Status: n.Status, ConnectedAt: n.ConnectedAt, LastPing: n.LastPing,
PoolName: n.PoolName, 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. // AuthInfo holds Yao user authorization extracted from OAuth token.
type AuthInfo struct { type AuthInfo struct {
Subject string 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. // ListByTeam returns snapshots of all nodes belonging to the given team.
func (r *Registry) ListByTeam(teamID string) []NodeSnapshot { func (r *Registry) ListByTeam(teamID string) []NodeSnapshot {
r.mu.RLock() r.mu.RLock()

View file

@ -130,6 +130,7 @@ type Client struct {
scheme string // "tai", "docker", or "tunnel" scheme string // "tai", "docker", or "tunnel"
host string host string
addr string addr string
taiID string // registry key — set by initLocal/initRemote/initTunnel
ports Ports ports Ports
dataDir string // host-side data directory for local volume dataDir string // host-side data directory for local volume
vol volume.Volume vol volume.Volume
@ -209,6 +210,23 @@ func (c *Client) initLocal(cfg *config) (*Client, error) {
c.dataDir = dataDir c.dataDir = dataDir
c.vol = volume.NewLocal(dataDir) c.vol = volume.NewLocal(dataDir)
} }
if reg := registry.Global(); reg != nil {
id := c.host
if id == "" {
id = c.addr
}
if id == "" {
id = "local"
}
c.taiID = id
reg.Register(&registry.TaiNode{
TaiID: id,
Mode: "local",
Addr: c.addr,
})
reg.SetClient(id, c)
}
return c, nil return c, nil
} }
@ -276,10 +294,12 @@ func (c *Client) initRemote(cfg *config) (*Client, error) {
} }
if reg := registry.Global(); reg != nil { if reg := registry.Global(); reg != nil {
id := fmt.Sprintf("%s-%d", c.host, c.ports.GRPC)
c.taiID = id
reg.Register(&registry.TaiNode{ reg.Register(&registry.TaiNode{
TaiID: c.host, TaiID: id,
Mode: "direct", Mode: "direct",
Addr: c.host, Addr: fmt.Sprintf("tai://%s:%d", c.host, c.ports.GRPC),
Ports: map[string]int{ Ports: map[string]int{
"grpc": c.ports.GRPC, "grpc": c.ports.GRPC,
"http": c.ports.HTTP, "http": c.ports.HTTP,
@ -288,6 +308,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) {
"k8s": c.ports.K8s, "k8s": c.ports.K8s,
}, },
}) })
reg.SetClient(id, c)
} }
return c, nil 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 taiID := c.host // for tunnel:// scheme, host stores the taiID
c.taiID = taiID
node, ok := reg.Get(taiID) node, ok := reg.Get(taiID)
if !ok || node.Status != "online" { if !ok || node.Status != "online" {
return nil, fmt.Errorf("tai node %s not online", taiID) 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.prx = proxy.NewTunnel(taiID, node.YaoBase)
c.vc = vnc.NewTunnel(taiID, node.YaoBase) c.vc = vnc.NewTunnel(taiID, node.YaoBase)
} }
reg.SetClient(taiID, c)
return c, nil return c, nil
} }
@ -396,9 +419,9 @@ func (c *Client) Close() error {
} }
} }
c.closeTunnelListeners() c.closeTunnelListeners()
if c.scheme == "tai" { if c.taiID != "" {
if reg := registry.Global(); reg != nil { if reg := registry.Global(); reg != nil {
reg.Unregister(c.host) reg.Unregister(c.taiID)
} }
} }
if len(errs) > 0 { 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. // Empty for remote (Tai gRPC) connections — the Tai server manages paths.
func (c *Client) DataDir() string { return c.dataDir } 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. // Workspace returns an fs.FS-compatible filesystem for the given session.
func (c *Client) Workspace(sessionID string) workspace.FS { func (c *Client) Workspace(sessionID string) workspace.FS {
return workspace.New(c.vol, sessionID) return workspace.New(c.vol, sessionID)
@ -549,3 +578,20 @@ func (c *Client) discoverServerInfo(conn *grpc.ClientConn, cfg *config) (map[str
} }
return caps, nil 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
}

View file

@ -9,9 +9,9 @@ import (
v8runtime "github.com/yaoapp/gou/runtime/v8" v8runtime "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/yao/config" "github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/tai" "github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/volume" "github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/test" "github.com/yaoapp/yao/test"
"github.com/yaoapp/yao/workspace"
_ "github.com/yaoapp/yao/workspace/jsapi" _ "github.com/yaoapp/yao/workspace/jsapi"
) )
@ -32,6 +32,8 @@ func testModes() []testMode {
func setupForMode(t *testing.T, m testMode) { func setupForMode(t *testing.T, m testMode) {
t.Helper() t.Helper()
test.Prepare(t, config.Conf) test.Prepare(t, config.Conf)
registry.Init(nil)
var client *tai.Client var client *tai.Client
var err error var err error
if m.Addr == "local" { 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.Fatalf("tai.New(%s): %v", m.Addr, err)
} }
t.Cleanup(func() { client.Close() }) t.Cleanup(func() { client.Close() })
workspace.Init(map[string]*tai.Client{"default": client})
} }
func setupGlobal(t *testing.T) { func setupGlobal(t *testing.T) {
@ -88,7 +89,7 @@ func TestWSCreateAndDelete(t *testing.T) {
t.Run(m.Name, func(t *testing.T) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSCreateAndDelete() { 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; var id = ws.id;
workspace.Delete(id); workspace.Delete(id);
return id; return id;
@ -105,7 +106,7 @@ func TestWSGet(t *testing.T) {
t.Run(m.Name, func(t *testing.T) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSGet() { 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 got = workspace.Get(ws.id);
var result = got ? got.id : "null"; var result = got ? got.id : "null";
workspace.Delete(ws.id); workspace.Delete(ws.id);
@ -138,8 +139,8 @@ func TestWSList(t *testing.T) {
t.Run(m.Name, func(t *testing.T) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSList() { res := runJS(t, `function TestWSList() {
var ws1 = workspace.Create({ name: "list-a", 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: "default" }); var ws2 = workspace.Create({ name: "list-b", owner: "u1", node: "local" });
var list = workspace.List({ owner: "u1" }); var list = workspace.List({ owner: "u1" });
var count = list.length; var count = list.length;
workspace.Delete(ws1.id); workspace.Delete(ws1.id);
@ -158,7 +159,7 @@ func TestWSReadWriteFile(t *testing.T) {
t.Run(m.Name, func(t *testing.T) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSReadWriteFile() { 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!"); ws.WriteFile("hello.txt", "Hello, World!");
var content = ws.ReadFile("hello.txt"); var content = ws.ReadFile("hello.txt");
workspace.Delete(ws.id); workspace.Delete(ws.id);
@ -176,7 +177,7 @@ func TestWSReadDir(t *testing.T) {
t.Run(m.Name, func(t *testing.T) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSReadDir() { 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.WriteFile("a.txt", "aaa");
ws.MkdirAll("sub"); ws.MkdirAll("sub");
ws.WriteFile("sub/b.txt", "bbb"); ws.WriteFile("sub/b.txt", "bbb");
@ -196,7 +197,7 @@ func TestWSReadDirRecursive(t *testing.T) {
t.Run(m.Name, func(t *testing.T) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSReadDirRecursive() { 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.WriteFile("a.txt", "aaa");
ws.MkdirAll("sub/deep"); ws.MkdirAll("sub/deep");
ws.WriteFile("sub/b.txt", "bbb"); ws.WriteFile("sub/b.txt", "bbb");
@ -217,7 +218,7 @@ func TestWSStat(t *testing.T) {
t.Run(m.Name, func(t *testing.T) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSStat() { 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"); ws.WriteFile("file.txt", "12345");
var info = ws.Stat("file.txt"); var info = ws.Stat("file.txt");
workspace.Delete(ws.id); workspace.Delete(ws.id);
@ -235,7 +236,7 @@ func TestWSExistsIsDirIsFile(t *testing.T) {
t.Run(m.Name, func(t *testing.T) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSExistsIsDirIsFile() { 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.WriteFile("f.txt", "data");
ws.MkdirAll("d"); ws.MkdirAll("d");
var r = [ var r = [
@ -261,7 +262,7 @@ func TestWSRemoveAndRename(t *testing.T) {
t.Run(m.Name, func(t *testing.T) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSRemoveAndRename() { 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.WriteFile("del.txt", "x");
ws.Remove("del.txt"); ws.Remove("del.txt");
var a = ws.Exists("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) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSBase64() { 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"); ws.WriteFile("src.txt", "base64 test");
var b64 = ws.ReadFileBase64("src.txt"); var b64 = ws.ReadFileBase64("src.txt");
ws.WriteFileBase64("dst.txt", b64); ws.WriteFileBase64("dst.txt", b64);
@ -311,7 +312,7 @@ func TestWSCopyInternal(t *testing.T) {
t.Run(m.Name, func(t *testing.T) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSCopyInternal() { 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.WriteFile("src.txt", "copy me");
ws.Copy("src.txt", "dst.txt"); ws.Copy("src.txt", "dst.txt");
var content = ws.ReadFile("dst.txt"); var content = ws.ReadFile("dst.txt");
@ -336,7 +337,7 @@ func TestWSCopyLocalToLocal(t *testing.T) {
dstRel := dstDir[len(os.TempDir()):] dstRel := dstDir[len(os.TempDir()):]
runJS(t, `function TestWSCopyLocalToLocal() { 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+`"); ws.Copy("tmp://`+srcRel+`", "tmp://`+dstRel+`");
workspace.Delete(ws.id); workspace.Delete(ws.id);
return "ok"; return "ok";
@ -356,7 +357,7 @@ func TestWSZipUnzip(t *testing.T) {
t.Run(m.Name, func(t *testing.T) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSZipUnzip() { 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.MkdirAll("src");
ws.WriteFile("src/a.txt", "zip content"); ws.WriteFile("src/a.txt", "zip content");
ws.WriteFile("src/b.txt", "more"); ws.WriteFile("src/b.txt", "more");
@ -378,7 +379,7 @@ func TestWSGzipGunzip(t *testing.T) {
t.Run(m.Name, func(t *testing.T) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSGzipGunzip() { 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.WriteFile("data.txt", "gzip test");
ws.Gzip("data.txt", "data.txt.gz"); ws.Gzip("data.txt", "data.txt.gz");
ws.Gunzip("data.txt.gz", "restored.txt"); ws.Gunzip("data.txt.gz", "restored.txt");
@ -398,7 +399,7 @@ func TestWSTarUntar(t *testing.T) {
t.Run(m.Name, func(t *testing.T) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSTarUntar() { 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.MkdirAll("src");
ws.WriteFile("src/a.txt", "tar a"); ws.WriteFile("src/a.txt", "tar a");
ws.Tar("src", "out.tar"); ws.Tar("src", "out.tar");
@ -419,7 +420,7 @@ func TestWSTgzUntgz(t *testing.T) {
t.Run(m.Name, func(t *testing.T) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSTgzUntgz() { 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.MkdirAll("src");
ws.WriteFile("src/x.txt", "tgz x"); ws.WriteFile("src/x.txt", "tgz x");
ws.Tgz("src", "out.tgz"); ws.Tgz("src", "out.tgz");
@ -440,7 +441,7 @@ func TestWSZipExcludes(t *testing.T) {
t.Run(m.Name, func(t *testing.T) { t.Run(m.Name, func(t *testing.T) {
setupForMode(t, m) setupForMode(t, m)
res := runJS(t, `function TestWSZipExcludes() { 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.MkdirAll("src");
ws.WriteFile("src/keep.txt", "keep"); ws.WriteFile("src/keep.txt", "keep");
ws.WriteFile("src/skip.log", "skip"); ws.WriteFile("src/skip.log", "skip");

View file

@ -4,42 +4,28 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"sync"
"time" "time"
"github.com/yaoapp/yao/tai" "github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/volume" "github.com/yaoapp/yao/tai/volume"
taiworkspace "github.com/yaoapp/yao/tai/workspace" taiworkspace "github.com/yaoapp/yao/tai/workspace"
) )
var mgr *Manager var mgr = NewManager()
// Init initializes the global workspace Manager with the given pools. // M returns the global Manager.
func Init(pools map[string]*tai.Client) {
mgr = NewManager(pools)
}
// M returns the global Manager. Panics if Init was not called.
func M() *Manager { func M() *Manager {
if mgr == nil {
panic("workspace.Init not called")
}
return mgr return mgr
} }
// Manager owns workspace CRUD, file I/O, and node management. // Manager owns workspace CRUD, file I/O, and node management.
// Pools are shared with sandbox.Manager — both reference the same tai.Client instances. // All node/client lookups go through tai.GetClient → registry.
type Manager struct { type Manager struct{}
pools map[string]*tai.Client
mu sync.RWMutex
}
// NewManager creates a workspace manager with the given pools. // NewManager creates a workspace manager.
func NewManager(pools map[string]*tai.Client) *Manager { func NewManager() *Manager {
if pools == nil { return &Manager{}
pools = make(map[string]*tai.Client)
}
return &Manager{pools: pools}
} }
// Create allocates storage on the target node and persists metadata. // 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 return nil, ErrNodeMissing
} }
client, err := m.getClient(opts.Node) client, ok := tai.GetClient(opts.Node)
if err != nil { if !ok {
return nil, err return nil, ErrNodeOffline
} }
id := opts.ID id := opts.ID
@ -87,18 +73,19 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, e
} }
// Get returns a workspace by ID. // 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) { func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error) {
m.mu.RLock() for _, snap := range listNodes() {
defer m.mu.RUnlock() client, ok := tai.GetClient(snap.TaiID)
if !ok {
for nodeName, client := range m.pools { continue
ws, err := m.readMeta(ctx, client, id) }
ws, err := readMeta(ctx, client, id)
if err != nil { if err != nil {
continue continue
} }
if ws.Node == "" { if ws.Node == "" {
ws.Node = nodeName ws.Node = snap.TaiID
} }
return ws, nil 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. // List returns workspaces, optionally filtered by owner and/or node.
func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, error) { func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*Workspace var result []*Workspace
for nodeName, client := range m.pools { for _, snap := range listNodes() {
if opts.Node != "" && nodeName != opts.Node { if opts.Node != "" && snap.TaiID != opts.Node {
continue
}
client, ok := tai.GetClient(snap.TaiID)
if !ok {
continue continue
} }
entries, err := client.Volume().ListDir(ctx, "", ".") entries, err := client.Volume().ListDir(ctx, "", ".")
@ -123,12 +111,12 @@ func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, err
if !e.IsDir { if !e.IsDir {
continue continue
} }
ws, err := m.readMeta(ctx, client, e.Path) ws, err := readMeta(ctx, client, e.Path)
if err != nil { if err != nil {
continue continue
} }
if ws.Node == "" { if ws.Node == "" {
ws.Node = nodeName ws.Node = snap.TaiID
} }
if opts.Owner != "" && ws.Owner != opts.Owner { if opts.Owner != "" && ws.Owner != opts.Owner {
continue continue
@ -179,28 +167,25 @@ func (m *Manager) Delete(ctx context.Context, id string, force bool) error {
return nil 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 { func (m *Manager) Nodes() []NodeInfo {
m.mu.RLock() nodes := listNodes()
defer m.mu.RUnlock() result := make([]NodeInfo, 0, len(nodes))
for _, snap := range nodes {
nodes := make([]NodeInfo, 0, len(m.pools)) result = append(result, NodeInfo{
for name := range m.pools { Name: snap.TaiID,
nodes = append(nodes, NodeInfo{ Online: snap.Status == "online" || snap.Status == "",
Name: name,
Online: true,
}) })
} }
return nodes return result
} }
// FS returns an fs.FS-compatible filesystem for the given workspace. // FS returns an fs.FS-compatible filesystem for the given workspace.
func (m *Manager) FS(ctx context.Context, id string) (taiworkspace.FS, error) { func (m *Manager) FS(ctx context.Context, id string) (taiworkspace.FS, error) {
ws, client, err := m.resolve(ctx, id) _, client, err := m.resolve(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
_ = ws
return client.Workspace(id), nil 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 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. // NodeForWorkspace returns the node name for a given workspace ID.
// Used by sandbox.Manager to route container creation to the correct pool. // Used by sandbox.Manager to route container creation to the correct pool.
func (m *Manager) NodeForWorkspace(ctx context.Context, id string) (string, error) { func (m *Manager) NodeForWorkspace(ctx context.Context, id string) (string, error) {
@ -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, // MountPath returns the host-side directory path for a workspace,
// suitable for use as a Docker bind mount source. // 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) { func (m *Manager) MountPath(ctx context.Context, id string) (string, error) {
_, client, err := m.resolve(ctx, id) _, client, err := m.resolve(ctx, id)
if err != nil { if err != nil {
@ -321,23 +291,14 @@ func (m *Manager) MountPath(ctx context.Context, id string) (string, error) {
// --- internal --- // --- internal ---
func (m *Manager) getClient(node string) (*tai.Client, error) { // resolve finds the workspace and its tai.Client by scanning all registered nodes.
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.
func (m *Manager) resolve(ctx context.Context, id string) (*Workspace, *tai.Client, error) { func (m *Manager) resolve(ctx context.Context, id string) (*Workspace, *tai.Client, error) {
m.mu.RLock() for _, snap := range listNodes() {
defer m.mu.RUnlock() client, ok := tai.GetClient(snap.TaiID)
if !ok {
for _, client := range m.pools { continue
ws, err := m.readMeta(ctx, client, id) }
ws, err := readMeta(ctx, client, id)
if err != nil { if err != nil {
continue continue
} }
@ -346,7 +307,7 @@ func (m *Manager) resolve(ctx context.Context, id string) (*Workspace, *tai.Clie
return nil, nil, ErrNotFound 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) data, _, err := client.Volume().ReadFile(ctx, id, metadataFile)
if err != nil { if err != nil {
return nil, err return nil, err
@ -354,6 +315,14 @@ func (m *Manager) readMeta(ctx context.Context, client *tai.Client, id string) (
return unmarshalMeta(data) 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. // DirEntry represents a file or directory entry in a workspace listing.
type DirEntry struct { type DirEntry struct {
Name string `json:"name"` Name string `json:"name"`

View file

@ -2,11 +2,14 @@ package workspace_test
import ( import (
"context" "context"
"net/url"
"os" "os"
"strings"
"testing" "testing"
"time" "time"
"github.com/yaoapp/yao/tai" "github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/volume" "github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/workspace" "github.com/yaoapp/yao/workspace"
) )
@ -21,19 +24,47 @@ func testPools() []poolConfig {
{Name: "local", Addr: "local"}, {Name: "local", Addr: "local"},
} }
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" { 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 return pools
} }
func setupManagerForPool(tb testing.TB, pc poolConfig) *workspace.Manager { func taiIDFromAddr(addr string) string {
tb.Helper() addr = strings.TrimSpace(addr)
client := clientForPool(tb, pc) if addr == "local" || addr == "" {
pools := map[string]*tai.Client{pc.Name: client} return "local"
return workspace.NewManager(pools) }
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() tb.Helper()
if pc.Addr == "local" { if pc.Addr == "local" {
return localClient(tb, tb.TempDir()) return localClient(tb, tb.TempDir())
@ -57,13 +88,25 @@ func localClient(tb testing.TB, dataDir string) *tai.Client {
return client return client
} }
func setupManagerMultiNode(t *testing.T) *workspace.Manager { func setupManagerMultiNode(t *testing.T) (*workspace.Manager, string, string) {
t.Helper() t.Helper()
pools := map[string]*tai.Client{ ensureRegistry(t)
"node-a": localClient(t, t.TempDir()),
"node-b": localClient(t, t.TempDir()), 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 { func createWorkspace(tb testing.TB, m *workspace.Manager, node string, opts ...func(*workspace.CreateOptions)) *workspace.Workspace {

View file

@ -7,8 +7,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/workspace" "github.com/yaoapp/yao/workspace"
) )
@ -157,18 +155,18 @@ func TestList_FilterOwner(t *testing.T) {
} }
func TestList_FilterNode(t *testing.T) { func TestList_FilterNode(t *testing.T) {
m := setupManagerMultiNode(t) m, nodeA, nodeB := setupManagerMultiNode(t)
ctx := context.Background() 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) 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) 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) require.NoError(t, err)
assert.Len(t, list, 1) 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) { func TestUpdate_Name(t *testing.T) {
@ -246,17 +244,16 @@ func TestDelete_NotFound(t *testing.T) {
} }
func TestNodes(t *testing.T) { func TestNodes(t *testing.T) {
m := setupManagerMultiNode(t) m, nodeA, nodeB := setupManagerMultiNode(t)
nodes := m.Nodes() nodes := m.Nodes()
assert.Len(t, nodes, 2) assert.GreaterOrEqual(t, len(nodes), 2)
names := make(map[string]bool) names := make(map[string]bool)
for _, n := range nodes { for _, n := range nodes {
names[n.Name] = true names[n.Name] = true
assert.True(t, n.Online)
} }
assert.True(t, names["node-a"]) assert.True(t, names[nodeA])
assert.True(t, names["node-b"]) assert.True(t, names[nodeB])
} }
func TestNodeForWorkspace(t *testing.T) { func TestNodeForWorkspace(t *testing.T) {
@ -282,29 +279,17 @@ func TestNodeForWorkspace_NotFound(t *testing.T) {
} }
} }
func TestAddPool(t *testing.T) { func TestRegistryDrivenNodes(t *testing.T) {
for _, pc := range testPools() { m, nodeA, nodeB := setupManagerMultiNode(t)
t.Run(pc.Name, func(t *testing.T) { nodes := m.Nodes()
m := setupManagerForPool(t, pc) assert.GreaterOrEqual(t, len(nodes), 2)
assert.Len(t, m.Nodes(), 1)
vol := volume.NewLocal(t.TempDir()) names := make(map[string]bool)
client, err := tai.New("local", tai.WithVolume(vol)) for _, n := range nodes {
require.NoError(t, err) names[n.Name] = true
defer client.Close()
m.AddPool("new-node", client)
assert.Len(t, m.Nodes(), 2)
})
} }
} assert.True(t, names[nodeA])
assert.True(t, names[nodeB])
func TestRemovePool(t *testing.T) {
m := setupManagerMultiNode(t)
assert.Len(t, m.Nodes(), 2)
m.RemovePool("node-b")
assert.Len(t, m.Nodes(), 1)
} }
func TestMountPath(t *testing.T) { func TestMountPath(t *testing.T) {