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