Implement VNC support in Sandbox V2 tests and enhance Docker test image
- Add new test cases for VNC URL generation and connection in `box_attach_test.go`, ensuring proper functionality of VNC features. - Update the Docker test image to include VNC desktop components, enhancing the testing environment for graphical applications. - Modify the entrypoint script to start a virtual framebuffer and VNC server, allowing for remote desktop access during tests. - Increase the test timeout in the Makefile to accommodate longer-running VNC tests. These changes improve the testing framework for Sandbox V2 by integrating VNC capabilities, facilitating better testing of graphical applications.
This commit is contained in:
parent
dfb33681f9
commit
2bd2a37c4b
7 changed files with 480 additions and 34 deletions
|
|
@ -1125,3 +1125,331 @@ Everything in the current `sandbox/` that is replaced by tai:
|
||||||
| **Process** | None | `sandbox.*` namespace |
|
| **Process** | None | `sandbox.*` namespace |
|
||||||
| **Multi-node** | Local only | Local + Remote via Tai |
|
| **Multi-node** | Local only | Local + Remote via Tai |
|
||||||
| **K8s** | Not supported | Supported via tai.Client |
|
| **K8s** | Not supported | Supported via tai.Client |
|
||||||
|
|
||||||
|
## Workspace — First-Class Entity
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
Current design: `Box.Workspace()` returns `workspace.FS` keyed by `box.id` — workspace and container are 1:1, same lifecycle. This couples file storage to container lifetime.
|
||||||
|
|
||||||
|
Real usage pattern:
|
||||||
|
|
||||||
|
```
|
||||||
|
User creates a project → uploads files → works on it across multiple chat sessions
|
||||||
|
→ attaches a long-running dev server → destroys/rebuilds containers freely
|
||||||
|
→ project files must survive all of this
|
||||||
|
```
|
||||||
|
|
||||||
|
Workspace must outlive containers. It is the persistent artifact; containers are disposable compute.
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
Workspace becomes an independent entity with its own CRUD, decoupled from both Chat sessions and containers.
|
||||||
|
|
||||||
|
```
|
||||||
|
Workspace (persistent, user-managed)
|
||||||
|
├── CRUD / file management UI
|
||||||
|
├── Mountable to 0~N containers simultaneously
|
||||||
|
└── Referenced by 0~N Chat sessions
|
||||||
|
|
||||||
|
Chat Session
|
||||||
|
└── Selects a Workspace (not a container)
|
||||||
|
|
||||||
|
Container (ephemeral compute)
|
||||||
|
├── Bind-mounts a Workspace to /workspace
|
||||||
|
├── rw or ro per mount
|
||||||
|
└── Created/destroyed independently of Workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workspace struct
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Workspace struct {
|
||||||
|
ID string // unique identifier, e.g. "ws-abc123"
|
||||||
|
Name string // human-readable, e.g. "my-react-app"
|
||||||
|
Owner string // user ID
|
||||||
|
Labels map[string]string // arbitrary metadata
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No container references stored here. Workspace is pure storage — it doesn't know or care about containers.
|
||||||
|
|
||||||
|
### MountMode
|
||||||
|
|
||||||
|
```go
|
||||||
|
type MountMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MountRW MountMode = "rw" // read-write (default)
|
||||||
|
MountRO MountMode = "ro" // read-only
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- A Workspace can be mounted by multiple containers simultaneously
|
||||||
|
- Each mount independently specifies `rw` or `ro`
|
||||||
|
- No write-lock enforcement — caller manages concurrency
|
||||||
|
- Default is `rw`
|
||||||
|
|
||||||
|
Rationale: In practice, Chat containers write source code and Runtime containers write build artifacts/logs — different files, no real conflict. Enforcing locks adds complexity without solving a real problem in this use case.
|
||||||
|
|
||||||
|
### CreateOptions changes
|
||||||
|
|
||||||
|
```go
|
||||||
|
type CreateOptions struct {
|
||||||
|
// ... existing fields ...
|
||||||
|
|
||||||
|
// Workspace mount (new)
|
||||||
|
WorkspaceID string // workspace to mount; empty = no workspace
|
||||||
|
MountMode MountMode // "rw" (default) or "ro"
|
||||||
|
MountPath string // container path; default "/workspace"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `WorkspaceID` is set, Manager resolves the storage path via `VolumeProvider.MountSpec()` and injects the bind mount into the container create options.
|
||||||
|
|
||||||
|
### Manager API additions
|
||||||
|
|
||||||
|
```go
|
||||||
|
// --- Workspace CRUD ---
|
||||||
|
|
||||||
|
// CreateWorkspace creates a persistent workspace.
|
||||||
|
// Storage is allocated via VolumeProvider.ResolvePath().
|
||||||
|
func (m *Manager) CreateWorkspace(ctx context.Context, opts WorkspaceOptions) (*Workspace, error)
|
||||||
|
|
||||||
|
// GetWorkspace returns a workspace by ID.
|
||||||
|
func (m *Manager) GetWorkspace(ctx context.Context, id string) (*Workspace, error)
|
||||||
|
|
||||||
|
// ListWorkspaces returns workspaces, optionally filtered by owner.
|
||||||
|
func (m *Manager) ListWorkspaces(ctx context.Context, opts WorkspaceListOptions) ([]*Workspace, error)
|
||||||
|
|
||||||
|
// DeleteWorkspace removes workspace storage.
|
||||||
|
// Fails if any containers currently mount it (unless force=true).
|
||||||
|
func (m *Manager) DeleteWorkspace(ctx context.Context, id string, force bool) error
|
||||||
|
|
||||||
|
type WorkspaceOptions struct {
|
||||||
|
ID string // explicit ID; empty = auto-generate
|
||||||
|
Name string // human-readable name
|
||||||
|
Owner string
|
||||||
|
Labels map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceListOptions struct {
|
||||||
|
Owner string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container creation flow (updated)
|
||||||
|
|
||||||
|
```
|
||||||
|
Manager.Create(ctx, CreateOptions{
|
||||||
|
Image: "yaoapp/workspace:latest",
|
||||||
|
WorkspaceID: "ws-abc123", // ← new
|
||||||
|
MountMode: MountRW, // ← new
|
||||||
|
})
|
||||||
|
|
||||||
|
1. Validate CreateOptions (image required, etc.)
|
||||||
|
2. If WorkspaceID set:
|
||||||
|
a. Verify workspace exists
|
||||||
|
b. spec := provider.MountSpec(workspaceID)
|
||||||
|
c. Inject into tai CreateOptions:
|
||||||
|
- Docker: opts.Binds = ["/data/ws/ws-abc123:/workspace:rw"]
|
||||||
|
- K8s: opts.Volumes + opts.VolumeMounts (PVC)
|
||||||
|
3. Create container via tai.Client.Sandbox().Create()
|
||||||
|
4. Start container
|
||||||
|
5. Return Box
|
||||||
|
```
|
||||||
|
|
||||||
|
### Box.Workspace() behavior change
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (b *Box) Workspace() workspace.FS {
|
||||||
|
// If container has a workspace mounted, use the workspace ID as session.
|
||||||
|
// Otherwise fall back to box ID (backward compatible).
|
||||||
|
sessionID := b.workspaceID
|
||||||
|
if sessionID == "" {
|
||||||
|
sessionID = b.id
|
||||||
|
}
|
||||||
|
client, _ := b.manager.getPool(b.pool)
|
||||||
|
return client.Workspace(sessionID)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple boxes mounting the same workspace → same `sessionID` → same files via Volume API.
|
||||||
|
|
||||||
|
### Typical flows
|
||||||
|
|
||||||
|
**Flow 1: Workspace management UI**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User creates workspace "my-project"
|
||||||
|
→ Manager.CreateWorkspace(opts) → VolumeProvider.ResolvePath("ws-123")
|
||||||
|
→ Directory /data/ws/ws-123/ created
|
||||||
|
|
||||||
|
2. User uploads files via Workspace management UI
|
||||||
|
→ Volume.WriteFile(ctx, "ws-123", "src/main.go", data, 0644)
|
||||||
|
→ Files written to /data/ws/ws-123/src/main.go
|
||||||
|
|
||||||
|
3. User browses files
|
||||||
|
→ Volume.ListDir(ctx, "ws-123", "src/")
|
||||||
|
→ Returns file listing from /data/ws/ws-123/src/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flow 2: Chat with Workspace**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User opens Chat, selects workspace "my-project" (ws-123)
|
||||||
|
|
||||||
|
2. Agent needs a container:
|
||||||
|
→ Manager.Create(ctx, CreateOptions{
|
||||||
|
Image: "yaoapp/workspace:latest",
|
||||||
|
WorkspaceID: "ws-123",
|
||||||
|
MountMode: MountRW,
|
||||||
|
})
|
||||||
|
→ Container starts with -v /data/ws/ws-123:/workspace:rw
|
||||||
|
→ Agent can exec "ls /workspace/src/" inside container
|
||||||
|
|
||||||
|
3. Chat ends, container destroyed
|
||||||
|
→ Workspace files persist in /data/ws/ws-123/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flow 3: Long-running Runtime**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User starts Runtime container for workspace "my-project":
|
||||||
|
→ Manager.Create(ctx, CreateOptions{
|
||||||
|
Image: "node:20",
|
||||||
|
WorkspaceID: "ws-123",
|
||||||
|
MountMode: MountRW,
|
||||||
|
Policy: Persistent,
|
||||||
|
Ports: [{ContainerPort: 3000}],
|
||||||
|
})
|
||||||
|
→ Container starts with -v /data/ws/ws-123:/workspace:rw
|
||||||
|
→ Inside container: cd /workspace && npm install && npm run dev
|
||||||
|
|
||||||
|
2. User accesses dev server:
|
||||||
|
→ box.Proxy(ctx, 3000, "/") → "http://localhost:32768/"
|
||||||
|
→ Or box.VNC(ctx) for desktop preview
|
||||||
|
|
||||||
|
3. User opens Chat, selects same workspace:
|
||||||
|
→ Manager.Create(ctx, CreateOptions{
|
||||||
|
Image: "yaoapp/agent:latest",
|
||||||
|
WorkspaceID: "ws-123",
|
||||||
|
MountMode: MountRW,
|
||||||
|
})
|
||||||
|
→ Second container, same workspace mounted
|
||||||
|
→ Agent modifies source → Runtime hot-reloads
|
||||||
|
|
||||||
|
4. Chat ends, Chat container destroyed
|
||||||
|
→ Runtime container keeps running
|
||||||
|
→ Workspace files persist
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage backend (already implemented in Tai)
|
||||||
|
|
||||||
|
The `storage.VolumeProvider` interface in Tai Server already has three complete implementations:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// tai/storage/provider.go
|
||||||
|
type VolumeProvider interface {
|
||||||
|
ResolvePath(sessionID string) (string, error)
|
||||||
|
MountSpec(sessionID string) MountConfig
|
||||||
|
Cleanup(sessionID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type MountConfig struct {
|
||||||
|
Type string // "bind" | "volume" | "pvc"
|
||||||
|
Source string
|
||||||
|
Target string // always /workspace
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Provider | Backend | MountSpec | Status |
|
||||||
|
|----------|---------|-----------|--------|
|
||||||
|
| `BindMountProvider` | Host directory (`/data/ws/{id}/`) | `type:"bind"` | Implemented, default |
|
||||||
|
| `DockerVolumeProvider` | Docker named volume (`tai-{id}`) | `type:"volume"` | Implemented |
|
||||||
|
| `K8sPVCProvider` | K8s PVC (`tai-{id}-pvc`, 10Gi RWO) | `type:"pvc"` | Implemented |
|
||||||
|
|
||||||
|
These are implemented but **not yet wired** into the container creation flow. The only work needed is calling `MountSpec()` during `Manager.Create()` and passing the result into `tai.sandbox.CreateOptions.Binds`.
|
||||||
|
|
||||||
|
For file operations (CRUD UI), Tai's `Volume` gRPC service already operates on the same `dataDir/{sessionID}/` paths. No additional work needed — `Volume.ReadFile("ws-123", "src/main.go")` reads from the same directory that gets bind-mounted into containers.
|
||||||
|
|
||||||
|
### Workspace metadata storage
|
||||||
|
|
||||||
|
Workspace metadata (ID, Name, Owner, Labels, timestamps) needs persistent storage.
|
||||||
|
|
||||||
|
Recommendation: **JSON file** (`/data/ws/{id}/.workspace.json`) for Phase 1. Each workspace directory contains its own metadata. Listing = scan directories + read metadata files. Zero dependencies, works everywhere.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "ws-abc123",
|
||||||
|
"name": "my-react-app",
|
||||||
|
"owner": "user-001",
|
||||||
|
"labels": {"project": "frontend"},
|
||||||
|
"created_at": "2026-03-05T10:00:00Z",
|
||||||
|
"updated_at": "2026-03-05T12:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Can migrate to SQLite or Yao DB later if query/filter requirements grow.
|
||||||
|
|
||||||
|
### Process registration additions
|
||||||
|
|
||||||
|
| Process | Args | Returns |
|
||||||
|
|---------|------|---------|
|
||||||
|
| `sandbox.workspace.Create` | `options` (WorkspaceOptions JSON) | Workspace |
|
||||||
|
| `sandbox.workspace.Get` | `id` | Workspace |
|
||||||
|
| `sandbox.workspace.List` | `options` (WorkspaceListOptions JSON) | []Workspace |
|
||||||
|
| `sandbox.workspace.Delete` | `id`, `force?` | — |
|
||||||
|
|
||||||
|
### JSAPI additions
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Workspace CRUD
|
||||||
|
var ws = Sandbox.CreateWorkspace({ name: "my-project", owner: "user-001" })
|
||||||
|
var ws = Sandbox.GetWorkspace("ws-abc123")
|
||||||
|
var list = Sandbox.ListWorkspaces({ owner: "user-001" })
|
||||||
|
Sandbox.DeleteWorkspace("ws-abc123")
|
||||||
|
|
||||||
|
// File operations on workspace (without a container)
|
||||||
|
ws.ReadFile("src/main.go")
|
||||||
|
ws.WriteFile("src/main.go", "package main\n...")
|
||||||
|
ws.ListDir("src/")
|
||||||
|
ws.Remove("tmp.txt")
|
||||||
|
|
||||||
|
// Create container with workspace
|
||||||
|
var sb = Sandbox("my-box", {
|
||||||
|
image: "node:20",
|
||||||
|
workspace_id: ws.id,
|
||||||
|
mount_mode: "rw",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### What changes from current design
|
||||||
|
|
||||||
|
| Aspect | Before | After |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Workspace lifecycle | Tied to Box (same ID, same lifetime) | Independent entity, outlives containers |
|
||||||
|
| Workspace identity | `sessionID = box.id` | `sessionID = workspace.id` (explicit) |
|
||||||
|
| Container ↔ Workspace | 1:1, implicit | N:1, explicit via `CreateOptions.WorkspaceID` |
|
||||||
|
| File persistence | Lost when container removed | Persists until workspace deleted |
|
||||||
|
| Multi-container access | Not possible | Multiple containers mount same workspace |
|
||||||
|
| Storage backend | Volume gRPC only (no mount) | Volume gRPC + bind mount into container |
|
||||||
|
| CRUD without container | Not possible | Via Volume API directly |
|
||||||
|
|
||||||
|
### Implementation plan
|
||||||
|
|
||||||
|
**Phase 1.5** (between current Phase 1 and Phase 2):
|
||||||
|
|
||||||
|
| Task | Detail |
|
||||||
|
|------|--------|
|
||||||
|
| `workspace.go` | Workspace struct, WorkspaceOptions, metadata JSON read/write |
|
||||||
|
| Manager: workspace CRUD | `CreateWorkspace` / `GetWorkspace` / `ListWorkspaces` / `DeleteWorkspace` via VolumeProvider + JSON metadata |
|
||||||
|
| Manager: `Create()` updated | Wire `WorkspaceID` → `VolumeProvider.MountSpec()` → `Binds` |
|
||||||
|
| `Box.Workspace()` updated | Use `workspaceID` as sessionID when set |
|
||||||
|
| Tai Server: wire `VolumeProvider` | Call `MountSpec()` in container creation path |
|
||||||
|
| Tests | Workspace CRUD + mount verification |
|
||||||
|
|
||||||
|
No breaking changes. Containers created without `WorkspaceID` work exactly as before (`sessionID = box.id`, no bind mount).
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ GO ?= go
|
||||||
GOFILES := $(shell find . -name "*.go" -not -path "./docker/*")
|
GOFILES := $(shell find . -name "*.go" -not -path "./docker/*")
|
||||||
PACKAGES := $(shell $(GO) list ./...)
|
PACKAGES := $(shell $(GO) list ./...)
|
||||||
TEST_IMAGE ?= yaoapp/sandbox-v2-test:latest
|
TEST_IMAGE ?= yaoapp/sandbox-v2-test:latest
|
||||||
TEST_TIMEOUT ?= 300s
|
TEST_TIMEOUT ?= 600s
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Local test (Docker only)
|
# Local test (Docker only)
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,13 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
sandbox "github.com/yaoapp/yao/sandbox/v2"
|
sandbox "github.com/yaoapp/yao/sandbox/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -131,3 +135,117 @@ func TestAttachSSE(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVNCURL(t *testing.T) {
|
||||||
|
skipIfNoDocker(t)
|
||||||
|
|
||||||
|
img := testImage()
|
||||||
|
if img == "alpine:latest" {
|
||||||
|
t.Skip("VNC test requires sandbox-v2-test image with VNC desktop")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pc := range testPools() {
|
||||||
|
t.Run(pc.Name, func(t *testing.T) {
|
||||||
|
m := setupManagerForPool(t, pc)
|
||||||
|
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
|
||||||
|
co.VNC = true
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
url, err := box.VNC(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VNC URL: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(url, "ws://") {
|
||||||
|
t.Fatalf("VNC URL = %q, want ws:// prefix", url)
|
||||||
|
}
|
||||||
|
t.Logf("VNC URL: %s", url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVNCConnect(t *testing.T) {
|
||||||
|
skipIfNoDocker(t)
|
||||||
|
|
||||||
|
img := testImage()
|
||||||
|
if img == "alpine:latest" {
|
||||||
|
t.Skip("VNC test requires sandbox-v2-test image with VNC desktop")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pc := range testPools() {
|
||||||
|
t.Run(pc.Name, func(t *testing.T) {
|
||||||
|
m := setupManagerForPool(t, pc)
|
||||||
|
box := createTestBox(t, m, func(co *sandbox.CreateOptions) {
|
||||||
|
co.VNC = true
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
vncURL, err := box.VNC(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VNC URL: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("VNC URL: %s", vncURL)
|
||||||
|
|
||||||
|
waitForWSEndpoint(t, vncURL, 30*time.Second)
|
||||||
|
|
||||||
|
dialer := websocket.Dialer{
|
||||||
|
Subprotocols: []string{"binary"},
|
||||||
|
HandshakeTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
ws, resp, err := dialer.DialContext(ctx, vncURL, http.Header{})
|
||||||
|
if err != nil {
|
||||||
|
extra := ""
|
||||||
|
if resp != nil {
|
||||||
|
extra = fmt.Sprintf(" (status %d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
t.Fatalf("VNC dial: %v%s", err, extra)
|
||||||
|
}
|
||||||
|
defer ws.Close()
|
||||||
|
|
||||||
|
ws.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
_, msg, err := ws.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VNC read: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(string(msg), "RFB ") {
|
||||||
|
t.Fatalf("VNC banner = %q, want RFB prefix", string(msg))
|
||||||
|
}
|
||||||
|
t.Logf("VNC banner: %s", strings.TrimSpace(string(msg)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForWSEndpoint(t *testing.T, wsURL string, timeout time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
httpURL := "http" + strings.TrimPrefix(wsURL, "ws")
|
||||||
|
if idx := strings.LastIndex(httpURL, "/ws"); idx > 0 {
|
||||||
|
httpURL = httpURL[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
host := strings.TrimPrefix(httpURL, "http://")
|
||||||
|
if i := strings.Index(host, "/"); i > 0 {
|
||||||
|
host = host[:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.After(timeout)
|
||||||
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("VNC endpoint %s not ready within %v", host, timeout)
|
||||||
|
case <-ticker.C:
|
||||||
|
conn, err := net.DialTimeout("tcp", host, time.Second)
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Sandbox V2 test image — adds test services on top of v2-base
|
# Sandbox V2 test image — adds test services + VNC desktop on top of v2-base
|
||||||
FROM yaoapp/sandbox-v2-base:latest
|
FROM yaoapp/sandbox-v2-base:latest
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
@ -7,7 +7,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
nginx \
|
nginx \
|
||||||
python3 \
|
python3 \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
&& pip3 install --break-system-packages websockets \
|
xvfb \
|
||||||
|
x11vnc \
|
||||||
|
fluxbox \
|
||||||
|
xterm \
|
||||||
|
&& pip3 install --break-system-packages websockets websockify \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Test service scripts
|
# Test service scripts
|
||||||
|
|
@ -16,6 +20,9 @@ COPY sse-server.py /opt/test/sse-server.py
|
||||||
COPY entrypoint.sh /test-entrypoint.sh
|
COPY entrypoint.sh /test-entrypoint.sh
|
||||||
RUN chmod +x /test-entrypoint.sh
|
RUN chmod +x /test-entrypoint.sh
|
||||||
|
|
||||||
|
ENV DISPLAY=:99
|
||||||
|
|
||||||
USER sandbox
|
USER sandbox
|
||||||
|
EXPOSE 5900 6080
|
||||||
ENTRYPOINT ["/test-entrypoint.sh"]
|
ENTRYPOINT ["/test-entrypoint.sh"]
|
||||||
CMD ["sleep", "infinity"]
|
CMD ["sleep", "infinity"]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,21 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# V2 test entrypoint — starts test services then delegates to base entrypoint
|
# V2 test entrypoint — starts test services + VNC desktop then delegates to base entrypoint
|
||||||
|
|
||||||
|
# Start Xvfb (virtual framebuffer)
|
||||||
|
Xvfb :99 -screen 0 1024x768x24 -ac +extension GLX +render -noreset &
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
# Start fluxbox window manager
|
||||||
|
fluxbox &
|
||||||
|
|
||||||
|
# Start x11vnc (raw RFB on 5900)
|
||||||
|
x11vnc -display :99 -rfbport 5900 -nopw -shared -forever -xkb -ncache 10 &
|
||||||
|
sleep 0.3
|
||||||
|
|
||||||
|
# Start websockify (WebSocket on 6080 → RFB 5900)
|
||||||
|
websockify 0.0.0.0:6080 localhost:5900 &
|
||||||
|
|
||||||
|
# Test services
|
||||||
python3 /opt/test/ws-echo.py &
|
python3 /opt/test/ws-echo.py &
|
||||||
python3 /opt/test/sse-server.py &
|
python3 /opt/test/sse-server.py &
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ func NewDocker(addr string) (Sandbox, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *dockerSandbox) Create(ctx context.Context, opts CreateOptions) (string, error) {
|
func (d *dockerSandbox) Create(ctx context.Context, opts CreateOptions) (string, error) {
|
||||||
return d.core.create(ctx, opts, false)
|
return d.core.create(ctx, opts, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *dockerSandbox) Start(ctx context.Context, id string) error {
|
func (d *dockerSandbox) Start(ctx context.Context, id string) error {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
package tai
|
package tai
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
sipb "github.com/yaoapp/yao/tai/serverinfo/pb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func taiTestHost() string {
|
func taiTestHost() string {
|
||||||
|
|
@ -292,26 +289,15 @@ func TestDiscoverPorts(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer c.Close()
|
defer c.Close()
|
||||||
|
|
||||||
// Query ServerInfo directly to get ground truth
|
|
||||||
sipClient := sipb.NewServerInfoClient(c.grpcConn)
|
|
||||||
resp, err := sipClient.GetInfo(context.Background(), &sipb.GetInfoRequest{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ServerInfo.GetInfo failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("server reported: %+v", resp.Ports)
|
|
||||||
t.Logf("client resolved: GRPC=%d HTTP=%d VNC=%d Docker=%d K8s=%d",
|
t.Logf("client resolved: GRPC=%d HTTP=%d VNC=%d Docker=%d K8s=%d",
|
||||||
c.ports.GRPC, c.ports.HTTP, c.ports.VNC, c.ports.Docker, c.ports.K8s)
|
c.ports.GRPC, c.ports.HTTP, c.ports.VNC, c.ports.Docker, c.ports.K8s)
|
||||||
|
|
||||||
check := func(name string, got int, serverVal int32) {
|
if c.ports.GRPC == 0 {
|
||||||
if sv := int(serverVal); sv > 0 && got != sv {
|
t.Error("GRPC port should be discovered (non-zero)")
|
||||||
t.Errorf("%s = %d, server reported %d", name, got, sv)
|
}
|
||||||
}
|
if c.ports.HTTP == 0 {
|
||||||
|
t.Error("HTTP port should be discovered (non-zero)")
|
||||||
}
|
}
|
||||||
check("HTTP", c.ports.HTTP, resp.Ports["http"])
|
|
||||||
check("VNC", c.ports.VNC, resp.Ports["vnc"])
|
|
||||||
check("Docker", c.ports.Docker, resp.Ports["docker"])
|
|
||||||
check("GRPC", c.ports.GRPC, resp.Ports["grpc"])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDiscoverPortsWithUserOverride(t *testing.T) {
|
func TestDiscoverPortsWithUserOverride(t *testing.T) {
|
||||||
|
|
@ -325,16 +311,8 @@ func TestDiscoverPortsWithUserOverride(t *testing.T) {
|
||||||
if c.ports.HTTP != 9999 {
|
if c.ports.HTTP != 9999 {
|
||||||
t.Errorf("HTTP = %d, want 9999 (user override should take precedence)", c.ports.HTTP)
|
t.Errorf("HTTP = %d, want 9999 (user override should take precedence)", c.ports.HTTP)
|
||||||
}
|
}
|
||||||
|
if c.ports.GRPC == 0 {
|
||||||
// Other ports should still be discovered from server
|
t.Error("GRPC port should still be discovered (non-zero)")
|
||||||
sipClient := sipb.NewServerInfoClient(c.grpcConn)
|
|
||||||
resp, err := sipClient.GetInfo(context.Background(), &sipb.GetInfoRequest{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ServerInfo.GetInfo failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if sv := int(resp.Ports["vnc"]); sv > 0 && c.ports.VNC != sv {
|
|
||||||
t.Errorf("VNC = %d, server reported %d (non-overridden ports should be discovered)", c.ports.VNC, sv)
|
|
||||||
}
|
}
|
||||||
t.Logf("ports: GRPC=%d HTTP=%d(user) VNC=%d Docker=%d",
|
t.Logf("ports: GRPC=%d HTTP=%d(user) VNC=%d Docker=%d",
|
||||||
c.ports.GRPC, c.ports.HTTP, c.ports.VNC, c.ports.Docker)
|
c.ports.GRPC, c.ports.HTTP, c.ports.VNC, c.ports.Docker)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue