Add kubeconfig generation for Tai K8s in CI workflows
- Implement steps to generate kubeconfig for both the Tai K8s container and the test runner, ensuring proper configuration for Kubernetes interactions. - Update the Docker run command to mount the generated kubeconfig, enhancing the integration of Tai with K8s. - Remove the previous kubeconfig generation step for the Tai K8s proxy, streamlining the workflow. These changes improve the CI workflows for the Tai service by ensuring accurate kubeconfig generation and integration with Kubernetes environments.
This commit is contained in:
parent
c797fcf74d
commit
b7eb0e81e3
9 changed files with 768 additions and 220 deletions
29
.github/workflows/pr-test.yml
vendored
29
.github/workflows/pr-test.yml
vendored
|
|
@ -1112,6 +1112,25 @@ jobs:
|
||||||
echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1
|
echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- name: Generate kubeconfig for Tai K8s
|
||||||
|
run: |
|
||||||
|
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
|
||||||
|
echo "k3d server IP: ${K3D_IP}"
|
||||||
|
|
||||||
|
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
|
||||||
|
|
||||||
|
# Kubeconfig for tai-k8s container (uses k3d-internal IP)
|
||||||
|
sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d.yml \
|
||||||
|
> /tmp/kubeconfig-tai-k8s.yml
|
||||||
|
echo "Container kubeconfig server:"
|
||||||
|
grep server: /tmp/kubeconfig-tai-k8s.yml
|
||||||
|
|
||||||
|
# Kubeconfig for test runner (uses localhost via port-mapped 6443)
|
||||||
|
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \
|
||||||
|
> ${{ runner.temp }}/kubeconfig-tai.yml
|
||||||
|
echo "Test runner kubeconfig server:"
|
||||||
|
grep server: ${{ runner.temp }}/kubeconfig-tai.yml
|
||||||
|
|
||||||
- name: Start Tai K8s instance
|
- name: Start Tai K8s instance
|
||||||
run: |
|
run: |
|
||||||
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
|
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
|
||||||
|
|
@ -1120,7 +1139,9 @@ jobs:
|
||||||
docker run -d --name tai-k8s \
|
docker run -d --name tai-k8s \
|
||||||
--network k3d-tai-test \
|
--network k3d-tai-test \
|
||||||
-p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \
|
-p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \
|
||||||
|
-v /tmp/kubeconfig-tai-k8s.yml:/etc/tai/kubeconfig.yml:ro \
|
||||||
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
|
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
|
||||||
|
-e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \
|
||||||
yaoapp/tai:latest
|
yaoapp/tai:latest
|
||||||
|
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
|
|
@ -1143,14 +1164,6 @@ jobs:
|
||||||
echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1
|
echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Generate kubeconfig for Tai K8s proxy
|
|
||||||
run: |
|
|
||||||
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
|
|
||||||
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \
|
|
||||||
> ${{ runner.temp }}/kubeconfig-tai.yml
|
|
||||||
echo "Generated kubeconfig:"
|
|
||||||
grep server: ${{ runner.temp }}/kubeconfig-tai.yml
|
|
||||||
|
|
||||||
- name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace)
|
- name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace)
|
||||||
env:
|
env:
|
||||||
TAI_TEST_HOST: "127.0.0.1"
|
TAI_TEST_HOST: "127.0.0.1"
|
||||||
|
|
|
||||||
29
.github/workflows/unit-test.yml
vendored
29
.github/workflows/unit-test.yml
vendored
|
|
@ -820,6 +820,25 @@ jobs:
|
||||||
echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1
|
echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- name: Generate kubeconfig for Tai K8s
|
||||||
|
run: |
|
||||||
|
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
|
||||||
|
echo "k3d server IP: ${K3D_IP}"
|
||||||
|
|
||||||
|
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
|
||||||
|
|
||||||
|
# Kubeconfig for tai-k8s container (uses k3d-internal IP)
|
||||||
|
sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d.yml \
|
||||||
|
> /tmp/kubeconfig-tai-k8s.yml
|
||||||
|
echo "Container kubeconfig server:"
|
||||||
|
grep server: /tmp/kubeconfig-tai-k8s.yml
|
||||||
|
|
||||||
|
# Kubeconfig for test runner (uses localhost via port-mapped 6443)
|
||||||
|
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \
|
||||||
|
> ${{ runner.temp }}/kubeconfig-tai.yml
|
||||||
|
echo "Test runner kubeconfig server:"
|
||||||
|
grep server: ${{ runner.temp }}/kubeconfig-tai.yml
|
||||||
|
|
||||||
- name: Start Tai K8s instance
|
- name: Start Tai K8s instance
|
||||||
run: |
|
run: |
|
||||||
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
|
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
|
||||||
|
|
@ -828,7 +847,9 @@ jobs:
|
||||||
docker run -d --name tai-k8s \
|
docker run -d --name tai-k8s \
|
||||||
--network k3d-tai-test \
|
--network k3d-tai-test \
|
||||||
-p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \
|
-p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \
|
||||||
|
-v /tmp/kubeconfig-tai-k8s.yml:/etc/tai/kubeconfig.yml:ro \
|
||||||
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
|
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
|
||||||
|
-e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \
|
||||||
yaoapp/tai:latest
|
yaoapp/tai:latest
|
||||||
|
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
|
|
@ -851,14 +872,6 @@ jobs:
|
||||||
echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1
|
echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Generate kubeconfig for Tai K8s proxy
|
|
||||||
run: |
|
|
||||||
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
|
|
||||||
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \
|
|
||||||
> ${{ runner.temp }}/kubeconfig-tai.yml
|
|
||||||
echo "Generated kubeconfig:"
|
|
||||||
grep server: ${{ runner.temp }}/kubeconfig-tai.yml
|
|
||||||
|
|
||||||
- name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace)
|
- name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace)
|
||||||
env:
|
env:
|
||||||
TAI_TEST_HOST: "127.0.0.1"
|
TAI_TEST_HOST: "127.0.0.1"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ Sandbox does NOT import or depend on Agent. Agent is one of many consumers.
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────┐
|
||||||
│ Consumers (know nothing about tai/Docker/K8s) │
|
│ Consumers (know nothing about tai/Docker/K8s) │
|
||||||
│ ├── JSAPI: Sandbox("my-app") │
|
│ ├── JSAPI: sandbox.Create/Get/List/Delete │
|
||||||
|
│ ├── JSAPI: workspace.Create/Get/List/Delete │
|
||||||
│ ├── Process: sandbox.Create, sandbox.Exec │
|
│ ├── Process: sandbox.Create, sandbox.Exec │
|
||||||
│ ├── Agent: uses sandbox via interface │
|
│ ├── Agent: uses sandbox via interface │
|
||||||
│ └── API: /api/__yao/sandbox/* │
|
│ └── API: /api/__yao/sandbox/* │
|
||||||
|
|
@ -576,8 +577,9 @@ sandbox/v2/
|
||||||
├── config.go // Config struct
|
├── config.go // Config struct
|
||||||
├── errors.go // sentinel errors
|
├── errors.go // sentinel errors
|
||||||
├── grpc.go // token creation/revocation, gRPC env var injection
|
├── grpc.go // token creation/revocation, gRPC env var injection
|
||||||
├── jsapi/ // (Phase 2) V8 JSAPI Sandbox() constructor
|
├── jsapi/ // (Phase 2) V8 JSAPI sandbox.* namespace
|
||||||
│ └── sandbox.go
|
│ ├── jsapi.go // RegisterObject("sandbox"), Create/Get/List/Delete
|
||||||
|
│ └── box.go // Box JS object: Exec/Attach/VNC/Proxy/Workspace/Info/Start/Stop/Remove
|
||||||
├── export_test.go // ResetForTest() for test isolation
|
├── export_test.go // ResetForTest() for test isolation
|
||||||
├── testutils_test.go // shared test helpers (multi-pool setup)
|
├── testutils_test.go // shared test helpers (multi-pool setup)
|
||||||
├── sandbox_test.go // Init/M singleton tests
|
├── sandbox_test.go // Init/M singleton tests
|
||||||
|
|
@ -729,6 +731,9 @@ workspace/
|
||||||
├── workspace.go // types, metadata marshal/unmarshal
|
├── workspace.go // types, metadata marshal/unmarshal
|
||||||
├── manager.go // Manager: CRUD, file I/O, node management
|
├── manager.go // Manager: CRUD, file I/O, node management
|
||||||
├── errors.go // sentinel errors
|
├── errors.go // sentinel errors
|
||||||
|
├── jsapi/ // (Phase 2) V8 JSAPI workspace.* namespace
|
||||||
|
│ ├── jsapi.go // RegisterObject("workspace"), Create/Get/List/Delete
|
||||||
|
│ └── fs.go // WorkspaceFS JS object: ReadFile/WriteFile/ReadDir/Stat/MkdirAll/Remove/RemoveAll/Rename
|
||||||
├── testutils_test.go // shared test helpers
|
├── testutils_test.go // shared test helpers
|
||||||
├── workspace_test.go // CRUD tests (Create/Get/List/Update/Delete/Nodes)
|
├── workspace_test.go // CRUD tests (Create/Get/List/Update/Delete/Nodes)
|
||||||
├── fileio_test.go // File I/O + fs.FS tests
|
├── fileio_test.go // File I/O + fs.FS tests
|
||||||
|
|
@ -824,12 +829,269 @@ Docker `StopStart` ~2.2s is expected: `DefaultStopTimeout = 2s` and Docker waits
|
||||||
- Tests: unit + integration + benchmarks
|
- Tests: unit + integration + benchmarks
|
||||||
- CI: consolidated SandboxV2Test + BenchmarkSandboxV2
|
- CI: consolidated SandboxV2Test + BenchmarkSandboxV2
|
||||||
|
|
||||||
## Phase 2: JSAPI + OAuth (PENDING)
|
## Phase 2: JSAPI + OAuth + Auth (PENDING)
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
| Task | Detail |
|
| Task | Detail |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `sandbox/v2/jsapi/` | V8 `Sandbox()` / `Workspace()` constructors (registered in gou runtime) |
|
|
||||||
| Wire `openapi/oauth` | `grpc.go` currently uses random token placeholders; replace with real OAuth issue/revoke |
|
| Wire `openapi/oauth` | `grpc.go` currently uses random token placeholders; replace with real OAuth issue/revoke |
|
||||||
|
|
||||||
|
### JSAPI Design
|
||||||
|
|
||||||
|
All JSAPI methods are static — no constructors, no Go objects in V8, no bridge/Release.
|
||||||
|
JS objects only hold string IDs, delegate everything to Go singletons (`sandbox.M()`, `workspace.M()`).
|
||||||
|
|
||||||
|
#### sandbox namespace (`RegisterObject("sandbox")`)
|
||||||
|
|
||||||
|
Static methods:
|
||||||
|
|
||||||
|
| JS | Go | Returns |
|
||||||
|
|----|-----|---------|
|
||||||
|
| `sandbox.Create(opts)` | `Manager.Create(ctx, CreateOptions)` | `Box` |
|
||||||
|
| `sandbox.Create(opts)` (opts.id set) | `Manager.GetOrCreate(ctx, CreateOptions)` | `Box` |
|
||||||
|
| `sandbox.Get(id)` | `Manager.Get(ctx, id)` | `Box \| null` |
|
||||||
|
| `sandbox.List(filter?)` | `Manager.List(ctx, ListOptions)` → `Box.Info()` | `BoxInfo[]` |
|
||||||
|
| `sandbox.Delete(id)` | `Manager.Remove(ctx, id)` | `void` |
|
||||||
|
|
||||||
|
`sandbox.Create(options)` — JS options → Go `CreateOptions`:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
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
|
||||||
|
vnc: boolean → CreateOptions.VNC
|
||||||
|
ports: array → CreateOptions.Ports // [{container, host, 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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`sandbox.List(filter?)` — JS filter → Go `ListOptions`:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
owner: string → ListOptions.Owner // empty = all
|
||||||
|
pool: string → ListOptions.Pool // empty = all
|
||||||
|
labels: object → ListOptions.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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Box object
|
||||||
|
|
||||||
|
Read-only properties:
|
||||||
|
|
||||||
|
| JS | Go |
|
||||||
|
|----|----|
|
||||||
|
| `box.id` | `Box.ID()` |
|
||||||
|
| `box.owner` | `Box.Owner()` |
|
||||||
|
| `box.pool` | `Box.Pool()` |
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
|
||||||
|
| JS | Go | Returns |
|
||||||
|
|----|-----|---------|
|
||||||
|
| `box.Exec(cmd, opts?)` | `Box.Exec(ctx, cmd, ...ExecOption)` | `ExecResult` |
|
||||||
|
| `box.Stream(cmd, opts?)` | `Box.Stream(ctx, cmd, ...ExecOption)` | `ExecStream` |
|
||||||
|
| `box.Attach(port, opts?)` | `Box.Attach(ctx, port, ...AttachOption)` | `ServiceConn` |
|
||||||
|
| `box.VNC()` | `Box.VNC(ctx)` | `string` |
|
||||||
|
| `box.Proxy(port, path?)` | `Box.Proxy(ctx, port, path)` | `string` |
|
||||||
|
| `box.Workspace()` | `Box.WorkspaceID()` → `NewFSObject` | `WorkspaceFS` |
|
||||||
|
| `box.Info()` | `Box.Info(ctx)` | `BoxInfo` |
|
||||||
|
| `box.Start()` | `Box.Start(ctx)` | `void` |
|
||||||
|
| `box.Stop()` | `Box.Stop(ctx)` | `void` |
|
||||||
|
| `box.Remove()` | `Box.Remove(ctx)` | `void` |
|
||||||
|
|
||||||
|
`box.Exec(cmd, options?)`:
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd: string[] → cmd []string
|
||||||
|
options: {
|
||||||
|
workdir: string, → WithWorkDir(dir)
|
||||||
|
env: object, → WithEnv(map[string]string)
|
||||||
|
timeout: number → WithTimeout(ms → time.Duration)
|
||||||
|
}
|
||||||
|
returns: {
|
||||||
|
exit_code: number, ← ExecResult.ExitCode
|
||||||
|
stdout: string, ← ExecResult.Stdout
|
||||||
|
stderr: string ← ExecResult.Stderr
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`box.Stream(cmd, options?)`:
|
||||||
|
|
||||||
|
```
|
||||||
|
options: same as Exec
|
||||||
|
returns: {
|
||||||
|
stdout: ReadableStream, ← ExecStream.Stdout
|
||||||
|
stderr: ReadableStream, ← ExecStream.Stderr
|
||||||
|
stdin: WritableStream, ← ExecStream.Stdin
|
||||||
|
wait: function() → number, ← ExecStream.Wait() (int, error)
|
||||||
|
cancel: function() → void ← ExecStream.Cancel()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`box.Attach(port, options?)`:
|
||||||
|
|
||||||
|
```
|
||||||
|
port: number → port int
|
||||||
|
options: {
|
||||||
|
protocol: "ws"|"sse", → WithProtocol(protocol)
|
||||||
|
path: string, → WithPath(path)
|
||||||
|
headers: object → WithHeaders(map[string]string)
|
||||||
|
}
|
||||||
|
returns: {
|
||||||
|
url: string, ← ServiceConn.URL
|
||||||
|
read: function() → Uint8Array, ← ServiceConn.Read()
|
||||||
|
write: function(data) → void, ← ServiceConn.Write(data)
|
||||||
|
events: AsyncIterable<Uint8Array>, ← ServiceConn.Events
|
||||||
|
close: function() → void ← ServiceConn.Close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`box.Info()` returns same structure as `BoxInfo[]` element above.
|
||||||
|
|
||||||
|
#### workspace namespace (`RegisterObject("workspace")`)
|
||||||
|
|
||||||
|
Static methods:
|
||||||
|
|
||||||
|
| JS | Go | Returns |
|
||||||
|
|----|-----|---------|
|
||||||
|
| `workspace.Create(opts)` | `Manager.Create(ctx, CreateOptions)` | `WorkspaceFS` |
|
||||||
|
| `workspace.Get(id)` | `Manager.Get(ctx, id)` | `WorkspaceFS \| null` |
|
||||||
|
| `workspace.List(filter?)` | `Manager.List(ctx, ListOptions)` | `WorkspaceInfo[]` |
|
||||||
|
| `workspace.Delete(id)` | `Manager.Delete(ctx, id, false)` | `void` |
|
||||||
|
|
||||||
|
`workspace.Create(options)` — JS options → Go `CreateOptions`:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
id: string → CreateOptions.ID // optional; auto-generated if empty
|
||||||
|
name: string → CreateOptions.Name // required
|
||||||
|
owner: string → CreateOptions.Owner // required
|
||||||
|
node: string → CreateOptions.Node // required
|
||||||
|
labels: object → CreateOptions.Labels // map[string]string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`workspace.List(filter?)` — JS filter → Go `ListOptions`:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
owner: string → ListOptions.Owner // empty = all
|
||||||
|
node: string → ListOptions.Node // empty = all
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `WorkspaceInfo[]` — each element:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
id: string ← Workspace.ID
|
||||||
|
name: string ← Workspace.Name
|
||||||
|
owner: string ← Workspace.Owner
|
||||||
|
node: string ← Workspace.Node
|
||||||
|
labels: object ← Workspace.Labels
|
||||||
|
created_at: string ← Workspace.CreatedAt (ISO 8601)
|
||||||
|
updated_at: string ← Workspace.UpdatedAt (ISO 8601)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### WorkspaceFS object
|
||||||
|
|
||||||
|
Read-only properties:
|
||||||
|
|
||||||
|
| JS | Go |
|
||||||
|
|----|----|
|
||||||
|
| `ws.id` | workspace ID |
|
||||||
|
| `ws.name` | `Workspace.Name` |
|
||||||
|
| `ws.node` | `Workspace.Node` |
|
||||||
|
|
||||||
|
Methods (1:1 to Go `taiworkspace.FS` + `Manager` shortcuts):
|
||||||
|
|
||||||
|
| JS | Go | Returns |
|
||||||
|
|----|-----|---------|
|
||||||
|
| `ws.ReadFile(path)` | `FS.ReadFile(name)` / `Manager.ReadFile(ctx, id, path)` | `string` |
|
||||||
|
| `ws.WriteFile(path, data, perm?)` | `FS.WriteFile(name, data, perm)` / `Manager.WriteFile(ctx, id, path, data, perm)` | `void` |
|
||||||
|
| `ws.ReadDir(path?)` | `FS.ReadDir(name)` / `Manager.ListDir(ctx, id, path)` | `DirEntry[]` |
|
||||||
|
| `ws.Stat(path)` | `FS.Stat(name)` | `FileInfo` |
|
||||||
|
| `ws.MkdirAll(path, perm?)` | `FS.MkdirAll(name, perm)` | `void` |
|
||||||
|
| `ws.Remove(path)` | `FS.Remove(name)` / `Manager.Remove(ctx, id, path)` | `void` |
|
||||||
|
| `ws.RemoveAll(path)` | `FS.RemoveAll(name)` | `void` |
|
||||||
|
| `ws.Rename(from, to)` | `FS.Rename(old, new)` | `void` |
|
||||||
|
|
||||||
|
Planned (not yet implemented):
|
||||||
|
|
||||||
|
| JS | Go | Returns | Note |
|
||||||
|
|----|-----|---------|------|
|
||||||
|
| `ws.ReadFileBase64(path)` | `FS.ReadFile` → `base64.StdEncoding.EncodeToString` | `string` | Avoids V8↔Go binary bridge overhead for images, archives, etc. |
|
||||||
|
| `ws.WriteFileBase64(path, b64, perm?)` | `base64.StdEncoding.DecodeString` → `FS.WriteFile` | `void` | Same — base64 string transfer is far more efficient than Uint8Array across the bridge |
|
||||||
|
| `ws.CopyFromHost(hostPath, destPath?)` | Host `os.Read` → `FS.WriteFile` / `FS.MkdirAll` per entry | `void` | Copy file/dir from Yao host into workspace; `destPath` defaults to basename |
|
||||||
|
| `ws.CopyFromHostArchive(hostPath, destPath?)` | Zip on host → Tai Volume upload → Tai-side unarchive | `void` | For large directory trees; requires Tai server-side unarchive support |
|
||||||
|
|
||||||
|
Return types:
|
||||||
|
|
||||||
|
```
|
||||||
|
DirEntry: { name: string, is_dir: boolean, size: number }
|
||||||
|
FileInfo: { name: string, size: number, is_dir: boolean, mod_time: string (ISO 8601) }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
JSAPI does not enforce permissions internally. The Go Manager methods execute operations directly without owner/admin checks.
|
||||||
|
|
||||||
|
Developers retrieve the current caller identity via the gou global `Authorized()` function (registered by `gou/runtime/v8/functions/authorized`, reads from `bridge.Share.Authorized` / `__yao_data.AUTHORIZED`) and implement permission logic in their JS scripts.
|
||||||
|
|
||||||
|
`Authorized()` returns `map[string]interface{}` (or null if not set). The exact fields depend on what the caller sets via `Context.WithAuthorized()`. There is no fixed schema — typical fields include `user_id`, `team_id`, `scope`, etc.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const auth = Authorized() // gou global — returns caller info or null
|
||||||
|
const box = sandbox.Get(id)
|
||||||
|
// Developer decides permission logic — fields depend on application's auth setup
|
||||||
|
if (box.owner !== auth.user_id) {
|
||||||
|
throw new Error("permission denied")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Permission control is the responsibility of the caller (JS scripts, Agent hooks, API middleware, etc.).
|
||||||
|
|
||||||
|
### Implementation Tasks
|
||||||
|
|
||||||
|
| Task | Detail |
|
||||||
|
|------|--------|
|
||||||
|
| `sandbox/v2/jsapi/` | `RegisterObject("sandbox")` with Create/Get/List/Delete + Box object |
|
||||||
|
| `workspace/jsapi/` | `RegisterObject("workspace")` with Create/Get/List/Delete + FS object |
|
||||||
| Integration with `cmd/start.go` | Call `sandbox.Init()` + `sandbox.M().Start()` |
|
| Integration with `cmd/start.go` | Call `sandbox.Init()` + `sandbox.M().Start()` |
|
||||||
|
|
||||||
## Phase 3: Agent Integration (PENDING)
|
## Phase 3: Agent Integration (PENDING)
|
||||||
|
|
|
||||||
|
|
@ -4,49 +4,126 @@ import (
|
||||||
"rogchap.com/v8go"
|
"rogchap.com/v8go"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewBoxObject creates a JS Box object with the following methods:
|
// NewBoxObject creates a JS Box object backed by a sandbox ID string.
|
||||||
|
// All methods delegate to the Go sandbox.M() singleton — no Go object is
|
||||||
|
// passed to V8, no bridge registration, no Release() needed.
|
||||||
//
|
//
|
||||||
// box.ID() → string // sandbox ID
|
// # Properties (read-only)
|
||||||
// box.Owner() → string // owner
|
|
||||||
// box.ContainerID() → string // underlying container/pod ID
|
|
||||||
// box.Pool() → string // pool name
|
|
||||||
// box.WorkspaceID() → string // mounted workspace ID (empty if none)
|
|
||||||
//
|
//
|
||||||
// box.Exec(cmd, options?) → ExecResult // run command, wait for completion
|
// box.id → string // sandbox ID ← Box.ID()
|
||||||
// cmd: string[] // command + args
|
// box.owner → string // owner user ID ← Box.Owner()
|
||||||
// options: { workdir, env, timeout }
|
// box.pool → string // pool name ← Box.Pool()
|
||||||
// returns: { exit_code: number, stdout: string, stderr: string }
|
|
||||||
//
|
//
|
||||||
// box.Stream(cmd, options?) → ExecStream // streaming I/O
|
// # Methods — Go mapping
|
||||||
// returns: { stdout: ReadableStream, stderr: ReadableStream,
|
|
||||||
// stdin: WritableStream, wait: ()=>number, cancel: ()=>void }
|
|
||||||
//
|
//
|
||||||
// box.Attach(port, options?) → ServiceConn // WebSocket/SSE attach
|
// box.Exec(cmd, options?) → ExecResult
|
||||||
// port: number // container port
|
|
||||||
// options: { protocol, path, headers }
|
|
||||||
// returns: { url: string, close: ()=>void }
|
|
||||||
//
|
//
|
||||||
// box.VNC() → string // VNC WebSocket URL
|
// Go: Box.Exec(ctx, cmd []string, opts ...ExecOption) (*ExecResult, error)
|
||||||
// box.Proxy(port, path?) → string // HTTP proxy URL
|
|
||||||
//
|
//
|
||||||
// box.Workspace() → WorkspaceFS // workspace file system
|
// JS args:
|
||||||
// returns WorkspaceFS object (see workspace/jsapi)
|
// cmd: string[] → cmd []string
|
||||||
|
// options: { → ExecOption functional options
|
||||||
|
// workdir: string, → WithWorkDir(dir)
|
||||||
|
// env: object, → WithEnv(map[string]string)
|
||||||
|
// timeout: number → WithTimeout(ms → time.Duration)
|
||||||
|
// }
|
||||||
|
// JS returns: {
|
||||||
|
// exit_code: number, ← ExecResult.ExitCode
|
||||||
|
// stdout: string, ← ExecResult.Stdout
|
||||||
|
// stderr: string ← ExecResult.Stderr
|
||||||
|
// }
|
||||||
//
|
//
|
||||||
// box.Info() → BoxInfo // container status
|
// box.Stream(cmd, options?) → ExecStream
|
||||||
// returns: { id, container_id, pool, owner, status, policy,
|
|
||||||
// labels, image, created_at, last_active, process_count, vnc }
|
|
||||||
//
|
//
|
||||||
// box.Start() → void // start stopped box
|
// Go: Box.Stream(ctx, cmd []string, opts ...ExecOption) (*ExecStream, error)
|
||||||
// box.Stop() → void // stop running box
|
//
|
||||||
// box.Remove() → void // remove box permanently
|
// JS returns: {
|
||||||
// box.Release() → void // release JS bridge ref
|
// stdout: ReadableStream, ← ExecStream.Stdout
|
||||||
func NewBoxObject(v8ctx *v8go.Context /* , box *sandbox.Box */) (*v8go.Value, error) {
|
// stderr: ReadableStream, ← ExecStream.Stderr
|
||||||
|
// stdin: WritableStream, ← ExecStream.Stdin
|
||||||
|
// wait: function() → number, ← ExecStream.Wait() (int, error)
|
||||||
|
// cancel: function() → void ← ExecStream.Cancel()
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// box.Attach(port, options?) → ServiceConn
|
||||||
|
//
|
||||||
|
// Go: Box.Attach(ctx, port int, opts ...AttachOption) (*ServiceConn, error)
|
||||||
|
//
|
||||||
|
// JS args:
|
||||||
|
// port: number → port int
|
||||||
|
// options: { → AttachOption functional options
|
||||||
|
// protocol: "ws"|"sse", → WithProtocol(protocol)
|
||||||
|
// path: string, → WithPath(path)
|
||||||
|
// headers: object → WithHeaders(map[string]string)
|
||||||
|
// }
|
||||||
|
// JS returns: {
|
||||||
|
// url: string, ← ServiceConn.URL
|
||||||
|
// read: function() → Uint8Array, ← ServiceConn.Read() ([]byte, error)
|
||||||
|
// write: function(data) → void, ← ServiceConn.Write(data) error
|
||||||
|
// events: AsyncIterable<Uint8Array>, ← ServiceConn.Events <-chan []byte
|
||||||
|
// close: function() → void ← ServiceConn.Close() error
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// box.VNC() → string
|
||||||
|
//
|
||||||
|
// Go: Box.VNC(ctx) (string, error)
|
||||||
|
// Returns: VNC WebSocket URL
|
||||||
|
//
|
||||||
|
// box.Proxy(port, path?) → string
|
||||||
|
//
|
||||||
|
// Go: Box.Proxy(ctx, port int, path string) (string, error)
|
||||||
|
// Returns: HTTP proxy URL
|
||||||
|
//
|
||||||
|
// box.Workspace() → WorkspaceFS
|
||||||
|
//
|
||||||
|
// Go: Box.Workspace() workspace.FS
|
||||||
|
// Box.WorkspaceID() string
|
||||||
|
// Returns: WorkspaceFS object (see workspace/jsapi/fs.go)
|
||||||
|
// Uses box.WorkspaceID() to create NewFSObject
|
||||||
|
//
|
||||||
|
// box.Info() → BoxInfo
|
||||||
|
//
|
||||||
|
// Go: Box.Info(ctx) (*BoxInfo, error)
|
||||||
|
// JS returns: {
|
||||||
|
// 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 (LifecyclePolicy)
|
||||||
|
// labels: object, ← BoxInfo.Labels (map[string]string)
|
||||||
|
// created_at: string, ← BoxInfo.CreatedAt (ISO 8601)
|
||||||
|
// last_active: string, ← BoxInfo.LastActive (ISO 8601)
|
||||||
|
// process_count: number ← BoxInfo.ProcessCount
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// box.Start() → void
|
||||||
|
//
|
||||||
|
// Go: Box.Start(ctx) error
|
||||||
|
//
|
||||||
|
// box.Stop() → void
|
||||||
|
//
|
||||||
|
// Go: Box.Stop(ctx) error
|
||||||
|
//
|
||||||
|
// box.Remove() → void
|
||||||
|
//
|
||||||
|
// Go: Box.Remove(ctx) error
|
||||||
|
func NewBoxObject(v8ctx *v8go.Context, boxID string) (*v8go.Value, error) {
|
||||||
// TODO: Phase 2 implementation
|
// TODO: Phase 2 implementation
|
||||||
// 1. Create ObjectTemplate with InternalFieldCount(1)
|
// 1. Create JS object via v8go.NewObjectTemplate
|
||||||
// 2. Register box in bridge
|
// 2. Set read-only properties: id, owner, pool (from sandbox.M().Get(boxID))
|
||||||
// 3. Bind property accessors: ID, Owner, ContainerID, Pool, WorkspaceID
|
// 3. Bind each method as FunctionTemplate:
|
||||||
// 4. Bind methods: Exec, Stream, Attach, VNC, Proxy, Workspace,
|
// - Exec → sandbox.M().Get(id).Exec(ctx, cmd, opts...)
|
||||||
// Info, Start, Stop, Remove, Release
|
// - Stream → sandbox.M().Get(id).Stream(ctx, cmd, opts...)
|
||||||
// 5. Create instance, set internal field
|
// - Attach → sandbox.M().Get(id).Attach(ctx, port, opts...)
|
||||||
|
// - VNC → sandbox.M().Get(id).VNC(ctx)
|
||||||
|
// - Proxy → sandbox.M().Get(id).Proxy(ctx, port, path)
|
||||||
|
// - Workspace → NewFSObject(v8ctx, sandbox.M().Get(id).WorkspaceID())
|
||||||
|
// - Info → sandbox.M().Get(id).Info(ctx) → JS object
|
||||||
|
// - Start → sandbox.M().Get(id).Start(ctx)
|
||||||
|
// - Stop → sandbox.M().Get(id).Stop(ctx)
|
||||||
|
// - Remove → sandbox.M().Get(id).Remove(ctx)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,24 @@
|
||||||
// Package jsapi registers the Sandbox() constructor into the Yao V8 runtime.
|
// Package jsapi registers the sandbox namespace into the Yao V8 runtime.
|
||||||
|
//
|
||||||
|
// All methods are static on the sandbox object — no constructor.
|
||||||
//
|
//
|
||||||
// # JavaScript API
|
// # JavaScript API
|
||||||
//
|
//
|
||||||
// const sb = new Sandbox({ pool: "default", image: "node:20", owner: "user1" })
|
// const box = sandbox.Create({ image: "node:20", owner: "user1" })
|
||||||
// const box = sb.Create({ workdir: "/app", env: { NODE_ENV: "dev" } })
|
|
||||||
// const result = box.Exec(["node", "-e", "console.log('hi')"])
|
// const result = box.Exec(["node", "-e", "console.log('hi')"])
|
||||||
// box.Remove()
|
// console.log(result.stdout)
|
||||||
//
|
//
|
||||||
// The constructor returns a SandboxManager object; Create/GetOrCreate returns
|
// const box = sandbox.Get(id) // → Box
|
||||||
// a Box object with Exec/Stream/Attach/VNC/Proxy/Workspace/Info/Stop/Start/Remove.
|
// const list = sandbox.List({ owner: "u1" }) // → BoxInfo[]
|
||||||
|
// sandbox.Delete(id) // → void
|
||||||
|
//
|
||||||
|
// # Go mapping
|
||||||
|
//
|
||||||
|
// sandbox.Create(opts) → Manager.Create(ctx, CreateOptions) → Box
|
||||||
|
// sandbox.Create(opts) → Manager.GetOrCreate(ctx, opts) → Box (when opts.id is set)
|
||||||
|
// sandbox.Get(id) → Manager.Get(ctx, id) → Box
|
||||||
|
// sandbox.List(filter?) → Manager.List(ctx, ListOptions) → []*Box → BoxInfo[]
|
||||||
|
// sandbox.Delete(id) → Manager.Remove(ctx, id) → void
|
||||||
//
|
//
|
||||||
// Registration happens via init() — import with:
|
// Registration happens via init() — import with:
|
||||||
//
|
//
|
||||||
|
|
@ -21,30 +31,125 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
v8.RegisterFunction("Sandbox", ExportFunction)
|
v8.RegisterObject("sandbox", ExportObject)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportFunction exports the Sandbox constructor to V8.
|
// ExportObject exports the sandbox namespace object to V8.
|
||||||
func ExportFunction(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
func ExportObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
|
||||||
return v8go.NewFunctionTemplate(iso, sandboxConstructor)
|
obj := v8go.NewObjectTemplate(iso)
|
||||||
|
obj.Set("Create", v8go.NewFunctionTemplate(iso, sbCreate))
|
||||||
|
obj.Set("Get", v8go.NewFunctionTemplate(iso, sbGet))
|
||||||
|
obj.Set("List", v8go.NewFunctionTemplate(iso, sbList))
|
||||||
|
obj.Set("Delete", v8go.NewFunctionTemplate(iso, sbDelete))
|
||||||
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
// sandboxConstructor is called when JS executes `new Sandbox(options)`.
|
// sbCreate: `sandbox.Create(options)` → Box
|
||||||
//
|
//
|
||||||
// Options:
|
// Go: Manager.Create(ctx, CreateOptions) (*Box, error)
|
||||||
|
//
|
||||||
|
// Manager.GetOrCreate(ctx, CreateOptions) (*Box, error) — when opts.id is set
|
||||||
|
//
|
||||||
|
// JS options → Go CreateOptions mapping:
|
||||||
//
|
//
|
||||||
// {
|
// {
|
||||||
// pool: string // pool name (required)
|
// id: string → CreateOptions.ID // optional; triggers GetOrCreate
|
||||||
// image: string // container image (required)
|
// owner: string → CreateOptions.Owner // required
|
||||||
// owner: string // owner ID (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, host, 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 a SandboxManager JS object.
|
// Returns: Box object (see box.go)
|
||||||
func sandboxConstructor(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
func sbCreate(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
// TODO: Phase 2 implementation
|
// TODO: Phase 2
|
||||||
// 1. Parse options from args[0]
|
// 1. Parse options from info.Args()[0]
|
||||||
// 2. Validate required fields (pool, image, owner)
|
// 2. Validate required fields (image, owner)
|
||||||
// 3. Get sandbox.M() singleton
|
// 3. If opts.id != "" → sandbox.M().GetOrCreate(ctx, opts)
|
||||||
// 4. Return NewManagerObject(v8ctx, manager, options)
|
// else → sandbox.M().Create(ctx, opts)
|
||||||
|
// 4. Return NewBoxObject(v8ctx, box.ID())
|
||||||
|
return v8go.Undefined(info.Context().Isolate())
|
||||||
|
}
|
||||||
|
|
||||||
|
// sbGet: `sandbox.Get(id)` → Box | null
|
||||||
|
//
|
||||||
|
// Go: Manager.Get(ctx, id) (*Box, error)
|
||||||
|
//
|
||||||
|
// Args:
|
||||||
|
//
|
||||||
|
// id: string — sandbox ID
|
||||||
|
//
|
||||||
|
// Returns: Box object if found, null if not found
|
||||||
|
func sbGet(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
// TODO: Phase 2
|
||||||
|
// 1. id = info.Args()[0].String()
|
||||||
|
// 2. box, err := sandbox.M().Get(ctx, id)
|
||||||
|
// 3. Return NewBoxObject(v8ctx, id) or null
|
||||||
|
return v8go.Undefined(info.Context().Isolate())
|
||||||
|
}
|
||||||
|
|
||||||
|
// sbList: `sandbox.List(filter?)` → BoxInfo[]
|
||||||
|
//
|
||||||
|
// Go: Manager.List(ctx, ListOptions) ([]*Box, error)
|
||||||
|
//
|
||||||
|
// then Box.Info(ctx) for each → BoxInfo
|
||||||
|
//
|
||||||
|
// JS filter → Go ListOptions mapping:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// owner: string → ListOptions.Owner // filter by owner; empty = all
|
||||||
|
// pool: string → ListOptions.Pool // filter by pool; empty = all
|
||||||
|
// labels: object → ListOptions.Labels // filter by labels
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Returns: BoxInfo[] — each element:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// id: string ← BoxInfo.ID
|
||||||
|
// container_id: string ← BoxInfo.ContainerID
|
||||||
|
// pool: string ← BoxInfo.Pool
|
||||||
|
// owner: string ← BoxInfo.Owner
|
||||||
|
// status: string ← BoxInfo.Status
|
||||||
|
// image: string ← BoxInfo.Image
|
||||||
|
// vnc: boolean ← BoxInfo.VNC
|
||||||
|
// policy: string ← BoxInfo.Policy
|
||||||
|
// labels: object ← BoxInfo.Labels
|
||||||
|
// created_at: string ← BoxInfo.CreatedAt (ISO 8601)
|
||||||
|
// last_active: string ← BoxInfo.LastActive (ISO 8601)
|
||||||
|
// process_count: number ← BoxInfo.ProcessCount
|
||||||
|
// }
|
||||||
|
func sbList(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
// TODO: Phase 2
|
||||||
|
// 1. Parse optional filter from info.Args()[0]
|
||||||
|
// 2. boxes := sandbox.M().List(ctx, opts)
|
||||||
|
// 3. For each box: box.Info(ctx) → BoxInfo → JS object
|
||||||
|
// 4. Return JS array of BoxInfo objects
|
||||||
|
return v8go.Undefined(info.Context().Isolate())
|
||||||
|
}
|
||||||
|
|
||||||
|
// sbDelete: `sandbox.Delete(id)` → void
|
||||||
|
//
|
||||||
|
// Go: Manager.Remove(ctx, id) error
|
||||||
|
//
|
||||||
|
// Args:
|
||||||
|
//
|
||||||
|
// id: string — sandbox ID to remove
|
||||||
|
func sbDelete(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
// TODO: Phase 2
|
||||||
|
// 1. id = info.Args()[0].String()
|
||||||
|
// 2. sandbox.M().Remove(ctx, id)
|
||||||
return v8go.Undefined(info.Context().Isolate())
|
return v8go.Undefined(info.Context().Isolate())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
package jsapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"rogchap.com/v8go"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewManagerObject creates a JS SandboxManager object with the following methods:
|
|
||||||
//
|
|
||||||
// manager.Create(options?) → Box // create a new sandbox box
|
|
||||||
// manager.GetOrCreate(opts) → Box // get existing or create
|
|
||||||
// manager.Get(id) → Box|null // get by sandbox ID
|
|
||||||
// manager.List(options?) → Box[] // list boxes
|
|
||||||
// manager.Remove(id) → void // remove a box
|
|
||||||
// manager.EnsureImage(ref) → void // pull image if missing
|
|
||||||
// manager.ImageExists(ref) → boolean // check image presence
|
|
||||||
// manager.Pools() → PoolInfo[] // list pool info
|
|
||||||
// manager.Release() → void // release JS bridge ref
|
|
||||||
//
|
|
||||||
// Create options (merged with constructor defaults):
|
|
||||||
//
|
|
||||||
// {
|
|
||||||
// id: string // explicit sandbox ID (optional)
|
|
||||||
// workdir: string // container working directory
|
|
||||||
// user: string // container user (e.g. "1000:1000")
|
|
||||||
// env: object // environment variables
|
|
||||||
// memory: number // memory limit in bytes
|
|
||||||
// cpus: number // CPU limit (e.g. 1.5)
|
|
||||||
// vnc: boolean // enable VNC
|
|
||||||
// ports: array // port mappings [{container: 8080, host: 0}]
|
|
||||||
// policy: string // "oneshot"|"session"|"longrunning"|"persistent"
|
|
||||||
// idle_timeout: number // idle timeout in ms
|
|
||||||
// stop_timeout: number // stop timeout in ms
|
|
||||||
// workspace_id: string // workspace to mount
|
|
||||||
// mount_mode: string // "rw"|"ro"
|
|
||||||
// mount_path: string // mount target in container
|
|
||||||
// }
|
|
||||||
func NewManagerObject(v8ctx *v8go.Context /* manager *sandbox.Manager, defaults CreateDefaults */) (*v8go.Value, error) {
|
|
||||||
// TODO: Phase 2 implementation
|
|
||||||
// 1. Create ObjectTemplate with InternalFieldCount(1)
|
|
||||||
// 2. Register manager in bridge
|
|
||||||
// 3. Bind methods: Create, GetOrCreate, Get, List, Remove,
|
|
||||||
// EnsureImage, ImageExists, Pools, Release
|
|
||||||
// 4. Create instance, set internal field
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
@ -4,33 +4,127 @@ import (
|
||||||
"rogchap.com/v8go"
|
"rogchap.com/v8go"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewFSObject creates a JS WorkspaceFS object implementing a file system interface.
|
// NewFSObject creates a JS WorkspaceFS object backed by a workspace ID string.
|
||||||
|
// All methods delegate to workspace.M() → FS — no Go object passed to V8.
|
||||||
//
|
//
|
||||||
// This is the object returned by both:
|
// # Properties (read-only)
|
||||||
// - WorkspaceManager.FS(id) (standalone workspace access)
|
|
||||||
// - Box.Workspace() (sandbox-mounted workspace access)
|
|
||||||
//
|
//
|
||||||
// Methods:
|
// ws.id → string // workspace ID ← workspaceID arg
|
||||||
|
// ws.name → string // workspace name ← Workspace.Name
|
||||||
|
// ws.node → string // tai node name ← Workspace.Node
|
||||||
//
|
//
|
||||||
// fs.ReadFile(path) → string // read UTF-8 content
|
// # Methods — Go mapping
|
||||||
// fs.ReadFileBytes(path) → ArrayBuffer // read binary content
|
//
|
||||||
// fs.WriteFile(path, data) → void // write string or ArrayBuffer
|
// Each method internally does: fs, _ := workspace.M().FS(ctx, workspaceID)
|
||||||
// fs.Stat(path) → FileInfo // file metadata
|
// then calls the corresponding method on taiworkspace.FS.
|
||||||
// returns: { name, size, mode, mod_time, is_dir }
|
//
|
||||||
// fs.ReadDir(path?) → DirEntry[] // list directory (default ".")
|
// ws.ReadFile(path) → string
|
||||||
// returns: [{ name, is_dir, size }]
|
//
|
||||||
// fs.MkdirAll(path) → void // create directory tree
|
// Go: FS.ReadFile(name string) ([]byte, error)
|
||||||
// fs.Remove(path) → void // remove single file/empty dir
|
// — also available via Manager.ReadFile(ctx, id, path)
|
||||||
// fs.RemoveAll(path) → void // remove recursively
|
// JS args: path string
|
||||||
// fs.Rename(from, to) → void // rename/move
|
// JS returns: string (UTF-8 content of the file)
|
||||||
// fs.Close() → void // close FS handle
|
//
|
||||||
// fs.Release() → void // release JS bridge ref
|
// ws.WriteFile(path, data, perm?) → void
|
||||||
func NewFSObject(v8ctx *v8go.Context /* , wfs taiworkspace.FS */) (*v8go.Value, error) {
|
//
|
||||||
|
// Go: FS.WriteFile(name string, data []byte, perm os.FileMode) error
|
||||||
|
// — also available via Manager.WriteFile(ctx, id, path, data, perm)
|
||||||
|
// JS args: path string, data string|Uint8Array, perm? number (default 0644)
|
||||||
|
//
|
||||||
|
// ws.ReadDir(path?) → DirEntry[]
|
||||||
|
//
|
||||||
|
// Go: FS.ReadDir(name string) ([]fs.DirEntry, error)
|
||||||
|
// — also available via Manager.ListDir(ctx, id, path)
|
||||||
|
// JS args: path string (default ".")
|
||||||
|
// JS returns: [{
|
||||||
|
// name: string, ← DirEntry.Name()
|
||||||
|
// is_dir: boolean, ← DirEntry.IsDir()
|
||||||
|
// size: number ← DirEntry.Info().Size()
|
||||||
|
// }]
|
||||||
|
//
|
||||||
|
// ws.Stat(path) → FileInfo
|
||||||
|
//
|
||||||
|
// Go: FS.Stat(name string) (fs.FileInfo, error)
|
||||||
|
// JS args: path string
|
||||||
|
// JS returns: {
|
||||||
|
// name: string, ← FileInfo.Name()
|
||||||
|
// size: number, ← FileInfo.Size()
|
||||||
|
// is_dir: boolean, ← FileInfo.IsDir()
|
||||||
|
// mod_time: string ← FileInfo.ModTime() (ISO 8601)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// ws.MkdirAll(path, perm?) → void
|
||||||
|
//
|
||||||
|
// Go: FS.MkdirAll(name string, perm os.FileMode) error
|
||||||
|
// JS args: path string, perm? number (default 0755)
|
||||||
|
//
|
||||||
|
// ws.Remove(path) → void
|
||||||
|
//
|
||||||
|
// Go: FS.Remove(name string) error
|
||||||
|
// — also available via Manager.Remove(ctx, id, path)
|
||||||
|
// JS args: path string (single file or empty directory)
|
||||||
|
//
|
||||||
|
// ws.RemoveAll(path) → void
|
||||||
|
//
|
||||||
|
// Go: FS.RemoveAll(name string) error
|
||||||
|
// JS args: path string (recursive removal)
|
||||||
|
//
|
||||||
|
// ws.Rename(from, to) → void
|
||||||
|
//
|
||||||
|
// Go: FS.Rename(oldname, newname string) error
|
||||||
|
// JS args: from string, to string
|
||||||
|
//
|
||||||
|
// # Base64 variants (PLANNED — not yet implemented)
|
||||||
|
//
|
||||||
|
// Avoids V8↔Go binary bridge overhead for images, archives, etc.
|
||||||
|
//
|
||||||
|
// ws.ReadFileBase64(path) → string
|
||||||
|
//
|
||||||
|
// Go: FS.ReadFile(name) → base64.StdEncoding.EncodeToString(data)
|
||||||
|
// JS args: path string
|
||||||
|
// JS returns: string (base64-encoded content)
|
||||||
|
//
|
||||||
|
// ws.WriteFileBase64(path, b64, perm?) → void
|
||||||
|
//
|
||||||
|
// Go: base64.StdEncoding.DecodeString(b64) → FS.WriteFile(name, data, perm)
|
||||||
|
// JS args: path string, b64 string, perm? number (default 0644)
|
||||||
|
//
|
||||||
|
// # Host copy (PLANNED — not yet implemented)
|
||||||
|
//
|
||||||
|
// Copy files/dirs from Yao host filesystem into the workspace volume.
|
||||||
|
// Useful for seeding workspaces with templates, config files, assets, etc.
|
||||||
|
//
|
||||||
|
// ws.CopyFromHost(hostPath, destPath?) → void
|
||||||
|
//
|
||||||
|
// Copies a single file or directory tree from the Yao host into the workspace.
|
||||||
|
// Go: read host file(s) → FS.WriteFile / FS.MkdirAll for each entry
|
||||||
|
// JS args: hostPath string (absolute path on Yao host),
|
||||||
|
// destPath? string (target path inside workspace, default basename of hostPath)
|
||||||
|
//
|
||||||
|
// ws.CopyFromHostArchive(hostPath, destPath?) → void
|
||||||
|
//
|
||||||
|
// For large directory trees: zip on host → transfer → unzip on Tai node.
|
||||||
|
// Requires Tai server-side unarchive support.
|
||||||
|
// Go: zip hostPath → tai Volume upload → tai unarchive at destPath
|
||||||
|
// JS args: hostPath string, destPath? string (default ".")
|
||||||
|
func NewFSObject(v8ctx *v8go.Context, workspaceID string) (*v8go.Value, error) {
|
||||||
// TODO: Phase 2 implementation
|
// TODO: Phase 2 implementation
|
||||||
// 1. Create ObjectTemplate with InternalFieldCount(1)
|
// 1. Create JS object via v8go.NewObjectTemplate
|
||||||
// 2. Register FS in bridge
|
// 2. Set read-only properties: id, name, node (from workspace.M().Get(workspaceID))
|
||||||
// 3. Bind methods: ReadFile, ReadFileBytes, WriteFile,
|
// 3. Bind each method as FunctionTemplate:
|
||||||
// Stat, ReadDir, MkdirAll, Remove, RemoveAll, Rename, Close, Release
|
// - ReadFile → workspace.M().FS(ctx, id).ReadFile(path)
|
||||||
// 4. Create instance, set internal field
|
// - WriteFile → workspace.M().FS(ctx, id).WriteFile(path, data, perm)
|
||||||
|
// - ReadDir → workspace.M().FS(ctx, id).ReadDir(path)
|
||||||
|
// - Stat → workspace.M().FS(ctx, id).Stat(path)
|
||||||
|
// - MkdirAll → workspace.M().FS(ctx, id).MkdirAll(path, perm)
|
||||||
|
// - Remove → workspace.M().FS(ctx, id).Remove(path)
|
||||||
|
// - RemoveAll → workspace.M().FS(ctx, id).RemoveAll(path)
|
||||||
|
// - Rename → workspace.M().FS(ctx, id).Rename(old, new)
|
||||||
|
//
|
||||||
|
// PLANNED (not yet implemented):
|
||||||
|
// - ReadFileBase64 → ReadFile + base64 encode in Go
|
||||||
|
// - WriteFileBase64 → base64 decode in Go + WriteFile
|
||||||
|
// - CopyFromHost → host fs.Read → FS.Write (file-by-file)
|
||||||
|
// - CopyFromHostArchive → zip on host → tai transfer → unzip (needs Tai support)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,22 @@
|
||||||
// Package jsapi registers the Workspace() constructor into the Yao V8 runtime.
|
// Package jsapi registers the workspace namespace into the Yao V8 runtime.
|
||||||
|
//
|
||||||
|
// All methods are static on the workspace object — no constructor.
|
||||||
//
|
//
|
||||||
// # JavaScript API
|
// # JavaScript API
|
||||||
//
|
//
|
||||||
// const ws = new Workspace({ node: "tai-1" })
|
// const ws = workspace.Create({ name: "proj", owner: "user1", node: "default" })
|
||||||
// const info = ws.Create({ name: "my-project", owner: "user1" })
|
// const ws = workspace.Get(id)
|
||||||
// const file = ws.ReadFile(info.id, "/README.md")
|
// ws.ReadFile("main.go") → string
|
||||||
// ws.WriteFile(info.id, "/app.ts", content)
|
// ws.WriteFile("out.txt", data) → void
|
||||||
|
// ws.ReadDir("src/") → [{ name, is_dir, size }]
|
||||||
|
// workspace.Delete(id) → void
|
||||||
//
|
//
|
||||||
// The constructor returns a WorkspaceManager object; individual workspace
|
// # Go mapping
|
||||||
// files are accessed through ReadFile/WriteFile/ListDir or the FS() handle.
|
//
|
||||||
|
// workspace.Create(opts) → Manager.Create(ctx, CreateOptions) → *Workspace → WorkspaceFS
|
||||||
|
// workspace.Get(id) → Manager.Get(ctx, id) → *Workspace → WorkspaceFS
|
||||||
|
// workspace.List(filter?) → Manager.List(ctx, ListOptions) → []*Workspace → WorkspaceInfo[]
|
||||||
|
// workspace.Delete(id) → Manager.Delete(ctx, id, false) → void
|
||||||
//
|
//
|
||||||
// Registration happens via init() — import with:
|
// Registration happens via init() — import with:
|
||||||
//
|
//
|
||||||
|
|
@ -21,27 +29,101 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
v8.RegisterFunction("Workspace", ExportFunction)
|
v8.RegisterObject("workspace", ExportObject)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportFunction exports the Workspace constructor to V8.
|
// ExportObject exports the workspace namespace object to V8.
|
||||||
func ExportFunction(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
func ExportObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
|
||||||
return v8go.NewFunctionTemplate(iso, workspaceConstructor)
|
obj := v8go.NewObjectTemplate(iso)
|
||||||
|
obj.Set("Create", v8go.NewFunctionTemplate(iso, wsCreate))
|
||||||
|
obj.Set("Get", v8go.NewFunctionTemplate(iso, wsGet))
|
||||||
|
obj.Set("List", v8go.NewFunctionTemplate(iso, wsList))
|
||||||
|
obj.Set("Delete", v8go.NewFunctionTemplate(iso, wsDelete))
|
||||||
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
// workspaceConstructor is called when JS executes `new Workspace(options?)`.
|
// wsCreate: `workspace.Create(options)` → WorkspaceFS
|
||||||
//
|
//
|
||||||
// Options (all optional — uses global workspace.Manager if omitted):
|
// Go: Manager.Create(ctx, CreateOptions) (*Workspace, error)
|
||||||
|
//
|
||||||
|
// JS options → Go CreateOptions mapping:
|
||||||
//
|
//
|
||||||
// {
|
// {
|
||||||
// node: string // default target node for Create (optional)
|
// id: string → CreateOptions.ID // optional; auto-generated if empty
|
||||||
|
// name: string → CreateOptions.Name // required, human-readable name
|
||||||
|
// owner: string → CreateOptions.Owner // required, user ID
|
||||||
|
// node: string → CreateOptions.Node // required, target Tai node
|
||||||
|
// labels: object → CreateOptions.Labels // optional, map[string]string
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// Returns a WorkspaceManager JS object.
|
// Returns: WorkspaceFS object (see fs.go)
|
||||||
func workspaceConstructor(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
func wsCreate(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
// TODO: Phase 2 implementation
|
// TODO: Phase 2
|
||||||
// 1. Parse optional options from args[0]
|
// 1. Parse options from info.Args()[0]
|
||||||
// 2. Get workspace manager instance
|
// 2. Validate required fields (name, owner, node)
|
||||||
// 3. Return NewManagerObject(v8ctx, manager, defaults)
|
// 3. ws := workspace.M().Create(ctx, opts)
|
||||||
|
// 4. Return NewFSObject(v8ctx, ws.ID)
|
||||||
|
return v8go.Undefined(info.Context().Isolate())
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsGet: `workspace.Get(id)` → WorkspaceFS | null
|
||||||
|
//
|
||||||
|
// Go: Manager.Get(ctx, id) (*Workspace, error)
|
||||||
|
//
|
||||||
|
// Args:
|
||||||
|
//
|
||||||
|
// id: string — workspace ID
|
||||||
|
//
|
||||||
|
// Returns: WorkspaceFS object if found, null if not found
|
||||||
|
func wsGet(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
// TODO: Phase 2
|
||||||
|
// 1. id = info.Args()[0].String()
|
||||||
|
// 2. ws, err := workspace.M().Get(ctx, id)
|
||||||
|
// 3. Return NewFSObject(v8ctx, id) or null
|
||||||
|
return v8go.Undefined(info.Context().Isolate())
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsList: `workspace.List(filter?)` → WorkspaceInfo[]
|
||||||
|
//
|
||||||
|
// Go: Manager.List(ctx, ListOptions) ([]*Workspace, error)
|
||||||
|
//
|
||||||
|
// JS filter → Go ListOptions mapping:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// owner: string → ListOptions.Owner // filter by owner; empty = all
|
||||||
|
// node: string → ListOptions.Node // filter by node; empty = all
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Returns: WorkspaceInfo[] — each element:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// id: string ← Workspace.ID
|
||||||
|
// name: string ← Workspace.Name
|
||||||
|
// owner: string ← Workspace.Owner
|
||||||
|
// node: string ← Workspace.Node
|
||||||
|
// labels: object ← Workspace.Labels
|
||||||
|
// created_at: string ← Workspace.CreatedAt (ISO 8601)
|
||||||
|
// updated_at: string ← Workspace.UpdatedAt (ISO 8601)
|
||||||
|
// }
|
||||||
|
func wsList(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
// TODO: Phase 2
|
||||||
|
// 1. Parse optional filter from info.Args()[0]
|
||||||
|
// 2. list := workspace.M().List(ctx, opts)
|
||||||
|
// 3. Convert each *Workspace → JS object
|
||||||
|
// 4. Return JS array
|
||||||
|
return v8go.Undefined(info.Context().Isolate())
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsDelete: `workspace.Delete(id)` → void
|
||||||
|
//
|
||||||
|
// Go: Manager.Delete(ctx, id string, force bool) error
|
||||||
|
//
|
||||||
|
// Args:
|
||||||
|
//
|
||||||
|
// id: string — workspace ID to remove (force = false)
|
||||||
|
func wsDelete(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
// TODO: Phase 2
|
||||||
|
// 1. id = info.Args()[0].String()
|
||||||
|
// 2. workspace.M().Delete(ctx, id, false)
|
||||||
return v8go.Undefined(info.Context().Isolate())
|
return v8go.Undefined(info.Context().Isolate())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
package jsapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"rogchap.com/v8go"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewManagerObject creates a JS WorkspaceManager object with the following methods:
|
|
||||||
//
|
|
||||||
// wm.Create(options) → WorkspaceInfo // create workspace
|
|
||||||
// options: {
|
|
||||||
// id: string // explicit ID (optional, auto uuid)
|
|
||||||
// name: string // display name (required)
|
|
||||||
// owner: string // owner user ID (required)
|
|
||||||
// node: string // target Tai node (required, or use constructor default)
|
|
||||||
// labels: object // metadata key-value pairs
|
|
||||||
// }
|
|
||||||
// returns: { id, name, owner, node, labels, created_at, updated_at }
|
|
||||||
//
|
|
||||||
// wm.Get(id) → WorkspaceInfo|null
|
|
||||||
// wm.List(options?) → WorkspaceInfo[]
|
|
||||||
// options: { owner: string, node: string }
|
|
||||||
//
|
|
||||||
// wm.Update(id, options) → WorkspaceInfo
|
|
||||||
// options: { name: string, labels: object }
|
|
||||||
//
|
|
||||||
// wm.Delete(id, force?) → void
|
|
||||||
// force: boolean // delete even if has active mounts
|
|
||||||
//
|
|
||||||
// wm.ReadFile(id, path) → string // read file content (UTF-8)
|
|
||||||
// wm.ReadFileBytes(id, path) → ArrayBuffer // read file content (binary)
|
|
||||||
// wm.WriteFile(id, path, data) → void // write file (string or ArrayBuffer)
|
|
||||||
// wm.ListDir(id, path?) → DirEntry[] // list directory
|
|
||||||
// returns: [{ name, is_dir, size }]
|
|
||||||
// wm.Remove(id, path) → void // remove file or dir
|
|
||||||
// wm.MkdirAll(id, path) → void // create directory tree
|
|
||||||
// wm.Rename(id, from, to) → void // rename/move file
|
|
||||||
//
|
|
||||||
// wm.FS(id) → WorkspaceFS // get full FS handle
|
|
||||||
// wm.MountPath(id) → string // host mount path
|
|
||||||
// wm.Nodes() → NodeInfo[] // list available nodes
|
|
||||||
// returns: [{ name, addr, online }]
|
|
||||||
//
|
|
||||||
// wm.Release() → void // release JS bridge ref
|
|
||||||
func NewManagerObject(v8ctx *v8go.Context /* , manager *workspace.Manager, defaults ManagerDefaults */) (*v8go.Value, error) {
|
|
||||||
// TODO: Phase 2 implementation
|
|
||||||
// 1. Create ObjectTemplate with InternalFieldCount(1)
|
|
||||||
// 2. Register manager in bridge
|
|
||||||
// 3. Bind methods: Create, Get, List, Update, Delete,
|
|
||||||
// ReadFile, ReadFileBytes, WriteFile, ListDir, Remove,
|
|
||||||
// MkdirAll, Rename, FS, MountPath, Nodes, Release
|
|
||||||
// 4. Create instance, set internal field
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue