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