diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 42c96f75..d248087c 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -924,10 +924,20 @@ jobs: }); # ============================================================================= - # Sandbox V2 Tests (requires Docker + Tai for dual-mode) + # Sandbox V2 Tests (tai + sandbox/v2 + workspace, Docker + K8s via k3d) # ============================================================================= SandboxV2Test: runs-on: ubuntu-latest + services: + mongodb: + image: mongo:6.0 + ports: + - 27017:27017 + env: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: 123456 + MONGO_INITDB_DATABASE: test + strategy: matrix: go: ["1.25"] @@ -975,7 +985,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: issue_number, - body: '๐Ÿค– Sandbox V2 Tests running (dual-mode: local + remote)...' + body: '๐Ÿค– Sandbox V2 Tests running (tai + sandbox-v2 + workspace)...' }); - name: Checkout Kun @@ -1038,48 +1048,107 @@ jobs: with: ref: ${{ env.HEAD }} + - name: Setup Apple Private Key + run: | + mkdir -p ../app/openapi/certs/apple + echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8 + - name: Setup Go ${{ matrix.go }} uses: actions/setup-go@v5 with: go-version: ${{ matrix.go }} + - name: Start Redis + run: docker run --name redis --publish 6379:6379 --detach redis:6 + - name: Setup Go Tools run: make tools + - name: Setup ENV (SQLite) + run: | + mkdir -p ${{ github.WORKSPACE }}/../app/db + echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV + echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV + - name: Pull Test Images run: | docker pull yaoapp/sandbox-v2-test:latest || true docker pull yaoapp/tai:latest + docker pull alpine:latest - - name: Start Tai Server (Docker proxy for remote mode) + - name: Install k3d + run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash + + - name: Create k3d cluster run: | + k3d cluster create tai-test --no-lb --wait --api-port 16443 + kubectl wait --for=condition=Ready node --all --timeout=60s + k3d image import alpine:latest -c tai-test + + - name: Start Tai (Docker + K8s proxy) + 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}" + docker run -d --name tai \ + --network k3d-tai-test \ -v /var/run/docker.sock:/var/run/docker.sock \ - -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \ + -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \ + -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ yaoapp/tai:latest + TAI_HTTP_READY=false for i in $(seq 1 30); do if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then echo "Tai HTTP is ready" + TAI_HTTP_READY=true break fi echo "Waiting for Tai HTTP... ($i)" sleep 1 done + if [ "$TAI_HTTP_READY" != "true" ]; then + echo "::error::Tai HTTP failed to become ready within 30s" + docker logs tai 2>&1 || true + docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true + exit 1 + fi + TAI_GRPC_READY=false for i in $(seq 1 15); do if nc -z 127.0.0.1 9100 2>/dev/null; then echo "Tai gRPC is ready" + TAI_GRPC_READY=true break fi echo "Waiting for Tai gRPC... ($i)" sleep 1 done + if [ "$TAI_GRPC_READY" != "true" ]; then + echo "::error::Tai gRPC failed to become ready within 15s" + docker logs tai 2>&1 || true + exit 1 + fi - - name: "Run Sandbox V2 Tests (dual-mode: local + remote)" + - 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) env: - SANDBOX_TEST_IMAGE: yaoapp/sandbox-v2-test:latest + TAI_TEST_HOST: "127.0.0.1" + TAI_TEST_DOCKER: "tcp://127.0.0.1:2375" + TAI_TEST_K8S_HOST: "127.0.0.1" + TAI_TEST_K8S_PORT: "6443" + TAI_TEST_GRPC_PORT: "9100" + TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml" + TAI_TEST_HOST_IP: "172.17.0.1" SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100" + SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest" run: make unit-test-sandbox-v2 - name: Codecov Report @@ -1087,8 +1156,6 @@ jobs: uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} - files: sandbox/v2/coverage.out - flags: sandbox-v2 fail_ci_if_error: false - name: "Comment on PR - Sandbox V2 Tests Done" @@ -1102,7 +1169,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: issue_number, - body: 'โœ… Sandbox V2 Tests passed!' + body: 'โœ… Sandbox V2 Tests passed (tai + sandbox-v2 + workspace)!' }); # ============================================================================= @@ -1715,20 +1782,10 @@ jobs: }); # ============================================================================= - # Tai SDK Tests (requires Tai container with Docker socket mount) + # Benchmark: Sandbox V2 + Workspace (parallel with SandboxV2Test, non-blocking) # ============================================================================= - TaiTest: + BenchmarkSandboxV2: runs-on: ubuntu-latest - services: - mongodb: - image: mongo:6.0 - ports: - - 27017:27017 - env: - MONGO_INITDB_ROOT_USERNAME: root - MONGO_INITDB_ROOT_PASSWORD: 123456 - MONGO_INITDB_DATABASE: test - strategy: matrix: go: ["1.25"] @@ -1765,20 +1822,6 @@ jobs: echo HEAD=$(cat SHA) >> $GITHUB_ENV echo NR=$(cat NR) >> $GITHUB_ENV - - name: "Comment on PR" - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { NR } = process.env - var issue_number = NR; - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue_number, - body: '๐Ÿค– Tai SDK Tests running...' - }); - - name: Checkout Kun uses: actions/checkout@v4 with: @@ -1861,105 +1904,46 @@ jobs: echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV - - name: Pull Tai & Test Images + - name: Pull Test Images run: | + docker pull yaoapp/sandbox-v2-test:latest || true docker pull yaoapp/tai:latest docker pull alpine:latest - - name: Install k3d - run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash - - - name: Create k3d cluster + - name: Start Tai (Docker proxy for benchmarks) run: | - k3d cluster create tai-test --no-lb --wait --api-port 16443 - kubectl wait --for=condition=Ready node --all --timeout=60s - k3d image import alpine:latest -c tai-test - - - name: Start Tai (with Docker socket + K8s proxy) - 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}" - docker run -d --name tai \ - --network k3d-tai-test \ -v /var/run/docker.sock:/var/run/docker.sock \ - -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \ - -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ + -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \ yaoapp/tai:latest - TAI_HTTP_READY=false for i in $(seq 1 30); do if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then echo "Tai HTTP is ready" - TAI_HTTP_READY=true break fi echo "Waiting for Tai HTTP... ($i)" sleep 1 done - if [ "$TAI_HTTP_READY" != "true" ]; then - echo "::error::Tai HTTP failed to become ready within 30s" - echo "--- Tai container logs ---" - docker logs tai 2>&1 || true - echo "--- Tai container status ---" - docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true - exit 1 - fi - TAI_GRPC_READY=false for i in $(seq 1 15); do if nc -z 127.0.0.1 9100 2>/dev/null; then echo "Tai gRPC is ready" - TAI_GRPC_READY=true break fi echo "Waiting for Tai gRPC... ($i)" sleep 1 done - if [ "$TAI_GRPC_READY" != "true" ]; then - echo "::error::Tai gRPC failed to become ready within 15s" - echo "--- Tai container logs ---" - docker logs tai 2>&1 || true - exit 1 - fi - - 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 Tai SDK Tests + - name: Run Benchmarks env: TAI_TEST_HOST: "127.0.0.1" - TAI_TEST_GRPC: "127.0.0.1:9100" TAI_TEST_DOCKER: "tcp://127.0.0.1:2375" - TAI_TEST_K8S_HOST: "127.0.0.1" - TAI_TEST_K8S_PORT: "6443" - TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml" + TAI_TEST_GRPC_PORT: "9100" TAI_TEST_HOST_IP: "172.17.0.1" - run: make unit-test-tai - - - name: Codecov Report - uses: codecov/codecov-action@v4 - with: - token: ${{ secrets.CODECOV_TOKEN }} - - - name: "Comment on PR - Tai Tests Done" - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { NR } = process.env - var issue_number = NR; - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue_number, - body: 'โœ… Tai SDK Tests passed!' - }); + SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100" + SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest" + run: make benchmark-sandbox-v2 # ============================================================================= # gRPC Tests - Run once with SQLite (transport layer, no DB matrix needed) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index a68ce673..9e58fe27 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -680,10 +680,20 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} # ============================================================================= - # Sandbox V2 Tests (requires Docker + Tai for dual-mode) + # Sandbox V2 Tests (tai + sandbox/v2 + workspace, Docker + K8s via k3d) # ============================================================================= sandbox-v2-test: runs-on: ubuntu-latest + services: + mongodb: + image: mongo:6.0 + ports: + - 27017:27017 + env: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: 123456 + MONGO_INITDB_DATABASE: test + strategy: matrix: go: ["1.25"] @@ -746,48 +756,107 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 + - name: Setup Apple Private Key + run: | + mkdir -p ../app/openapi/certs/apple + echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8 + - name: Setup Go ${{ matrix.go }} uses: actions/setup-go@v5 with: go-version: ${{ matrix.go }} + - name: Start Redis + run: docker run --name redis --publish 6379:6379 --detach redis:6 + - name: Setup Go Tools run: make tools + - name: Setup ENV (SQLite) + run: | + mkdir -p ${{ github.WORKSPACE }}/../app/db + echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV + echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV + - name: Pull Test Images run: | docker pull yaoapp/sandbox-v2-test:latest || true docker pull yaoapp/tai:latest + docker pull alpine:latest - - name: Start Tai Server (Docker proxy for remote mode) + - name: Install k3d + run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash + + - name: Create k3d cluster run: | + k3d cluster create tai-test --no-lb --wait --api-port 16443 + kubectl wait --for=condition=Ready node --all --timeout=60s + k3d image import alpine:latest -c tai-test + + - name: Start Tai (Docker + K8s proxy) + 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}" + docker run -d --name tai \ + --network k3d-tai-test \ -v /var/run/docker.sock:/var/run/docker.sock \ - -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \ + -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \ + -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ yaoapp/tai:latest + TAI_HTTP_READY=false for i in $(seq 1 30); do if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then echo "Tai HTTP is ready" + TAI_HTTP_READY=true break fi echo "Waiting for Tai HTTP... ($i)" sleep 1 done + if [ "$TAI_HTTP_READY" != "true" ]; then + echo "::error::Tai HTTP failed to become ready within 30s" + docker logs tai 2>&1 || true + docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true + exit 1 + fi + TAI_GRPC_READY=false for i in $(seq 1 15); do if nc -z 127.0.0.1 9100 2>/dev/null; then echo "Tai gRPC is ready" + TAI_GRPC_READY=true break fi echo "Waiting for Tai gRPC... ($i)" sleep 1 done + if [ "$TAI_GRPC_READY" != "true" ]; then + echo "::error::Tai gRPC failed to become ready within 15s" + docker logs tai 2>&1 || true + exit 1 + fi - - name: "Run Sandbox V2 Tests (dual-mode: local + remote)" + - 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) env: - SANDBOX_TEST_IMAGE: yaoapp/sandbox-v2-test:latest + TAI_TEST_HOST: "127.0.0.1" + TAI_TEST_DOCKER: "tcp://127.0.0.1:2375" + TAI_TEST_K8S_HOST: "127.0.0.1" + TAI_TEST_K8S_PORT: "6443" + TAI_TEST_GRPC_PORT: "9100" + TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml" + TAI_TEST_HOST_IP: "172.17.0.1" SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100" + SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest" run: make unit-test-sandbox-v2 - name: Codecov Report @@ -795,8 +864,6 @@ jobs: uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} - files: sandbox/v2/coverage.out - flags: sandbox-v2 fail_ci_if_error: false # ============================================================================= @@ -1255,20 +1322,10 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} # ============================================================================= - # Tai SDK Tests (requires Tai container with Docker socket mount) + # Benchmark: Sandbox V2 + Workspace (parallel with sandbox-v2-test) # ============================================================================= - tai-test: + benchmark-sandbox-v2: runs-on: ubuntu-latest - services: - mongodb: - image: mongo:6.0 - ports: - - 27017:27017 - env: - MONGO_INITDB_ROOT_USERNAME: root - MONGO_INITDB_ROOT_PASSWORD: 123456 - MONGO_INITDB_DATABASE: test - strategy: matrix: go: ["1.25"] @@ -1353,91 +1410,46 @@ jobs: echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV - - name: Pull Tai & Test Images + - name: Pull Test Images run: | + docker pull yaoapp/sandbox-v2-test:latest || true docker pull yaoapp/tai:latest docker pull alpine:latest - - name: Install k3d - run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash - - - name: Create k3d cluster + - name: Start Tai (Docker proxy for benchmarks) run: | - k3d cluster create tai-test --no-lb --wait --api-port 16443 - kubectl wait --for=condition=Ready node --all --timeout=60s - k3d image import alpine:latest -c tai-test - - - name: Start Tai (with Docker socket + K8s proxy) - 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}" - docker run -d --name tai \ - --network k3d-tai-test \ -v /var/run/docker.sock:/var/run/docker.sock \ - -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \ - -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ + -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \ yaoapp/tai:latest - TAI_HTTP_READY=false for i in $(seq 1 30); do if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then echo "Tai HTTP is ready" - TAI_HTTP_READY=true break fi echo "Waiting for Tai HTTP... ($i)" sleep 1 done - if [ "$TAI_HTTP_READY" != "true" ]; then - echo "::error::Tai HTTP failed to become ready within 30s" - echo "--- Tai container logs ---" - docker logs tai 2>&1 || true - echo "--- Tai container status ---" - docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true - exit 1 - fi - TAI_GRPC_READY=false for i in $(seq 1 15); do if nc -z 127.0.0.1 9100 2>/dev/null; then echo "Tai gRPC is ready" - TAI_GRPC_READY=true break fi echo "Waiting for Tai gRPC... ($i)" sleep 1 done - if [ "$TAI_GRPC_READY" != "true" ]; then - echo "::error::Tai gRPC failed to become ready within 15s" - echo "--- Tai container logs ---" - docker logs tai 2>&1 || true - exit 1 - fi - - 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 Tai SDK Tests + - name: Run Benchmarks env: TAI_TEST_HOST: "127.0.0.1" - TAI_TEST_GRPC: "127.0.0.1:9100" TAI_TEST_DOCKER: "tcp://127.0.0.1:2375" - TAI_TEST_K8S_HOST: "127.0.0.1" - TAI_TEST_K8S_PORT: "6443" - TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml" + TAI_TEST_GRPC_PORT: "9100" TAI_TEST_HOST_IP: "172.17.0.1" - run: make unit-test-tai - - - name: Codecov Report - uses: codecov/codecov-action@v4 - with: - token: ${{ secrets.CODECOV_TOKEN }} + SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100" + SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest" + run: make benchmark-sandbox-v2 # ============================================================================= # gRPC Tests - Run once with SQLite (transport layer, no DB matrix needed) diff --git a/Makefile b/Makefile index be2206dc..5c976622 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,8 @@ TESTFOLDER_ROBOT := $(shell $(GO) list ./agent/robot/... | grep -vE 'agent/robot TESTFOLDER_SANDBOX := $(shell $(GO) list ./sandbox/... | grep -v 'sandbox/v2') # Tai SDK tests (requires Tai container with Docker socket) TESTFOLDER_TAI := $(shell $(GO) list ./tai/...) +# Workspace tests (requires Tai for remote mode) +TESTFOLDER_WORKSPACE := $(shell $(GO) list ./workspace/...) # gRPC tests TESTFOLDER_GRPC := $(shell $(GO) list ./grpc/...) TESTTAGS ?= "" @@ -199,16 +201,95 @@ unit-test-registry: rm profile.out; \ fi -# Sandbox V2 Unit Test (requires Docker; optionally Tai for remote mode) +# --------------------------------------------------------------------------- +# Sandbox V2 Integration Test (tai + sandbox/v2 + workspace) +# Requires: Docker, Tai container, optionally k3d for K8s mode +# --------------------------------------------------------------------------- +SANDBOX_V2_IMAGE ?= yaoapp/sandbox-v2-test:latest + .PHONY: unit-test-sandbox-v2 -unit-test-sandbox-v2: +unit-test-sandbox-v2: unit-test-sandbox-v2-pull unit-test-tai unit-test-sandbox-v2-core unit-test-workspace + @echo "" + @echo "=============================================" + @echo "All Sandbox V2 integration tests passed" + @echo "=============================================" + +.PHONY: unit-test-sandbox-v2-pull +unit-test-sandbox-v2-pull: + @echo "" + @echo "=============================================" + @echo "Pulling test images..." + @echo "=============================================" + docker pull $(SANDBOX_V2_IMAGE) || true + docker pull alpine:latest || true + +.PHONY: unit-test-sandbox-v2-core +unit-test-sandbox-v2-core: @echo "" @echo "=============================================" @echo "Running Sandbox V2 Tests..." @echo "=============================================" - docker pull $(SANDBOX_V2_IMAGE) || true $(MAKE) -C sandbox/v2 test-ci TEST_IMAGE=$(SANDBOX_V2_IMAGE) -SANDBOX_V2_IMAGE ?= yaoapp/sandbox-v2-test:latest + +# Workspace Unit Test (requires Tai for remote mode) +.PHONY: unit-test-workspace +unit-test-workspace: + @echo "" + @echo "=============================================" + @echo "Running Workspace Tests..." + @echo "=============================================" + echo "mode: count" > coverage.out + for d in $(TESTFOLDER_WORKSPACE); do \ + $(GO) test -tags $(TESTTAGS) -v -timeout=10m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") $$d > tmp.out; \ + cat tmp.out; \ + if grep -q "^--- FAIL" tmp.out; then \ + rm tmp.out; \ + exit 1; \ + elif grep -q "^FAIL" tmp.out; then \ + rm tmp.out; \ + exit 1; \ + elif grep -q "^panic:" tmp.out; then \ + rm tmp.out; \ + exit 1; \ + elif grep -q "build failed" tmp.out; then \ + rm tmp.out; \ + exit 1; \ + elif grep -q "setup failed" tmp.out; then \ + rm tmp.out; \ + exit 1; \ + elif grep -q "runtime error" tmp.out; then \ + rm tmp.out; \ + exit 1; \ + fi; \ + if [ -f profile.out ]; then \ + cat profile.out | grep -v "mode:" >> coverage.out; \ + rm profile.out; \ + fi; \ + done + @echo "" + @echo "=============================================" + @echo "All workspace tests passed" + @echo "=============================================" + +# Benchmark: Sandbox V2 + Workspace +.PHONY: benchmark-sandbox-v2 +benchmark-sandbox-v2: + @echo "" + @echo "=============================================" + @echo "Running Sandbox V2 + Workspace Benchmarks..." + @echo "=============================================" + @for d in $$($(GO) list ./sandbox/v2/... ./workspace/...); do \ + if $(GO) test -list=Benchmark $$d 2>/dev/null | grep -q "^Benchmark"; then \ + echo ""; \ + echo "Benchmarking: $$d"; \ + echo "---------------------------------------------"; \ + $(GO) test -bench=. -benchmem -benchtime=1x -run='^$$' -timeout=600s $$d || true; \ + fi; \ + done + @echo "" + @echo "=============================================" + @echo "All benchmarks completed" + @echo "=============================================" # Sandbox Unit Test (requires Docker) .PHONY: unit-test-sandbox @@ -262,9 +343,6 @@ unit-test-tai: @echo "=============================================" @echo "Running Tai SDK Tests (requires Tai container)..." @echo "=============================================" - @echo "Pulling test images..." - docker pull alpine:latest || true - @echo "" echo "mode: count" > coverage.out for d in $(TESTFOLDER_TAI); do \ $(GO) test -tags $(TESTTAGS) -v -timeout=5m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") $$d > tmp.out; \ diff --git a/sandbox/v2/DESIGN.md b/sandbox/v2/DESIGN.md index 74fefc2c..accf1461 100644 --- a/sandbox/v2/DESIGN.md +++ b/sandbox/v2/DESIGN.md @@ -1126,330 +1126,31 @@ Everything in the current `sandbox/` that is replaced by tai: | **Multi-node** | Local only | Local + Remote via Tai | | **K8s** | Not supported | Supported via tai.Client | -## Workspace โ€” First-Class Entity +## Workspace -### Problem +Workspace is now a **top-level module** (`workspace/`), parallel to `sandbox/v2`. -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. +See [`workspace/DESIGN.md`](../workspace/DESIGN.md) for the full design document covering: +- Workspace as a first-class, persistent entity decoupled from containers +- Node binding and container scheduling +- Workspace CRUD and file I/O APIs +- Integration with Sandbox `CreateOptions` +- Metadata storage strategy +- Process and JSAPI registration +- Implementation plan -Real usage pattern: +### Integration point -``` -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 +`sandbox/v2` integrates with Workspace via `CreateOptions.WorkspaceID`: ```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). +When `WorkspaceID` is set, the Sandbox Manager resolves the Workspace's bound node and forces the container to be created on that node. See `workspace/DESIGN.md` for full details. diff --git a/sandbox/v2/bench_test.go b/sandbox/v2/bench_test.go new file mode 100644 index 00000000..605982da --- /dev/null +++ b/sandbox/v2/bench_test.go @@ -0,0 +1,254 @@ +package sandbox_test + +import ( + "context" + "fmt" + "testing" + "time" + + sandbox "github.com/yaoapp/yao/sandbox/v2" +) + +// BenchmarkContainerLifecycle measures the full Create โ†’ Exec โ†’ Remove cycle. +func BenchmarkContainerLifecycle(b *testing.B) { + for _, pc := range testPools() { + b.Run(pc.Name, func(b *testing.B) { + m := setupManagerForBench(b, pc) + ensureTestImageBench(b, m, pc.Name) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ctx := context.Background() + + box, err := m.Create(ctx, sandbox.CreateOptions{ + Image: testImage(), + Owner: "bench", + }) + if err != nil { + b.Fatalf("Create: %v", err) + } + + _, err = box.Exec(ctx, []string{"echo", "ok"}) + if err != nil { + b.Fatalf("Exec: %v", err) + } + + m.Remove(ctx, box.ID()) + } + }) + } +} + +// BenchmarkCreate measures container creation time only. +func BenchmarkCreate(b *testing.B) { + for _, pc := range testPools() { + b.Run(pc.Name, func(b *testing.B) { + m := setupManagerForBench(b, pc) + ensureTestImageBench(b, m, pc.Name) + + ids := make([]string, 0, b.N) + b.ResetTimer() + for i := 0; i < b.N; i++ { + box, err := m.Create(context.Background(), sandbox.CreateOptions{ + Image: testImage(), + Owner: "bench", + }) + if err != nil { + b.Fatalf("Create: %v", err) + } + ids = append(ids, box.ID()) + } + b.StopTimer() + + for _, id := range ids { + m.Remove(context.Background(), id) + } + }) + } +} + +// BenchmarkExec measures command execution latency on a pre-created container. +func BenchmarkExec(b *testing.B) { + for _, pc := range testPools() { + b.Run(pc.Name, func(b *testing.B) { + m := setupManagerForBench(b, pc) + box := createBoxForBench(b, m) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + result, err := box.Exec(context.Background(), []string{"echo", "bench"}) + if err != nil { + b.Fatalf("Exec: %v", err) + } + if result.ExitCode != 0 { + b.Fatalf("exit code = %d", result.ExitCode) + } + } + }) + } +} + +// BenchmarkExecHeavy measures execution of a heavier command (write + read file). +func BenchmarkExecHeavy(b *testing.B) { + for _, pc := range testPools() { + b.Run(pc.Name, func(b *testing.B) { + m := setupManagerForBench(b, pc) + box := createBoxForBench(b, m) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + cmd := []string{"sh", "-c", fmt.Sprintf("echo bench-%d > /tmp/b.txt && cat /tmp/b.txt", i)} + result, err := box.Exec(context.Background(), cmd) + if err != nil { + b.Fatalf("Exec: %v", err) + } + if result.ExitCode != 0 { + b.Fatalf("exit code = %d", result.ExitCode) + } + } + }) + } +} + +// BenchmarkRemove measures container removal time. +func BenchmarkRemove(b *testing.B) { + for _, pc := range testPools() { + b.Run(pc.Name, func(b *testing.B) { + m := setupManagerForBench(b, pc) + ensureTestImageBench(b, m, pc.Name) + + boxes := make([]*sandbox.Box, b.N) + for i := 0; i < b.N; i++ { + box, err := m.Create(context.Background(), sandbox.CreateOptions{ + Image: testImage(), + Owner: "bench", + }) + if err != nil { + b.Fatalf("Create: %v", err) + } + boxes[i] = box + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := m.Remove(context.Background(), boxes[i].ID()); err != nil { + b.Fatalf("Remove: %v", err) + } + } + }) + } +} + +// BenchmarkInfo measures Info() latency on a running container. +func BenchmarkInfo(b *testing.B) { + for _, pc := range testPools() { + b.Run(pc.Name, func(b *testing.B) { + m := setupManagerForBench(b, pc) + box := createBoxForBench(b, m) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := box.Info(context.Background()) + if err != nil { + b.Fatalf("Info: %v", err) + } + } + }) + } +} + +// BenchmarkStopStart measures Stop โ†’ Start cycle time. +func BenchmarkStopStart(b *testing.B) { + for _, pc := range testPools() { + b.Run(pc.Name, func(b *testing.B) { + if pc.Name == "k8s" { + b.Skip("K8s Stop deletes Pod; Stopโ†’Start cycle not applicable") + } + m := setupManagerForBench(b, pc) + box := createBoxForBench(b, m) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := box.Stop(context.Background()); err != nil { + b.Fatalf("Stop: %v", err) + } + if err := box.Start(context.Background()); err != nil { + b.Fatalf("Start: %v", err) + } + } + }) + } +} + +// BenchmarkWorkspaceReadWrite measures workspace file read/write via container Box. +func BenchmarkWorkspaceReadWrite(b *testing.B) { + for _, pc := range testPools() { + b.Run(pc.Name, func(b *testing.B) { + m := setupManagerForBench(b, pc) + box := createBoxForBench(b, m) + ws := box.Workspace() + if ws == nil { + b.Skip("workspace not available") + } + + payload := []byte("package main\nfunc main() { println(\"hello\") }\n") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + name := fmt.Sprintf("f%d.go", i) + if err := ws.WriteFile(name, payload, 0644); err != nil { + b.Fatalf("WriteFile: %v", err) + } + data, err := ws.ReadFile(name) + if err != nil { + b.Fatalf("ReadFile: %v", err) + } + if len(data) != len(payload) { + b.Fatalf("size mismatch: %d vs %d", len(data), len(payload)) + } + } + }) + } +} + +// --- helpers --- + +func setupManagerForBench(b *testing.B, pc poolConfig) *sandbox.Manager { + b.Helper() + pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options} + cfg := sandbox.Config{Pool: []sandbox.Pool{pool}} + if err := sandbox.Init(cfg); err != nil { + b.Fatalf("Init: %v", err) + } + m := sandbox.M() + b.Cleanup(func() { m.Close() }) + return m +} + +func ensureTestImageBench(b *testing.B, m *sandbox.Manager, pool string) { + b.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + if err := m.EnsureImage(ctx, pool, testImage(), sandbox.ImagePullOptions{}); err != nil { + b.Fatalf("EnsureImage: %v", err) + } +} + +func createBoxForBench(b *testing.B, m *sandbox.Manager) *sandbox.Box { + b.Helper() + pools := m.Pools() + if len(pools) > 0 { + ensureTestImageBench(b, m, pools[0].Name) + } + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + box, err := m.Create(ctx, sandbox.CreateOptions{ + Image: testImage(), + Owner: "bench", + }) + if err != nil { + b.Fatalf("Create: %v", err) + } + b.Cleanup(func() { m.Remove(context.Background(), box.ID()) }) + return box +} diff --git a/sandbox/v2/box.go b/sandbox/v2/box.go index dc98398c..5cb5bfc0 100644 --- a/sandbox/v2/box.go +++ b/sandbox/v2/box.go @@ -23,10 +23,12 @@ type Box struct { lastHeartbeat atomic.Int64 processCount atomic.Int32 idleTimeoutD time.Duration + stopTimeoutD time.Duration createdAt time.Time refreshToken string vnc bool image string + workspaceID string ws workspace.FS manager *Manager } @@ -143,19 +145,28 @@ func (b *Box) Attach(ctx context.Context, port int, opts ...AttachOption) (*Serv } // Workspace returns an fs.FS-compatible filesystem for this sandbox. +// If a workspace is mounted (WorkspaceID set), uses the workspace ID as session; +// otherwise falls back to the sandbox ID (backward compatible). func (b *Box) Workspace() workspace.FS { b.touch() if b.ws != nil { return b.ws } + sessionID := b.workspaceID + if sessionID == "" { + sessionID = b.id + } client, err := b.manager.getPool(b.pool) if err != nil { return nil } - b.ws = client.Workspace(b.id) + b.ws = client.Workspace(sessionID) return b.ws } +// WorkspaceID returns the workspace ID mounted to this sandbox, or empty string. +func (b *Box) WorkspaceID() string { return b.workspaceID } + // VNC returns the VNC WebSocket URL. func (b *Box) VNC(ctx context.Context) (string, error) { b.touch() @@ -191,7 +202,7 @@ func (b *Box) Stop(ctx context.Context) error { if err != nil { return err } - return client.Sandbox().Stop(ctx, b.containerID, 10*time.Second) + return client.Sandbox().Stop(ctx, b.containerID, b.stopTimeout()) } // Remove stops and removes the sandbox. @@ -259,3 +270,14 @@ func (b *Box) maxLifetime() time.Duration { } return 0 } + +func (b *Box) stopTimeout() time.Duration { + if b.stopTimeoutD > 0 { + return b.stopTimeoutD + } + pd := b.manager.findPoolDef(b.pool) + if pd != nil && pd.StopTimeout > 0 { + return pd.StopTimeout + } + return DefaultStopTimeout +} diff --git a/sandbox/v2/box_attach_test.go b/sandbox/v2/box_attach_test.go index 253d124a..8b4a0228 100644 --- a/sandbox/v2/box_attach_test.go +++ b/sandbox/v2/box_attach_test.go @@ -2,7 +2,6 @@ package sandbox_test import ( "context" - "fmt" "net" "net/http" "strings" @@ -19,12 +18,12 @@ func waitForPort(t *testing.T, box *sandbox.Box, port int, timeout time.Duration ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - url, err := box.Proxy(ctx, port, "/") + proxyURL, err := box.Proxy(ctx, port, "/") if err != nil { t.Fatalf("Proxy URL: %v", err) } - host := url[len("http://"):] + host := proxyURL[len("http://"):] if i := len(host) - 1; host[i] == '/' { host = host[:i] } @@ -36,7 +35,7 @@ func waitForPort(t *testing.T, box *sandbox.Box, port int, timeout time.Duration } deadline := time.After(timeout) - ticker := time.NewTicker(200 * time.Millisecond) + ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for { @@ -44,13 +43,16 @@ func waitForPort(t *testing.T, box *sandbox.Box, port int, timeout time.Duration case <-deadline: t.Fatalf("port %d not ready within %v", port, timeout) case <-ticker.C: - conn, err := net.DialTimeout("tcp", host, time.Second) - if err == nil { - conn.Close() - time.Sleep(200 * time.Millisecond) - return + conn, err := net.DialTimeout("tcp", host, 2*time.Second) + if err != nil { + continue } - fmt.Printf("waiting for %s: %v\n", host, err) + conn.Close() + // TCP reachable โ€” give the service process time to accept + // application-layer connections (Python ws/sse servers in CI + // may take 1-3s after the port opens before they're ready). + time.Sleep(2 * time.Second) + return } } } @@ -74,9 +76,17 @@ func TestAttachWS(t *testing.T) { waitForPort(t, box, 9800, 30*time.Second) - conn, err := box.Attach(t.Context(), 9800, sandbox.WithProtocol("ws"), sandbox.WithPath("/")) + var conn *sandbox.ServiceConn + var err error + for attempt := 0; attempt < 5; attempt++ { + conn, err = box.Attach(t.Context(), 9800, sandbox.WithProtocol("ws"), sandbox.WithPath("/")) + if err == nil { + break + } + time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond) + } if err != nil { - t.Fatalf("Attach WS: %v", err) + t.Fatalf("Attach WS after retries: %v", err) } defer conn.Close() @@ -114,9 +124,17 @@ func TestAttachSSE(t *testing.T) { waitForPort(t, box, 9801, 30*time.Second) - conn, err := box.Attach(t.Context(), 9801, sandbox.WithProtocol("sse"), sandbox.WithPath("/events")) + var conn *sandbox.ServiceConn + var err error + for attempt := 0; attempt < 5; attempt++ { + conn, err = box.Attach(t.Context(), 9801, sandbox.WithProtocol("sse"), sandbox.WithPath("/events")) + if err == nil { + break + } + time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond) + } if err != nil { - t.Fatalf("Attach SSE: %v", err) + t.Fatalf("Attach SSE after retries: %v", err) } defer conn.Close() @@ -181,7 +199,7 @@ func TestVNCConnect(t *testing.T) { co.VNC = true }) - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() vncURL, err := box.VNC(ctx) @@ -196,13 +214,20 @@ func TestVNCConnect(t *testing.T) { 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) + var ws *websocket.Conn + for attempt := 0; attempt < 5; attempt++ { + var resp *http.Response + ws, resp, err = dialer.DialContext(ctx, vncURL, http.Header{}) + if err == nil { + break } - t.Fatalf("VNC dial: %v%s", err, extra) + if resp != nil { + resp.Body.Close() + } + time.Sleep(time.Duration(attempt+1) * time.Second) + } + if err != nil { + t.Fatalf("VNC dial after retries: %v", err) } defer ws.Close() @@ -240,12 +265,16 @@ func waitForWSEndpoint(t *testing.T, wsURL string, timeout time.Duration) { 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 + conn, err := net.DialTimeout("tcp", host, 2*time.Second) + if err != nil { + continue } + conn.Close() + // VNC services (Xvfb โ†’ fluxbox โ†’ x11vnc โ†’ websockify) need time + // after the TCP port is reachable. Give the process chain time to + // stabilize before attempting the WebSocket handshake. + time.Sleep(2 * time.Second) + return } } } diff --git a/sandbox/v2/box_image_test.go b/sandbox/v2/box_image_test.go new file mode 100644 index 00000000..1f43fedb --- /dev/null +++ b/sandbox/v2/box_image_test.go @@ -0,0 +1,121 @@ +package sandbox_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + sandbox "github.com/yaoapp/yao/sandbox/v2" +) + +func TestImageExists(t *testing.T) { + for _, pc := range testPools() { + pc := pc + t.Run(pc.Name, func(t *testing.T) { + if pc.Name == "k8s" { + t.Run("always_true", func(t *testing.T) { + m := setupManagerForPool(t, pc) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + exists, err := m.ImageExists(ctx, pc.Name, "anything:nonexistent") + require.NoError(t, err) + assert.True(t, exists, "k8s mode should always return true") + }) + return + } + + m := setupManagerForPool(t, pc) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + t.Run("existing", func(t *testing.T) { + exists, err := m.ImageExists(ctx, pc.Name, "alpine:latest") + require.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("missing", func(t *testing.T) { + exists, err := m.ImageExists(ctx, pc.Name, "nonexistent/image:no-such-tag-ever-12345") + require.NoError(t, err) + assert.False(t, exists) + }) + }) + } +} + +func TestImagePull(t *testing.T) { + for _, pc := range testPools() { + pc := pc + t.Run(pc.Name, func(t *testing.T) { + if pc.Name == "k8s" { + t.Run("noop", func(t *testing.T) { + m := setupManagerForPool(t, pc) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + ch, err := m.PullImage(ctx, pc.Name, "alpine:latest", sandbox.ImagePullOptions{}) + require.NoError(t, err) + assert.Nil(t, ch, "k8s mode should return nil channel") + }) + return + } + + m := setupManagerForPool(t, pc) + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + t.Run("pull_with_progress", func(t *testing.T) { + ch, err := m.PullImage(ctx, pc.Name, "alpine:latest", sandbox.ImagePullOptions{}) + require.NoError(t, err) + require.NotNil(t, ch) + + var count int + for p := range ch { + if p.Error != "" { + t.Fatalf("pull error: %s", p.Error) + } + count++ + } + assert.Greater(t, count, 0, "should receive at least one progress event") + }) + }) + } +} + +func TestEnsureImage(t *testing.T) { + for _, pc := range testPools() { + pc := pc + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + err := m.EnsureImage(ctx, pc.Name, "alpine:latest", sandbox.ImagePullOptions{}) + require.NoError(t, err) + + if pc.Name != "k8s" { + exists, err := m.ImageExists(ctx, pc.Name, "alpine:latest") + require.NoError(t, err) + assert.True(t, exists) + } + }) + } +} + +func TestEnsureImage_BadRef(t *testing.T) { + for _, pc := range testPools() { + pc := pc + if pc.Name == "k8s" { + continue + } + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := m.EnsureImage(ctx, pc.Name, "nonexistent/image:no-such-tag-ever-12345", sandbox.ImagePullOptions{}) + assert.Error(t, err) + }) + } +} diff --git a/sandbox/v2/box_test.go b/sandbox/v2/box_test.go index 8c917502..83f9677d 100644 --- a/sandbox/v2/box_test.go +++ b/sandbox/v2/box_test.go @@ -3,6 +3,7 @@ package sandbox_test import ( "context" "io" + "strings" "testing" "time" @@ -143,7 +144,7 @@ func TestBoxInfo(t *testing.T) { if info.ID != box.ID() { t.Errorf("ID = %q, want %q", info.ID, box.ID()) } - if info.Status != "running" { + if s := strings.ToLower(info.Status); s != "running" { t.Errorf("status = %q, want running", info.Status) } if info.Owner != "test-user" { diff --git a/sandbox/v2/box_workspace_test.go b/sandbox/v2/box_workspace_test.go new file mode 100644 index 00000000..76c4ee96 --- /dev/null +++ b/sandbox/v2/box_workspace_test.go @@ -0,0 +1,284 @@ +package sandbox_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + sandbox "github.com/yaoapp/yao/sandbox/v2" + "github.com/yaoapp/yao/workspace" +) + +func TestWorkspaceID_Set(t *testing.T) { + skipIfNoDocker(t) + + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + sbm, wsm := setupManagerWithWorkspace(t, pc) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ws, err := wsm.Create(ctx, workspace.CreateOptions{ + Name: "test-ws", Owner: "user", Node: pc.Name, + }) + require.NoError(t, err) + defer wsm.Delete(context.Background(), ws.ID, true) + + box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + co.WorkspaceID = ws.ID + }) + + assert.Equal(t, ws.ID, box.WorkspaceID()) + }) + } +} + +func TestWorkspaceID_Empty(t *testing.T) { + skipIfNoDocker(t) + + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + box := createTestBox(t, m) + assert.Empty(t, box.WorkspaceID()) + }) + } +} + +func TestWorkspace_NodeRouting(t *testing.T) { + skipIfNoDocker(t) + + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + sbm, wsm := setupManagerWithWorkspace(t, pc) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ws, err := wsm.Create(ctx, workspace.CreateOptions{ + Name: "routed-ws", Owner: "user", Node: pc.Name, + }) + require.NoError(t, err) + defer wsm.Delete(context.Background(), ws.ID, true) + + box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + co.WorkspaceID = ws.ID + }) + + assert.Equal(t, pc.Name, box.Pool()) + }) + } +} + +func TestWorkspace_InvalidID(t *testing.T) { + skipIfNoDocker(t) + + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + sbm, _ := setupManagerWithWorkspace(t, pc) + ensureTestImage(t, sbm, pc.Name) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := sbm.Create(ctx, sandbox.CreateOptions{ + Image: testImage(), + Owner: "user", + WorkspaceID: "nonexistent-workspace", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "resolve workspace") + }) + } +} + +func TestWorkspace_BindMountLocal(t *testing.T) { + skipIfNoDocker(t) + + pc := poolConfig{Name: "local", Addr: testLocalAddr()} + sbm, wsm := setupManagerWithWorkspace(t, pc) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ws, err := wsm.Create(ctx, workspace.CreateOptions{ + Name: "mount-ws", Owner: "user", Node: pc.Name, + }) + require.NoError(t, err) + defer wsm.Delete(context.Background(), ws.ID, true) + + require.NoError(t, wsm.WriteFile(ctx, ws.ID, "seed.txt", []byte("hello from workspace"), 0644)) + + box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + co.WorkspaceID = ws.ID + }) + + result, err := box.Exec(ctx, []string{"cat", "/workspace/seed.txt"}) + require.NoError(t, err) + assert.Equal(t, "hello from workspace", result.Stdout) +} + +func TestWorkspace_ContainerWriteBack(t *testing.T) { + skipIfNoDocker(t) + + pc := poolConfig{Name: "local", Addr: testLocalAddr()} + sbm, wsm := setupManagerWithWorkspace(t, pc) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ws, err := wsm.Create(ctx, workspace.CreateOptions{ + Name: "writeback-ws", Owner: "user", Node: pc.Name, + }) + require.NoError(t, err) + defer wsm.Delete(context.Background(), ws.ID, true) + + box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + co.WorkspaceID = ws.ID + }) + + _, err = box.Exec(ctx, []string{"sh", "-c", "echo 'from container' > /workspace/output.txt"}) + require.NoError(t, err) + + data, err := wsm.ReadFile(ctx, ws.ID, "output.txt") + require.NoError(t, err) + assert.Equal(t, "from container\n", string(data)) +} + +func TestWorkspace_ReadOnlyMount(t *testing.T) { + skipIfNoDocker(t) + + pc := poolConfig{Name: "local", Addr: testLocalAddr()} + sbm, wsm := setupManagerWithWorkspace(t, pc) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ws, err := wsm.Create(ctx, workspace.CreateOptions{ + Name: "ro-ws", Owner: "user", Node: pc.Name, + }) + require.NoError(t, err) + defer wsm.Delete(context.Background(), ws.ID, true) + + require.NoError(t, wsm.WriteFile(ctx, ws.ID, "readonly.txt", []byte("immutable"), 0644)) + + box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + co.WorkspaceID = ws.ID + co.MountMode = "ro" + }) + + result, err := box.Exec(ctx, []string{"cat", "/workspace/readonly.txt"}) + require.NoError(t, err) + assert.Equal(t, "immutable", result.Stdout) + + result, err = box.Exec(ctx, []string{"sh", "-c", "echo fail > /workspace/nope.txt 2>&1; echo $?"}) + require.NoError(t, err) + // Write to read-only mount should fail (non-zero exit or error message) + assert.True(t, result.ExitCode != 0 || result.Stdout != "0\n" || len(result.Stderr) > 0, + "expected write to read-only mount to fail") +} + +func TestWorkspace_CustomMountPath(t *testing.T) { + skipIfNoDocker(t) + + pc := poolConfig{Name: "local", Addr: testLocalAddr()} + sbm, wsm := setupManagerWithWorkspace(t, pc) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ws, err := wsm.Create(ctx, workspace.CreateOptions{ + Name: "custom-path-ws", Owner: "user", Node: pc.Name, + }) + require.NoError(t, err) + defer wsm.Delete(context.Background(), ws.ID, true) + + require.NoError(t, wsm.WriteFile(ctx, ws.ID, "data.json", []byte(`{"ok":true}`), 0644)) + + box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + co.WorkspaceID = ws.ID + co.MountPath = "/data" + }) + + result, err := box.Exec(ctx, []string{"cat", "/data/data.json"}) + require.NoError(t, err) + assert.Equal(t, `{"ok":true}`, result.Stdout) +} + +func TestWorkspace_BoxWorkspaceFS(t *testing.T) { + skipIfNoDocker(t) + + for _, pc := range testPools() { + if pc.Name == "local" { + // Local mode: sandbox and workspace use separate tai.Clients with + // different dataDirs, so Box.Workspace() writes to the sandbox volume + // while wsm reads from the workspace volume. Bind mount tests cover + // local workspace I/O end-to-end instead. + continue + } + t.Run(pc.Name, func(t *testing.T) { + sbm, wsm := setupManagerWithWorkspace(t, pc) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ws, err := wsm.Create(ctx, workspace.CreateOptions{ + Name: "fs-ws", Owner: "user", Node: pc.Name, + }) + require.NoError(t, err) + defer wsm.Delete(context.Background(), ws.ID, true) + + box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + co.WorkspaceID = ws.ID + }) + + wfs := box.Workspace() + if wfs == nil { + t.Skip("Workspace FS not available") + } + + require.NoError(t, wfs.WriteFile("via-box.txt", []byte("box wrote this"), 0644)) + + data, err := wsm.ReadFile(ctx, ws.ID, "via-box.txt") + require.NoError(t, err) + assert.Equal(t, "box wrote this", string(data)) + }) + } +} + +func TestWorkspace_LabelPersistence(t *testing.T) { + skipIfNoDocker(t) + + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + sbm, wsm := setupManagerWithWorkspace(t, pc) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ws, err := wsm.Create(ctx, workspace.CreateOptions{ + Name: "label-ws", Owner: "user", Node: pc.Name, + }) + require.NoError(t, err) + defer wsm.Delete(context.Background(), ws.ID, true) + + box := createTestBox(t, sbm, func(co *sandbox.CreateOptions) { + co.WorkspaceID = ws.ID + }) + + // WorkspaceID getter should reflect what was set + assert.Equal(t, ws.ID, box.WorkspaceID()) + + // Container should also carry the label (verify via exec reading env or + // just trust that buildTaiCreateOptions sets it โ€” the label is tested + // indirectly by TestWorkspace_NodeRouting which relies on correct routing) + info, err := box.Info(ctx) + require.NoError(t, err) + assert.Contains(t, []string{"running", "Running"}, info.Status) + }) + } +} diff --git a/sandbox/v2/docker/base/Dockerfile b/sandbox/v2/docker/base/Dockerfile index c6de363f..8ad0b125 100644 --- a/sandbox/v2/docker/base/Dockerfile +++ b/sandbox/v2/docker/base/Dockerfile @@ -8,6 +8,7 @@ RUN sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list 2>/dev/null || true RUN apt-get update && apt-get install -y --no-install-recommends \ + tini \ curl wget git ca-certificates gnupg lsb-release jq \ vim less tree \ iputils-ping net-tools dnsutils telnet netcat-openbsd \ @@ -34,5 +35,5 @@ COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh USER sandbox -ENTRYPOINT ["/entrypoint.sh"] +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] CMD ["sleep", "infinity"] diff --git a/sandbox/v2/docker/test/Dockerfile b/sandbox/v2/docker/test/Dockerfile index 7ccd063e..697fc551 100644 --- a/sandbox/v2/docker/test/Dockerfile +++ b/sandbox/v2/docker/test/Dockerfile @@ -24,5 +24,5 @@ ENV DISPLAY=:99 USER sandbox EXPOSE 5900 6080 -ENTRYPOINT ["/test-entrypoint.sh"] +ENTRYPOINT ["/usr/bin/tini", "--", "/test-entrypoint.sh"] CMD ["sleep", "infinity"] diff --git a/sandbox/v2/manager.go b/sandbox/v2/manager.go index 95fecc03..4779bd14 100644 --- a/sandbox/v2/manager.go +++ b/sandbox/v2/manager.go @@ -8,6 +8,7 @@ import ( "github.com/yaoapp/yao/tai" taisandbox "github.com/yaoapp/yao/tai/sandbox" + "github.com/yaoapp/yao/workspace" ) // Manager manages a pool of tai.Client connections and sandbox lifecycle. @@ -20,6 +21,7 @@ type Manager struct { mu sync.Mutex cancel context.CancelFunc grpcPort int + wsManager *workspace.Manager } func newManager(cfg Config) (*Manager, error) { @@ -176,6 +178,16 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) poolName = m.defaultPool } + // Workspace node binding: when WorkspaceID is set, resolve the workspace's + // bound node and force the container onto that pool. + if opts.WorkspaceID != "" && m.wsManager != nil { + node, err := m.wsManager.NodeForWorkspace(ctx, opts.WorkspaceID) + if err != nil { + return nil, fmt.Errorf("sandbox: resolve workspace %q: %w", opts.WorkspaceID, err) + } + poolName = node + } + pd := m.findPoolDef(poolName) if pd == nil { return nil, ErrPoolNotFound @@ -225,11 +237,13 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) policy: policy, labels: opts.Labels, idleTimeoutD: opts.IdleTimeout, + stopTimeoutD: opts.StopTimeout, createdAt: time.Now(), refreshToken: refresh, manager: m, vnc: opts.VNC, image: opts.Image, + workspaceID: opts.WorkspaceID, } box.lastCall.Store(time.Now().UnixMilli()) @@ -280,7 +294,7 @@ func (m *Manager) List(_ context.Context, opts ListOptions) ([]*Box, error) { return result, nil } -// Remove stops and removes a sandbox. +// Remove force-removes a sandbox (SIGKILL + delete). func (m *Manager) Remove(ctx context.Context, id string) error { v, ok := m.boxes.Load(id) if !ok { @@ -290,7 +304,6 @@ func (m *Manager) Remove(ctx context.Context, id string) error { client, err := m.getPool(b.pool) if err == nil { - client.Sandbox().Stop(ctx, b.containerID, 10*time.Second) client.Sandbox().Remove(ctx, b.containerID, true) } @@ -319,7 +332,7 @@ func (m *Manager) Cleanup(ctx context.Context) error { case LongRunning: if timeout := b.idleTimeout(); timeout > 0 && idle > timeout { if client, err := m.getPool(b.pool); err == nil { - client.Sandbox().Stop(ctx, b.containerID, 10*time.Second) + client.Sandbox().Stop(ctx, b.containerID, b.stopTimeout()) } } if lifetime := b.maxLifetime(); lifetime > 0 && now.Sub(b.createdAt) > lifetime { @@ -352,6 +365,13 @@ func (m *Manager) SetGRPCPort(port int) { m.grpcPort = port } +// SetWorkspaceManager links the workspace manager for workspace-aware container creation. +// When CreateOptions.WorkspaceID is set, the sandbox Manager uses the workspace Manager +// to resolve the workspace's bound node and force container routing. +func (m *Manager) SetWorkspaceManager(wm *workspace.Manager) { + m.wsManager = wm +} + func (m *Manager) cleanupLoop(ctx context.Context) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() @@ -448,6 +468,9 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID, "sandbox-pool": pd.Name, "sandbox-policy": string(opts.Policy), } + if opts.WorkspaceID != "" { + labels["workspace-id"] = opts.WorkspaceID + } for k, v := range opts.Labels { labels[k] = v } @@ -457,7 +480,7 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID, workDir = "/workspace" } - cmd := []string{"sleep", "infinity"} + cmd := []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 86400 & wait $!; done"} var ports []taisandbox.PortMapping for _, p := range opts.Ports { @@ -469,11 +492,29 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID, }) } + // Workspace bind mount + var binds []string + if opts.WorkspaceID != "" && m.wsManager != nil { + mountPath := opts.MountPath + if mountPath == "" { + mountPath = "/workspace" + } + mode := opts.MountMode + if mode == "" { + mode = "rw" + } + hostPath, _ := m.wsManager.MountPath(context.Background(), opts.WorkspaceID) + if hostPath != "" { + binds = append(binds, fmt.Sprintf("%s:%s:%s", hostPath, mountPath, mode)) + } + } + return taisandbox.CreateOptions{ Name: sandboxID, Image: opts.Image, Cmd: cmd, Env: env, + Binds: binds, WorkingDir: workDir, User: opts.User, Memory: opts.Memory, @@ -511,9 +552,65 @@ func (m *Manager) recoverBoxes(ctx context.Context, pd *Pool, client *tai.Client labels: c.Labels, createdAt: time.Now(), image: c.Image, + workspaceID: c.Labels["workspace-id"], manager: m, } box.lastCall.Store(time.Now().UnixMilli()) m.boxes.Store(sandboxID, box) } } + +// ImageExists reports whether the given image ref exists on the target pool node. +func (m *Manager) ImageExists(ctx context.Context, pool, ref string) (bool, error) { + client, err := m.getPool(pool) + if err != nil { + return false, err + } + return client.Image().Exists(ctx, ref) +} + +// 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). +func (m *Manager) PullImage(ctx context.Context, pool, ref string, opts ImagePullOptions) (<-chan taisandbox.PullProgress, error) { + client, err := m.getPool(pool) + if err != nil { + return nil, err + } + pullOpts := taisandbox.PullOptions{} + if opts.Auth != nil { + pullOpts.Auth = &taisandbox.RegistryAuth{ + Username: opts.Auth.Username, + Password: opts.Auth.Password, + Server: opts.Auth.Server, + } + } + return client.Image().Pull(ctx, ref, pullOpts) +} + +// EnsureImage checks whether the image exists on the pool node; if not, it +// pulls the image and blocks until the pull completes. Returns the first +// error encountered during pull. For K8s pools this is a no-op. +func (m *Manager) EnsureImage(ctx context.Context, pool, ref string, opts ImagePullOptions) error { + exists, err := m.ImageExists(ctx, pool, ref) + if err != nil { + return fmt.Errorf("image exists check: %w", err) + } + if exists { + return nil + } + + ch, err := m.PullImage(ctx, pool, ref, opts) + if err != nil { + return fmt.Errorf("image pull: %w", err) + } + if ch == nil { + return nil + } + + for p := range ch { + if p.Error != "" { + return fmt.Errorf("image pull %q: %s", ref, p.Error) + } + } + return nil +} diff --git a/sandbox/v2/manager_lifecycle_test.go b/sandbox/v2/manager_lifecycle_test.go index b4b67c05..c42d049f 100644 --- a/sandbox/v2/manager_lifecycle_test.go +++ b/sandbox/v2/manager_lifecycle_test.go @@ -52,6 +52,7 @@ func TestIdleCleanup(t *testing.T) { m := setupManagerForPool(t, pc, func(p *sandbox.Pool) { p.IdleTimeout = 1 * time.Second }) + ensureTestImage(t, m, pc.Name) ctx := context.Background() box, err := m.Create(ctx, sandbox.CreateOptions{ diff --git a/sandbox/v2/manager_test.go b/sandbox/v2/manager_test.go index 5d7d1007..a6e6be5f 100644 --- a/sandbox/v2/manager_test.go +++ b/sandbox/v2/manager_test.go @@ -127,6 +127,7 @@ func TestRemove(t *testing.T) { for _, pc := range testPools() { t.Run(pc.Name, func(t *testing.T) { m := setupManagerForPool(t, pc) + ensureTestImage(t, m, pc.Name) ctx := context.Background() box, err := m.Create(ctx, sandbox.CreateOptions{ Image: testImage(), @@ -156,6 +157,7 @@ func TestPoolLimits_MaxTotal(t *testing.T) { m := setupManagerForPool(t, pc, func(p *sandbox.Pool) { p.MaxTotal = 1 }) + ensureTestImage(t, m, pc.Name) box1 := createTestBox(t, m) _ = box1 @@ -236,10 +238,14 @@ func TestMultiPool(t *testing.T) { var sps []sandbox.Pool for _, pc := range pools { - sps = append(sps, sandbox.Pool{Name: pc.Name, Addr: pc.Addr}) + sps = append(sps, sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options}) } m := setupManager(t, sps...) + for _, pc := range pools { + ensureTestImage(t, m, pc.Name) + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() diff --git a/sandbox/v2/testutils_test.go b/sandbox/v2/testutils_test.go index 4347f744..53e5fbb7 100644 --- a/sandbox/v2/testutils_test.go +++ b/sandbox/v2/testutils_test.go @@ -2,20 +2,28 @@ package sandbox_test import ( "context" + "fmt" "os" + "strconv" "testing" "time" sandbox "github.com/yaoapp/yao/sandbox/v2" + "github.com/yaoapp/yao/tai" + "github.com/yaoapp/yao/tai/volume" + "github.com/yaoapp/yao/workspace" ) type poolConfig struct { - Name string - Addr string + Name string + Addr string + Options []tai.Option } -// testPools returns all available pool configurations for dual-mode testing. -// Always includes "local"; includes "remote" when SANDBOX_TEST_REMOTE_ADDR is set. +// 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 proxy โ†’ Docker) +// - k8s: when TAI_TEST_K8S_HOST + TAI_TEST_KUBECONFIG are set (Tai proxy โ†’ K8s) func testPools() []poolConfig { pools := []poolConfig{ {Name: "local", Addr: testLocalAddr()}, @@ -23,6 +31,25 @@ func testPools() []poolConfig { if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" { pools = append(pools, poolConfig{Name: "remote", Addr: addr}) } + if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" { + kubeconfig := os.Getenv("TAI_TEST_KUBECONFIG") + if kubeconfig == "" { + return pools + } + addr := fmt.Sprintf("tai://%s", host) + opts := []tai.Option{ + tai.K8s, + tai.WithKubeConfig(kubeconfig), + tai.WithPorts(tai.Ports{ + K8s: envPort("TAI_TEST_K8S_PORT", 6443), + GRPC: envPort("TAI_TEST_GRPC_PORT", 9100), + }), + } + if ns := os.Getenv("TAI_TEST_K8S_NAMESPACE"); ns != "" { + opts = append(opts, tai.WithNamespace(ns)) + } + pools = append(pools, poolConfig{Name: "k8s", Addr: addr, Options: opts}) + } return pools } @@ -55,6 +82,15 @@ func testImage() string { return "alpine:latest" } +func envPort(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + if p, err := strconv.Atoi(v); err == nil { + return p + } + } + return fallback +} + func setupManager(t *testing.T, pools ...sandbox.Pool) *sandbox.Manager { t.Helper() cfg := sandbox.Config{Pool: pools} @@ -70,13 +106,49 @@ func setupManager(t *testing.T, pools ...sandbox.Pool) *sandbox.Manager { func setupManagerForPool(t *testing.T, pc poolConfig, mutators ...func(*sandbox.Pool)) *sandbox.Manager { t.Helper() - pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr} + pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options} for _, fn := range mutators { fn(&pool) } return setupManager(t, pool) } +// setupManagerWithWorkspace creates a sandbox Manager with a linked workspace Manager. +// Returns both managers and a helper to create workspaces on the given pool's node. +func setupManagerWithWorkspace(t *testing.T, pc poolConfig) (*sandbox.Manager, *workspace.Manager) { + t.Helper() + sbm := setupManagerForPool(t, pc) + + var wsClient *tai.Client + var err error + if pc.Addr == "local" || pc.Addr == "" { + dataDir := t.TempDir() + vol := volume.NewLocal(dataDir) + wsClient, err = tai.New("local", tai.WithVolume(vol), tai.WithDataDir(dataDir)) + } else { + wsClient, err = tai.New(pc.Addr, pc.Options...) + } + if err != nil { + t.Fatalf("tai.New for workspace: %v", err) + } + t.Cleanup(func() { wsClient.Close() }) + + wsm := workspace.NewManager(map[string]*tai.Client{pc.Name: wsClient}) + sbm.SetWorkspaceManager(wsm) + return sbm, wsm +} + +// ensureTestImage guarantees testImage() is available on the given pool before +// container creation. Safe for all modes (Docker pull; K8s no-op). +func ensureTestImage(t *testing.T, m *sandbox.Manager, pool string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + if err := m.EnsureImage(ctx, pool, testImage(), sandbox.ImagePullOptions{}); err != nil { + t.Fatalf("EnsureImage(%s, %s): %v", pool, testImage(), err) + } +} + func createTestBox(t *testing.T, m *sandbox.Manager, opts ...func(*sandbox.CreateOptions)) *sandbox.Box { t.Helper() co := sandbox.CreateOptions{ @@ -86,8 +158,24 @@ func createTestBox(t *testing.T, m *sandbox.Manager, opts ...func(*sandbox.Creat for _, fn := range opts { fn(&co) } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + + pool := co.Pool + if pool == "" { + pools := m.Pools() + if len(pools) > 0 { + pool = pools[0].Name + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() + + if pool != "" { + if err := m.EnsureImage(ctx, pool, co.Image, sandbox.ImagePullOptions{}); err != nil { + t.Fatalf("EnsureImage(%s, %s): %v", pool, co.Image, err) + } + } + box, err := m.Create(ctx, co) if err != nil { t.Fatalf("Create: %v", err) diff --git a/sandbox/v2/types.go b/sandbox/v2/types.go index 4375ae24..4d72a0c4 100644 --- a/sandbox/v2/types.go +++ b/sandbox/v2/types.go @@ -16,6 +16,8 @@ const ( Persistent LifecyclePolicy = "persistent" ) +const DefaultStopTimeout = 2 * time.Second + type Pool struct { Name string Addr string @@ -24,6 +26,7 @@ type Pool struct { MaxTotal int IdleTimeout time.Duration MaxLifetime time.Duration + StopTimeout time.Duration // SIGTERM grace period before SIGKILL; 0 = DefaultStopTimeout } type PoolInfo struct { @@ -59,6 +62,12 @@ type CreateOptions struct { Ports []PortMapping Policy LifecyclePolicy IdleTimeout time.Duration + + StopTimeout time.Duration // SIGTERM grace period; 0 = pool default or DefaultStopTimeout + + WorkspaceID string // workspace to mount; empty = no workspace + MountMode string // "rw" (default) or "ro" + MountPath string // container path; default "/workspace" } type ListOptions struct { @@ -133,6 +142,18 @@ func WithHeaders(headers map[string]string) AttachOption { } } +// ImagePullOptions configures an image pull operation. +type ImagePullOptions struct { + Auth *RegistryAuth // nil = anonymous / public +} + +// RegistryAuth holds credentials for a private container registry. +type RegistryAuth struct { + Username string + Password string + Server string +} + type ServiceConn struct { Read func() ([]byte, error) Write func(data []byte) error diff --git a/tai/sandbox/client_accessor.go b/tai/sandbox/client_accessor.go new file mode 100644 index 00000000..e6aedbc2 --- /dev/null +++ b/tai/sandbox/client_accessor.go @@ -0,0 +1,20 @@ +package sandbox + +import "github.com/docker/docker/client" + +// dockerCliAccessor is implemented by sandbox types that hold a Docker client. +type dockerCliAccessor interface { + dockerClient() *client.Client +} + +func (l *local) dockerClient() *client.Client { return l.core.cli } +func (d *dockerSandbox) dockerClient() *client.Client { return d.core.cli } + +// DockerCli extracts the underlying Docker SDK client from a Sandbox. +// Returns nil if the Sandbox is not Docker-based (e.g. K8s). +func DockerCli(sb Sandbox) *client.Client { + if a, ok := sb.(dockerCliAccessor); ok { + return a.dockerClient() + } + return nil +} diff --git a/tai/sandbox/image.go b/tai/sandbox/image.go new file mode 100644 index 00000000..b489f2a5 --- /dev/null +++ b/tai/sandbox/image.go @@ -0,0 +1,43 @@ +package sandbox + +import ( + "context" + "time" +) + +// Image manages container images on a runtime node. +type Image interface { + Exists(ctx context.Context, ref string) (bool, error) + Pull(ctx context.Context, ref string, opts PullOptions) (<-chan PullProgress, error) + Remove(ctx context.Context, ref string, force bool) error + List(ctx context.Context) ([]ImageInfo, error) +} + +// PullOptions configures an image pull operation. +type PullOptions struct { + Auth *RegistryAuth // nil = anonymous / public +} + +// RegistryAuth holds credentials for a private container registry. +type RegistryAuth struct { + Username string + Password string + Server string // e.g. "ghcr.io", "registry.example.com" +} + +// PullProgress reports real-time progress of an image pull. +type PullProgress struct { + Status string // "Pulling fs layer", "Downloading", "Extracting", "Pull complete", etc. + Layer string // layer digest / short ID + Current int64 // bytes completed + Total int64 // bytes total (0 if unknown) + Error string // non-empty on failure +} + +// ImageInfo describes a local image. +type ImageInfo struct { + ID string + Tags []string + Size int64 + Created time.Time +} diff --git a/tai/sandbox/image_docker.go b/tai/sandbox/image_docker.go new file mode 100644 index 00000000..96fef9a3 --- /dev/null +++ b/tai/sandbox/image_docker.go @@ -0,0 +1,132 @@ +package sandbox + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "time" + + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/registry" + "github.com/docker/docker/client" +) + +// dockerImage implements Image using the Docker SDK. +// Shared by both local and dockerSandbox (via Tai proxy) modes. +type dockerImage struct { + cli *client.Client +} + +// NewDockerImage creates an Image backed by a Docker client. +func NewDockerImage(cli *client.Client) Image { + return &dockerImage{cli: cli} +} + +func (d *dockerImage) Exists(ctx context.Context, ref string) (bool, error) { + _, _, err := d.cli.ImageInspectWithRaw(ctx, ref) + if err != nil { + if client.IsErrNotFound(err) { + return false, nil + } + return false, fmt.Errorf("image inspect %q: %w", ref, err) + } + return true, nil +} + +func (d *dockerImage) Pull(ctx context.Context, ref string, opts PullOptions) (<-chan PullProgress, error) { + pullOpts := image.PullOptions{} + if opts.Auth != nil { + encoded, err := encodeAuth(opts.Auth) + if err != nil { + return nil, err + } + pullOpts.RegistryAuth = encoded + } + + reader, err := d.cli.ImagePull(ctx, ref, pullOpts) + if err != nil { + return nil, fmt.Errorf("image pull %q: %w", ref, err) + } + + ch := make(chan PullProgress, 32) + go func() { + defer close(ch) + defer reader.Close() + decodePullStream(reader, ch) + }() + return ch, nil +} + +func (d *dockerImage) Remove(ctx context.Context, ref string, force bool) error { + _, err := d.cli.ImageRemove(ctx, ref, image.RemoveOptions{Force: force, PruneChildren: true}) + if err != nil { + return fmt.Errorf("image remove %q: %w", ref, err) + } + return nil +} + +func (d *dockerImage) List(ctx context.Context) ([]ImageInfo, error) { + imgs, err := d.cli.ImageList(ctx, image.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("image list: %w", err) + } + result := make([]ImageInfo, len(imgs)) + for i, img := range imgs { + result[i] = ImageInfo{ + ID: img.ID, + Tags: img.RepoTags, + Size: img.Size, + Created: time.Unix(img.Created, 0), + } + } + return result, nil +} + +// dockerPullEvent mirrors the JSON lines emitted by Docker's ImagePull stream. +type dockerPullEvent struct { + Status string `json:"status"` + ID string `json:"id"` + ProgressDetail struct { + Current int64 `json:"current"` + Total int64 `json:"total"` + } `json:"progressDetail"` + Error string `json:"error"` +} + +func decodePullStream(r io.Reader, ch chan<- PullProgress) { + dec := json.NewDecoder(r) + for { + var ev dockerPullEvent + if err := dec.Decode(&ev); err != nil { + if err != io.EOF { + ch <- PullProgress{Error: err.Error()} + } + return + } + p := PullProgress{ + Status: ev.Status, + Layer: ev.ID, + Current: ev.ProgressDetail.Current, + Total: ev.ProgressDetail.Total, + } + if ev.Error != "" { + p.Error = ev.Error + } + ch <- p + } +} + +func encodeAuth(auth *RegistryAuth) (string, error) { + cfg := registry.AuthConfig{ + Username: auth.Username, + Password: auth.Password, + ServerAddress: auth.Server, + } + data, err := json.Marshal(cfg) + if err != nil { + return "", fmt.Errorf("encode registry auth: %w", err) + } + return base64.URLEncoding.EncodeToString(data), nil +} diff --git a/tai/sandbox/image_k8s.go b/tai/sandbox/image_k8s.go new file mode 100644 index 00000000..487a30ff --- /dev/null +++ b/tai/sandbox/image_k8s.go @@ -0,0 +1,25 @@ +package sandbox + +import "context" + +// k8sImage is a no-op Image for K8s mode. +// Image pulling is handled by kubelet based on imagePullPolicy and imagePullSecrets. +type k8sImage struct{} + +func NewK8sImage() Image { return &k8sImage{} } + +func (k *k8sImage) Exists(_ context.Context, _ string) (bool, error) { + return true, nil +} + +func (k *k8sImage) Pull(_ context.Context, _ string, _ PullOptions) (<-chan PullProgress, error) { + return nil, nil +} + +func (k *k8sImage) Remove(_ context.Context, _ string, _ bool) error { + return nil +} + +func (k *k8sImage) List(_ context.Context) ([]ImageInfo, error) { + return nil, nil +} diff --git a/tai/sandbox/k8s.go b/tai/sandbox/k8s.go index 8bcd29d8..9ea5c826 100644 --- a/tai/sandbox/k8s.go +++ b/tai/sandbox/k8s.go @@ -113,7 +113,7 @@ func (s *k8sSandbox) Create(ctx context.Context, opts CreateOptions) (string, er container := corev1.Container{ Name: "main", Image: opts.Image, - Command: opts.Cmd, + Args: opts.Cmd, Env: envVars, WorkingDir: opts.WorkingDir, } @@ -160,9 +160,16 @@ func (s *k8sSandbox) Create(ctx context.Context, opts CreateOptions) (string, er } func (s *k8sSandbox) Start(ctx context.Context, id string) error { - // K8s pods start automatically after creation. - // Wait briefly for the pod to leave Pending. - for i := 0; i < 30; i++ { + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 60*time.Second) + defer cancel() + } + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { pod, err := s.cli.CoreV1().Pods(s.ns).Get(ctx, id, metav1.GetOptions{}) if err != nil { return fmt.Errorf("get pod: %w", err) @@ -170,9 +177,13 @@ func (s *k8sSandbox) Start(ctx context.Context, id string) error { if pod.Status.Phase == corev1.PodRunning || pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed { return nil } - time.Sleep(1 * time.Second) + + select { + case <-ctx.Done(): + return fmt.Errorf("pod %s did not reach Running: %w", id, ctx.Err()) + case <-ticker.C: + } } - return fmt.Errorf("pod %s did not reach Running within 30s", id) } func (s *k8sSandbox) Stop(ctx context.Context, id string, timeout time.Duration) error { diff --git a/tai/sandbox/local.go b/tai/sandbox/local.go index 916d9baf..9d276ac7 100644 --- a/tai/sandbox/local.go +++ b/tai/sandbox/local.go @@ -3,7 +3,6 @@ package sandbox import ( "context" "fmt" - "runtime" "time" "github.com/docker/docker/client" @@ -36,7 +35,7 @@ func NewLocal(addr string) (Sandbox, error) { } func (l *local) Create(ctx context.Context, opts CreateOptions) (string, error) { - return l.core.create(ctx, opts, opts.VNC && needsPortMapping()) + return l.core.create(ctx, opts, opts.VNC) } func (l *local) Start(ctx context.Context, id string) error { @@ -71,12 +70,6 @@ func (l *local) Close() error { return l.core.cli.Close() } -// needsPortMapping returns true on platforms where container IPs are not -// directly reachable (macOS Docker Desktop, Windows). -func needsPortMapping() bool { - return runtime.GOOS == "darwin" || runtime.GOOS == "windows" -} - func portStr(p int) string { if p == 0 { return "" diff --git a/tai/tai.go b/tai/tai.go index 43695bfa..1fc52ec0 100644 --- a/tai/tai.go +++ b/tai/tai.go @@ -77,6 +77,12 @@ func WithNamespace(ns string) Option { return optionFunc(func(c *config) { c.namespace = ns }) } +// WithVolume injects a custom Volume implementation. +// Useful for testing workspace operations without Docker. +func WithVolume(vol volume.Volume) Option { + return optionFunc(func(c *config) { c.volume = vol }) +} + type config struct { runtime Runtime ports Ports @@ -85,6 +91,7 @@ type config struct { dataDir string kubeConfig string namespace string + volume volume.Volume // override volume (for testing without Docker) } func defaultPorts() Ports { @@ -121,8 +128,10 @@ type Client struct { host string addr string ports Ports + dataDir string // host-side data directory for local volume vol volume.Volume sb sandbox.Sandbox + img sandbox.Image prx proxy.Proxy vc vnc.VNC grpcConn *grpc.ClientConn @@ -170,18 +179,27 @@ func New(addr string, opts ...Option) (*Client, error) { func (c *Client) initLocal(cfg *config) (*Client, error) { sb, err := sandbox.NewLocal(c.addr) - if err != nil { + if err != nil && cfg.volume == nil { return nil, err } - c.sb = sb - c.prx = proxy.NewLocal(sb) - c.vc = vnc.NewLocal(sb) - - dataDir := cfg.dataDir - if dataDir == "" { - dataDir = "/tmp/tai-volumes" + if sb != nil { + c.sb = sb + c.img = sandbox.NewDockerImage(sandbox.DockerCli(sb)) + c.prx = proxy.NewLocal(sb) + c.vc = vnc.NewLocal(sb) + } + + if cfg.volume != nil { + c.vol = cfg.volume + c.dataDir = cfg.dataDir + } else { + dataDir := cfg.dataDir + if dataDir == "" { + dataDir = "/tmp/tai-volumes" + } + c.dataDir = dataDir + c.vol = volume.NewLocal(dataDir) } - c.vol = volume.NewLocal(dataDir) return c, nil } @@ -219,6 +237,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) { return nil, err } c.sb = sb + c.img = sandbox.NewK8sImage() default: dockerPort := c.ports.Docker if dockerPort == 0 { @@ -231,6 +250,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) { return nil, err } c.sb = sb + c.img = sandbox.NewDockerImage(sandbox.DockerCli(sb)) } hc := cfg.httpClient @@ -266,6 +286,10 @@ func (c *Client) Close() error { // Volume returns the Volume IO layer. Never nil. func (c *Client) Volume() volume.Volume { return c.vol } +// DataDir returns the host-side data directory used by the local volume. +// Empty for remote (Tai gRPC) connections โ€” the Tai server manages paths. +func (c *Client) DataDir() string { return c.dataDir } + // Workspace returns an fs.FS-compatible filesystem for the given session. func (c *Client) Workspace(sessionID string) workspace.FS { return workspace.New(c.vol, sessionID) @@ -274,6 +298,9 @@ func (c *Client) Workspace(sessionID string) workspace.FS { // Sandbox returns the container lifecycle manager. Never nil. func (c *Client) Sandbox() sandbox.Sandbox { return c.sb } +// Image returns the container image manager. Never nil. +func (c *Client) Image() sandbox.Image { return c.img } + // Proxy returns the HTTP reverse proxy helper. Never nil. func (c *Client) Proxy() proxy.Proxy { return c.prx } diff --git a/workspace/DESIGN.md b/workspace/DESIGN.md new file mode 100644 index 00000000..d5398c3d --- /dev/null +++ b/workspace/DESIGN.md @@ -0,0 +1,600 @@ +# Workspace Design Document + +> **Status**: Draft +> **Module**: `workspace` (top-level, parallel to `sandbox/v2`) +> **Depends on**: `tai` SDK (Volume, VolumeProvider, Sandbox), `sandbox/v2` Manager + +--- + +## Overview + +Workspace is a **first-class, persistent storage entity** independent of containers, chat sessions, and user sessions. It represents a user's project files โ€” source code, configs, build artifacts โ€” that can be mounted into any number of ephemeral containers. + +Workspace is the **anchor point** for container scheduling: when a Workspace is created on a specific Tai node (host machine), all subsequent containers that reference it are automatically routed to the same node, because bind mounts require co-location on the same physical host. + +--- + +## 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. + +--- + +## Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Application Layer โ”‚ +โ”‚ โ”‚ +โ”‚ Workspace Management UI Chat Interface โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Create / Delete / UI โ”‚ โ”‚ Select Workspaceโ”‚ โ”‚ +โ”‚ โ”‚ Browse / Upload โ”‚ โ”‚ Start Chat โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ + โ–ผ โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Yao Engine โ”‚ +โ”‚ โ”‚ +โ”‚ workspace.Manager sandbox.Manager โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ CRUD โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ Mount workspace โ”‚ โ”‚ +โ”‚ โ”‚ File I/O โ”‚ โ”‚ Route to node โ”‚ โ”‚ +โ”‚ โ”‚ Node binding โ”‚ โ”‚ Create containerโ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ + โ–ผ โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Tai Node (Host) โ”‚ +โ”‚ โ”‚ +โ”‚ Volume gRPC Container Runtime โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ ReadFile โ”‚ โ”‚ Container A (rw) โ”‚ โ”‚ +โ”‚ โ”‚ WriteFile โ”‚ โ”‚ โ””โ”€ /workspace โ”€โ” โ”‚ โ”‚ +โ”‚ โ”‚ ListDir โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ SyncPush/Pull โ”‚ โ”‚ Container B (ro) โ”‚ โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ””โ”€ /workspace โ”€โ”โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”ผโ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ–ผ โ–ผโ–ผ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ /data/ws/{workspace-id}/ โ”‚ โ”‚ +โ”‚ โ”‚ โ”œโ”€โ”€ .workspace.json (metadata) โ”‚ โ”‚ +โ”‚ โ”‚ โ”œโ”€โ”€ src/ โ”‚ โ”‚ +โ”‚ โ”‚ โ”œโ”€โ”€ package.json โ”‚ โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€ ... โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ”‚ VolumeProvider โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ BindMount โ”‚ DockerVolume โ”‚ K8s PVC โ”‚ โ”‚ +โ”‚ โ”‚ (default) โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +--- + +## Core Design + +### Node Binding + +Workspace is physically stored on a Tai node's disk. **Bind mount requires Workspace and container to be on the same host.** Therefore: + +- **Workspace binds to a specific Tai node at creation time.** This binding is immutable. +- When a container references a Workspace (`CreateOptions.WorkspaceID`), the container is **automatically routed to the same Tai node** โ€” the caller does not (and should not) specify a Pool. +- One Tai node = one Pool = one host machine. These are equivalent in the current architecture. + +``` +ๅˆ›ๅปบ Workspace: + ็”จๆˆท้€‰ๆ‹ฉ่Š‚็‚น "gpu-server" โ†’ workspace.Create(opts) + โ†’ Tai "gpu-server" ไธŠๅˆ›ๅปบ /data/ws/ws-123/ + +ๅˆ›ๅปบๅฎนๅ™จ๏ผˆ้€‰ไบ† Workspace๏ผ‰: + โ†’ sandbox.Create(opts, WorkspaceID: "ws-123") + โ†’ Manager ๆŸฅๅˆฐ ws-123 ็ป‘ๅœจ "gpu-server" + โ†’ ่‡ชๅŠจ่ทฏ็”ฑๅˆฐ "gpu-server" Pool + โ†’ bind mount /data/ws/ws-123:/workspace:rw โœ“ ๅŒๆœบ + +ๅˆ›ๅปบๅฎนๅ™จ๏ผˆๆฒก้€‰ Workspace๏ผ‰: + โ†’ ๆŒ‰ๅŽŸ้€ป่พ‘้€‰ Pool๏ผˆ็”จๆˆทๆŒ‡ๅฎšๆˆ–้ป˜่ฎค๏ผ‰ +``` + +This makes Workspace the **scheduling anchor**: once a Workspace is chosen, the node is determined. + +### 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 + Node string // Tai node name (= Pool name); set at creation, immutable + Labels map[string]string // arbitrary metadata + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +`Node` is the critical field: it pins this Workspace to a specific machine. All container operations referencing this Workspace are routed to this node. + +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. + +--- + +## API Design + +### workspace.Manager + +Workspace has its own manager, separate from `sandbox.Manager`. It owns Workspace CRUD and file I/O. + +```go +package workspace + +type Manager struct { + pools map[string]*tai.Client // node name โ†’ tai client (shared with sandbox.Manager) +} + +// NewManager creates a workspace manager with the given pools. +// Pools are shared with sandbox.Manager โ€” both reference the same tai.Client instances. +func NewManager(pools map[string]*tai.Client) *Manager +``` + +### Workspace CRUD + +```go +type CreateOptions struct { + ID string // explicit ID; empty = auto-generate (uuid) + Name string // human-readable name + Owner string // user ID + Node string // target Tai node (required) + Labels map[string]string +} + +type ListOptions struct { + Owner string // filter by owner; empty = all + Node string // filter by node; empty = all +} + +// Create allocates storage on the target node and persists metadata. +func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, error) + +// Get returns a workspace by ID. +// Checks the metadata file on the bound node. +func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error) + +// List returns workspaces, optionally filtered. +func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, error) + +// Delete removes workspace storage from the node. +// Fails if containers currently mount it (unless force=true). +func (m *Manager) Delete(ctx context.Context, id string, force bool) error + +// Update modifies workspace metadata (Name, Labels). +// Node and Owner are immutable after creation. +func (m *Manager) Update(ctx context.Context, id string, opts UpdateOptions) (*Workspace, error) + +type UpdateOptions struct { + Name *string // nil = no change + Labels map[string]string // nil = no change; non-nil replaces all +} +``` + +### File I/O (no container needed) + +File operations go through the Tai `Volume` gRPC service, using the Workspace ID as the session identifier. No container is needed. + +```go +// FS returns an fs.FS view of the workspace, backed by Tai Volume gRPC. +func (m *Manager) FS(ctx context.Context, id string) (workspace.FS, error) + +// ReadFile reads a file from the workspace. +func (m *Manager) ReadFile(ctx context.Context, id string, path string) ([]byte, error) + +// WriteFile writes a file to the workspace. +func (m *Manager) WriteFile(ctx context.Context, id string, path string, data []byte, perm os.FileMode) error + +// ListDir lists entries in a workspace directory. +func (m *Manager) ListDir(ctx context.Context, id string, path string) ([]DirEntry, error) + +// Remove deletes a file or directory from the workspace. +func (m *Manager) Remove(ctx context.Context, id string, path string) error + +// SyncPush uploads a local directory tree to the workspace. +func (m *Manager) SyncPush(ctx context.Context, id string, localPath string) error + +// SyncPull downloads the workspace to a local directory. +func (m *Manager) SyncPull(ctx context.Context, id string, localPath string) error +``` + +These are thin wrappers around `tai.Client.Volume().{ReadFile,WriteFile,ListDir,...}` โ€” the Tai SDK already implements all of these. + +--- + +## Integration with Sandbox + +### sandbox.CreateOptions changes + +```go +type CreateOptions struct { + // ... existing fields ... + + WorkspaceID string // workspace to mount; empty = no workspace + MountMode MountMode // "rw" (default) or "ro" + MountPath string // container path; default "/workspace" +} +``` + +### Container creation flow + +When `WorkspaceID` is set in `CreateOptions`, the sandbox Manager: + +``` +Manager.Create(ctx, CreateOptions{ + Image: "yaoapp/workspace:latest", + WorkspaceID: "ws-abc123", + MountMode: MountRW, +}) + + 1. Validate CreateOptions (image required, etc.) + 2. If WorkspaceID is set: + a. ws := workspaceManager.Get(ctx, workspaceID) + b. Force Pool = ws.Node (override any user-specified Pool) + c. spec := taiClient.VolumeProvider().MountSpec(workspaceID) + d. Inject mount into container create: + - 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 { + sessionID := b.workspaceID + if sessionID == "" { + sessionID = b.id // backward compatible + } + client, _ := b.manager.getPool(b.pool) + return client.Workspace(sessionID) +} +``` + +Multiple boxes mounting the same workspace -> same `sessionID` -> same files via Volume API. + +--- + +## Metadata Storage + +Workspace metadata (ID, Name, Owner, Node, Labels, timestamps) is stored as a JSON file inside the workspace directory. + +### Storage path + +``` +/data/ws/{id}/.workspace.json +``` + +### Schema + +```json +{ + "id": "ws-abc123", + "name": "my-react-app", + "owner": "user-001", + "node": "gpu-server", + "labels": {"project": "frontend"}, + "created_at": "2026-03-05T10:00:00Z", + "updated_at": "2026-03-05T12:30:00Z" +} +``` + +### Operations + +| Operation | Implementation | +|-----------|---------------| +| Create | `Volume.WriteFile(id, ".workspace.json", json)` + `Volume.ResolvePath(id)` | +| Get | `Volume.ReadFile(id, ".workspace.json")` โ†’ unmarshal | +| List | `Volume.ListDir("")` โ†’ iterate dirs โ†’ read `.workspace.json` each | +| Update | Read โ†’ merge โ†’ `Volume.WriteFile(id, ".workspace.json", json)` | +| Delete | `Volume.Cleanup(id)` (removes entire dir) | + +Phase 1 strategy: simple JSON files, zero external dependencies. Can migrate to SQLite or Yao's built-in DB if query/filter performance becomes a bottleneck. + +--- + +## Node Management + +### Listing available nodes + +Application layer needs to present available nodes when user creates a Workspace. This comes from the sandbox Manager's pool configuration: + +```go +// In workspace.Manager or sandbox.Manager +func (m *Manager) Nodes() []NodeInfo + +type NodeInfo struct { + Name string // pool name = node name, e.g. "gpu-server" + Addr string // tai:// address + Online bool // is tai client connected + // Can be extended with capacity info later +} +``` + +### Dynamic node configuration + +Nodes are configured at the application level (Yao settings/config). When a node is added or removed, both `workspace.Manager` and `sandbox.Manager` share the updated pool map. The Pool configuration API (from `sandbox/v2`) handles this โ€” Workspace inherits it. + +``` +Application Config: + nodes: + - name: "local" + addr: "tai://localhost" + - name: "gpu-server" + addr: "tai://192.168.1.100:9527" + +โ†’ Both managers share: + pools["local"] = tai.Client("tai://localhost") + pools["gpu-server"] = tai.Client("tai://192.168.1.100:9527") +``` + +### Node failure handling + +If a Tai node goes offline: +- Workspace CRUD for that node: returns error (node unreachable) +- Container creation referencing a Workspace on that node: returns error +- Workspaces on that node are not lost โ€” data is still on the node's disk, will be available when node comes back online +- No automatic migration (Phase 1). Can add migration (rsync between nodes) later if needed. + +--- + +## User Flows + +### Flow 1: Workspace management UI + +``` +1. User opens Workspace management UI + โ†’ API: workspace.List(owner: "user-001") + โ†’ Returns list of workspaces with metadata + +2. User creates workspace + โ†’ UI shows available nodes (from Nodes() API) + โ†’ User selects "gpu-server" + โ†’ API: workspace.Create({ name: "my-project", node: "gpu-server" }) + โ†’ Directory /data/ws/ws-123/ created on gpu-server + โ†’ .workspace.json written + +3. User uploads files + โ†’ API: workspace.WriteFile("ws-123", "src/main.go", data) + โ†’ File written to /data/ws/ws-123/src/main.go via Volume gRPC + +4. User browses files + โ†’ API: workspace.ListDir("ws-123", "src/") + โ†’ Returns file listing + +5. User deletes workspace + โ†’ API: workspace.Delete("ws-123") + โ†’ Checks no active mounts โ†’ removes /data/ws/ws-123/ +``` + +### Flow 2: Chat with Workspace + +``` +1. User opens Chat + โ†’ Chat UI shows workspace selector + โ†’ User picks "my-project" (ws-123, on node "gpu-server") + +2. Agent needs a container: + โ†’ sandbox.Create({ + image: "yaoapp/workspace:latest", + workspace_id: "ws-123", + mount_mode: "rw", + }) + โ†’ Manager resolves ws-123.node = "gpu-server" + โ†’ Container created on "gpu-server" Pool + โ†’ -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/ + +4. User opens new Chat, selects same workspace + โ†’ New container, same workspace, all files still there +``` + +### Flow 3: Long-running Runtime + Chat + +``` +1. User starts Runtime container for workspace: + โ†’ sandbox.Create({ + image: "node:20", + workspace_id: "ws-123", + mount_mode: "rw", + policy: "persistent", + ports: [{ container: 3000 }], + }) + โ†’ Container starts on "gpu-server" + โ†’ -v /data/ws/ws-123:/workspace:rw + โ†’ Inside: cd /workspace && npm install && npm run dev + +2. User accesses dev server via proxy + โ†’ box.Proxy(ctx, 3000, "/") + +3. User opens Chat with same workspace: + โ†’ Second container created on "gpu-server" + โ†’ Same workspace mounted + โ†’ Agent modifies source โ†’ Runtime hot-reloads + +4. Chat ends, chat container destroyed + โ†’ Runtime container keeps running + โ†’ Workspace files persist +``` + +--- + +## Process & JSAPI + +### Process registration + +| Process | Args | Returns | +|---------|------|---------| +| `workspace.Create` | `options` (CreateOptions JSON) | Workspace | +| `workspace.Get` | `id` | Workspace | +| `workspace.List` | `options` (ListOptions JSON) | []Workspace | +| `workspace.Update` | `id`, `options` (UpdateOptions JSON) | Workspace | +| `workspace.Delete` | `id`, `force?` | โ€” | +| `workspace.ReadFile` | `id`, `path` | file content | +| `workspace.WriteFile` | `id`, `path`, `data` | โ€” | +| `workspace.ListDir` | `id`, `path` | []DirEntry | +| `workspace.Remove` | `id`, `path` | โ€” | +| `workspace.Nodes` | โ€” | []NodeInfo | + +### JSAPI + +```javascript +// Workspace CRUD +var ws = Workspace.Create({ name: "my-project", node: "gpu-server" }) +var ws = Workspace.Get("ws-abc123") +var list = Workspace.List({ owner: "user-001" }) +Workspace.Update("ws-abc123", { name: "new-name" }) +Workspace.Delete("ws-abc123") + +// File operations (no container needed) +var data = Workspace.ReadFile("ws-abc123", "src/main.go") +Workspace.WriteFile("ws-abc123", "src/main.go", "package main\n...") +var entries = Workspace.ListDir("ws-abc123", "src/") +Workspace.Remove("ws-abc123", "tmp.txt") + +// List available nodes +var nodes = Workspace.Nodes() +// โ†’ [{ name: "local", addr: "tai://localhost", online: true }, +// { name: "gpu-server", addr: "tai://192.168.1.100:9527", online: true }] + +// Create container with workspace (via Sandbox API) +var sb = Sandbox("my-box", { + image: "node:20", + workspace_id: ws.id, // โ†’ auto-routes to ws.node + mount_mode: "rw", +}) +``` + +--- + +## Storage Backend (Tai) + +The `storage.VolumeProvider` interface in Tai Server already has three 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 | + +Default is `BindMountProvider` for Docker environments (direct host path access for file CRUD). K8s environments use `K8sPVCProvider`. + +The Tai `Volume` gRPC service (`ReadFile`, `WriteFile`, `ListDir`, etc.) already operates on the same `dataDir/{sessionID}/` paths. No additional work needed โ€” Workspace file operations reuse existing Volume gRPC endpoints. + +--- + +## Comparison: Before vs After + +| 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` | +| Container scheduling | User picks Pool | Workspace determines Pool (node binding) | +| 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 | +| Module status | Part of sandbox/v2 | Top-level module, parallel to sandbox/v2 | + +--- + +## Implementation Plan + +### Phase 1: Core (target: week 1-2) + +| Task | Detail | +|------|--------| +| `workspace/workspace.go` | Workspace struct, MountMode, CreateOptions, metadata JSON read/write | +| `workspace/manager.go` | Manager with CRUD + file I/O (thin wrapper over tai Volume) | +| `workspace/manager_test.go` | Unit tests for CRUD and file operations | +| Node binding | `Workspace.Node` field, `Nodes()` API | +| `sandbox/v2` integration | `CreateOptions.WorkspaceID` โ†’ resolve node โ†’ force Pool โ†’ inject mount | +| `Box.Workspace()` update | Use `workspaceID` as sessionID when set | + +### Phase 2: Wire into Tai (target: week 2-3) + +| Task | Detail | +|------|--------| +| Tai Server: `VolumeProvider.MountSpec()` | Wire into container creation path | +| Tai gRPC: workspace metadata endpoints | Optional โ€” can use Volume gRPC directly for Phase 1 | +| Process + JSAPI registration | `workspace.*` processes, JS bindings | + +### Phase 3: Advanced (target: week 3+) + +| Task | Detail | +|------|--------| +| Active mount tracking | Track which containers mount which workspaces | +| Delete safety | Refuse delete if active mounts exist | +| Workspace migration | rsync between nodes (stretch goal) | +| Quota / size limits | Per-workspace storage limits | +| Snapshot / backup | Workspace snapshots for rollback | + +### Backward Compatibility + +No breaking changes. Containers created without `WorkspaceID` work exactly as before: +- `sessionID = box.id` +- No bind mount +- Workspace FS backed by Volume gRPC as today diff --git a/workspace/Makefile b/workspace/Makefile new file mode 100644 index 00000000..39bc73fa --- /dev/null +++ b/workspace/Makefile @@ -0,0 +1,36 @@ +GO ?= go +TEST_TIMEOUT ?= 120s + +.PHONY: test test-v test-cover test-race + +test: + $(GO) test -timeout=$(TEST_TIMEOUT) -count=1 ./... + +test-v: + $(GO) test -v -timeout=$(TEST_TIMEOUT) -count=1 ./... + +test-cover: + $(GO) test -v -timeout=$(TEST_TIMEOUT) -count=1 \ + -coverprofile=coverage.out -covermode=count ./... + $(GO) tool cover -func=coverage.out | tail -1 + +test-race: + $(GO) test -race -v -timeout=$(TEST_TIMEOUT) -count=1 ./... + +test-ci: + @echo "mode: count" > coverage.out + @for d in $$($(GO) list ./...); do \ + $(GO) test -v -timeout=$(TEST_TIMEOUT) -count=1 \ + -covermode=count -coverprofile=profile.out \ + -coverpkg=$$d $$d > tmp.out; \ + cat tmp.out; \ + if grep -q "^--- FAIL" tmp.out; then \ + rm -f tmp.out profile.out; \ + exit 1; \ + fi; \ + if [ -f profile.out ]; then \ + grep -v "mode:" profile.out >> coverage.out; \ + rm profile.out; \ + fi; \ + rm -f tmp.out; \ + done diff --git a/workspace/TEST.md b/workspace/TEST.md new file mode 100644 index 00000000..88b2fa21 --- /dev/null +++ b/workspace/TEST.md @@ -0,0 +1,74 @@ +# Workspace โ€” Test Specification + +Design: [DESIGN.md](./DESIGN.md) + +## Principles + +- **Black-box testing**: all `*_test.go` files use `package workspace_test` โ€” tests only access exported API +- **No Docker required**: workspace unit tests use `volume.NewLocal(t.TempDir())` via `tai.WithVolume` โ€” no Docker daemon needed +- **Skip when unavailable**: `skipIfNoTai(t)` for remote-mode tests +- **Tests follow implementation**: `*_test.go` lives next to the code it tests +- **Coverage > 80%**: per file and overall + +## Prerequisites + +No external services required for unit tests. Tests create a temp directory for storage. + +### Remote mode (optional) + +For remote-mode tests via Tai gRPC: + +```bash +SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1 go test -v ./workspace/ +``` + +## Directory Structure + +``` +workspace/ +โ”œโ”€โ”€ workspace.go # Types (Workspace, CreateOptions, MountMode, etc.) +โ”œโ”€โ”€ errors.go # Error definitions +โ”œโ”€โ”€ manager.go # Manager (CRUD, file I/O, Nodes) +โ”œโ”€โ”€ workspace_test.go # CRUD tests +โ”œโ”€โ”€ fileio_test.go # File I/O + FS tests +โ”œโ”€โ”€ testutils_test.go # Shared test helpers +โ”œโ”€โ”€ DESIGN.md # Design document +โ”œโ”€โ”€ TEST.md # This file +โ””โ”€โ”€ Makefile # Test runner +``` + +## testutils (internal to workspace_test) + +```go +// testutils_test.go +package workspace_test + +func setupManager(t *testing.T) *workspace.Manager +func setupManagerMultiNode(t *testing.T) *workspace.Manager +func localClient(t *testing.T, dataDir string) *tai.Client +func createTestWorkspace(t *testing.T, m *workspace.Manager, opts ...func(*workspace.CreateOptions)) *workspace.Workspace +func skipIfNoTai(t *testing.T) +``` + +## Required Test Cases + +| File | Required Cases | +|------|---------------| +| `workspace_test.go` | Create / Create auto ID / Create explicit ID / Create with labels / Create invalid node / Create node not found / Get / Get not found / List / List filter owner / List filter node / Update name / Update labels / Update not found / Delete / Delete not found / Nodes / NodeForWorkspace / NodeForWorkspace not found | +| `fileio_test.go` | ReadWriteFile / WriteFile nested path / ListDir / Remove file / FS ReadFile / FS WriteFile / FS MkdirAll / FS Rename / FS WalkDir / FS Remove / FS not found | + +## Running Tests + +```bash +# All workspace tests (no Docker needed) +make -C workspace test + +# Single test +go test -v ./workspace/ -run TestCreate + +# With race detector +go test -race -v ./workspace/ + +# With coverage +go test -v -coverprofile=coverage.out ./workspace/ +``` diff --git a/workspace/bench_test.go b/workspace/bench_test.go new file mode 100644 index 00000000..7e79fa82 --- /dev/null +++ b/workspace/bench_test.go @@ -0,0 +1,199 @@ +package workspace_test + +import ( + "context" + "fmt" + "io/fs" + "testing" + + "github.com/yaoapp/yao/workspace" +) + +// BenchmarkWriteFile measures workspace file write latency. +func BenchmarkWriteFile(b *testing.B) { + for _, pc := range testPools() { + b.Run(pc.Name, func(b *testing.B) { + m := setupManagerForPool(b, pc) + ws := createWorkspace(b, m, pc.Name) + ctx := context.Background() + payload := []byte("package main\nfunc main() { println(\"bench\") }\n") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := m.WriteFile(ctx, ws.ID, fmt.Sprintf("f%d.go", i), payload, 0644); err != nil { + b.Fatalf("WriteFile: %v", err) + } + } + }) + } +} + +// BenchmarkReadFile measures workspace file read latency. +func BenchmarkReadFile(b *testing.B) { + for _, pc := range testPools() { + b.Run(pc.Name, func(b *testing.B) { + m := setupManagerForPool(b, pc) + ws := createWorkspace(b, m, pc.Name) + ctx := context.Background() + if err := m.WriteFile(ctx, ws.ID, "bench.txt", []byte("benchmark data here"), 0644); err != nil { + b.Fatalf("setup WriteFile: %v", err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + data, err := m.ReadFile(ctx, ws.ID, "bench.txt") + if err != nil { + b.Fatalf("ReadFile: %v", err) + } + if len(data) == 0 { + b.Fatal("empty data") + } + } + }) + } +} + +// BenchmarkReadWriteCycle measures a full write-then-read cycle. +func BenchmarkReadWriteCycle(b *testing.B) { + for _, pc := range testPools() { + b.Run(pc.Name, func(b *testing.B) { + m := setupManagerForPool(b, pc) + ws := createWorkspace(b, m, pc.Name) + ctx := context.Background() + payload := []byte("package main\nfunc main() { println(\"cycle\") }\n") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + name := fmt.Sprintf("c%d.go", i) + if err := m.WriteFile(ctx, ws.ID, name, payload, 0644); err != nil { + b.Fatalf("WriteFile: %v", err) + } + data, err := m.ReadFile(ctx, ws.ID, name) + if err != nil { + b.Fatalf("ReadFile: %v", err) + } + if len(data) != len(payload) { + b.Fatalf("size mismatch: %d vs %d", len(data), len(payload)) + } + } + }) + } +} + +// BenchmarkWriteLargeFile measures write throughput with a 1MB payload. +func BenchmarkWriteLargeFile(b *testing.B) { + for _, pc := range testPools() { + b.Run(pc.Name, func(b *testing.B) { + m := setupManagerForPool(b, pc) + ws := createWorkspace(b, m, pc.Name) + ctx := context.Background() + payload := make([]byte, 1<<20) // 1 MB + for i := range payload { + payload[i] = byte('A' + i%26) + } + + b.SetBytes(int64(len(payload))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := m.WriteFile(ctx, ws.ID, fmt.Sprintf("large%d.bin", i), payload, 0644); err != nil { + b.Fatalf("WriteFile: %v", err) + } + } + }) + } +} + +// BenchmarkListDir measures directory listing latency (50 files). +func BenchmarkListDir(b *testing.B) { + for _, pc := range testPools() { + b.Run(pc.Name, func(b *testing.B) { + m := setupManagerForPool(b, pc) + ws := createWorkspace(b, m, pc.Name) + ctx := context.Background() + + for i := 0; i < 50; i++ { + m.WriteFile(ctx, ws.ID, fmt.Sprintf("file%d.txt", i), []byte("x"), 0644) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + entries, err := m.ListDir(ctx, ws.ID, ".") + if err != nil { + b.Fatalf("ListDir: %v", err) + } + if len(entries) < 50 { + b.Fatalf("expected >= 50 entries, got %d", len(entries)) + } + } + }) + } +} + +// BenchmarkFSWalkDir measures fs.WalkDir performance over a directory tree (45+ entries). +func BenchmarkFSWalkDir(b *testing.B) { + for _, pc := range testPools() { + b.Run(pc.Name, func(b *testing.B) { + m := setupManagerForPool(b, pc) + ws := createWorkspace(b, m, pc.Name) + ctx := context.Background() + + wfs, err := m.FS(ctx, ws.ID) + if err != nil { + b.Fatalf("FS: %v", err) + } + + for _, dir := range []string{"src", "src/pkg", "src/cmd", "lib"} { + wfs.MkdirAll(dir, 0755) + } + for i := 0; i < 20; i++ { + wfs.WriteFile(fmt.Sprintf("src/f%d.go", i), []byte("package src"), 0644) + } + for i := 0; i < 10; i++ { + wfs.WriteFile(fmt.Sprintf("src/pkg/p%d.go", i), []byte("package pkg"), 0644) + } + for i := 0; i < 10; i++ { + wfs.WriteFile(fmt.Sprintf("lib/l%d.go", i), []byte("package lib"), 0644) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + count := 0 + fs.WalkDir(wfs, ".", func(_ string, _ fs.DirEntry, err error) error { + if err != nil { + return err + } + count++ + return nil + }) + if count < 40 { + b.Fatalf("walk returned only %d entries", count) + } + } + }) + } +} + +// BenchmarkCreateDelete measures workspace CRUD cycle. +func BenchmarkCreateDelete(b *testing.B) { + for _, pc := range testPools() { + b.Run(pc.Name, func(b *testing.B) { + m := setupManagerForPool(b, pc) + ctx := context.Background() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + ws, err := m.Create(ctx, workspace.CreateOptions{ + Name: "bench-workspace", + Owner: "bench-user", + Node: pc.Name, + }) + if err != nil { + b.Fatalf("Create: %v", err) + } + if err := m.Delete(ctx, ws.ID, true); err != nil { + b.Fatalf("Delete: %v", err) + } + } + }) + } +} diff --git a/workspace/errors.go b/workspace/errors.go new file mode 100644 index 00000000..1967a6ce --- /dev/null +++ b/workspace/errors.go @@ -0,0 +1,10 @@ +package workspace + +import "errors" + +var ( + ErrNotFound = errors.New("workspace: not found") + ErrNodeMissing = errors.New("workspace: node is required") + ErrNodeOffline = errors.New("workspace: node is offline or not configured") + ErrHasMounts = errors.New("workspace: workspace has active container mounts") +) diff --git a/workspace/fileio_test.go b/workspace/fileio_test.go new file mode 100644 index 00000000..61a1f86e --- /dev/null +++ b/workspace/fileio_test.go @@ -0,0 +1,232 @@ +package workspace_test + +import ( + "context" + "io/fs" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yaoapp/yao/workspace" +) + +func TestReadWriteFile(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name) + + ctx := context.Background() + err := m.WriteFile(ctx, ws.ID, "hello.txt", []byte("hello world"), 0644) + require.NoError(t, err) + + data, err := m.ReadFile(ctx, ws.ID, "hello.txt") + require.NoError(t, err) + assert.Equal(t, "hello world", string(data)) + }) + } +} + +func TestWriteFile_NestedPath(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name) + + ctx := context.Background() + err := m.WriteFile(ctx, ws.ID, "src/main.go", []byte("package main"), 0644) + require.NoError(t, err) + + data, err := m.ReadFile(ctx, ws.ID, "src/main.go") + require.NoError(t, err) + assert.Equal(t, "package main", string(data)) + }) + } +} + +func TestListDir(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name) + + ctx := context.Background() + require.NoError(t, m.WriteFile(ctx, ws.ID, "a.txt", []byte("a"), 0644)) + require.NoError(t, m.WriteFile(ctx, ws.ID, "b.txt", []byte("b"), 0644)) + + entries, err := m.ListDir(ctx, ws.ID, ".") + require.NoError(t, err) + names := make(map[string]bool) + for _, e := range entries { + names[e.Name] = true + } + assert.True(t, names["a.txt"]) + assert.True(t, names["b.txt"]) + }) + } +} + +func TestRemoveFile(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name) + + ctx := context.Background() + require.NoError(t, m.WriteFile(ctx, ws.ID, "tmp.txt", []byte("temp"), 0644)) + + err := m.Remove(ctx, ws.ID, "tmp.txt") + require.NoError(t, err) + + _, err = m.ReadFile(ctx, ws.ID, "tmp.txt") + assert.Error(t, err) + }) + } +} + +func TestFS_ReadFile(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name) + + ctx := context.Background() + require.NoError(t, m.WriteFile(ctx, ws.ID, "test.txt", []byte("via fs"), 0644)) + + wfs, err := m.FS(ctx, ws.ID) + require.NoError(t, err) + + data, err := fs.ReadFile(wfs, "test.txt") + require.NoError(t, err) + assert.Equal(t, "via fs", string(data)) + }) + } +} + +func TestFS_WriteFile(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name) + + ctx := context.Background() + wfs, err := m.FS(ctx, ws.ID) + require.NoError(t, err) + + err = wfs.WriteFile("from-fs.txt", []byte("written via fs"), 0644) + require.NoError(t, err) + + data, err := m.ReadFile(ctx, ws.ID, "from-fs.txt") + require.NoError(t, err) + assert.Equal(t, "written via fs", string(data)) + }) + } +} + +func TestFS_MkdirAll(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name) + + ctx := context.Background() + wfs, err := m.FS(ctx, ws.ID) + require.NoError(t, err) + + err = wfs.MkdirAll("a/b/c", 0755) + require.NoError(t, err) + + info, err := fs.Stat(wfs, "a/b/c") + require.NoError(t, err) + assert.True(t, info.IsDir()) + }) + } +} + +func TestFS_Rename(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name) + + ctx := context.Background() + wfs, err := m.FS(ctx, ws.ID) + require.NoError(t, err) + + err = wfs.WriteFile("old.txt", []byte("content"), 0644) + require.NoError(t, err) + + err = wfs.Rename("old.txt", "new.txt") + require.NoError(t, err) + + data, err := fs.ReadFile(wfs, "new.txt") + require.NoError(t, err) + assert.Equal(t, "content", string(data)) + + _, err = fs.ReadFile(wfs, "old.txt") + assert.Error(t, err) + }) + } +} + +func TestFS_WalkDir(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name) + + ctx := context.Background() + wfs, err := m.FS(ctx, ws.ID) + require.NoError(t, err) + + require.NoError(t, wfs.MkdirAll("src", 0755)) + require.NoError(t, wfs.WriteFile("src/main.go", []byte("package main"), 0644)) + require.NoError(t, wfs.WriteFile("src/util.go", []byte("package main"), 0644)) + + var files []string + err = fs.WalkDir(wfs, "src", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + files = append(files, path) + } + return nil + }) + require.NoError(t, err) + assert.Len(t, files, 2) + }) + } +} + +func TestFS_Remove(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name) + + ctx := context.Background() + wfs, err := m.FS(ctx, ws.ID) + require.NoError(t, err) + + require.NoError(t, wfs.WriteFile("removeme.txt", []byte("bye"), 0644)) + + err = wfs.Remove("removeme.txt") + require.NoError(t, err) + + _, err = fs.ReadFile(wfs, "removeme.txt") + assert.Error(t, err) + }) + } +} + +func TestFS_NotFound(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + _, err := m.FS(context.Background(), "nonexistent") + assert.ErrorIs(t, err, workspace.ErrNotFound) + }) + } +} diff --git a/workspace/manager.go b/workspace/manager.go new file mode 100644 index 00000000..8c4bb3dd --- /dev/null +++ b/workspace/manager.go @@ -0,0 +1,319 @@ +package workspace + +import ( + "context" + "fmt" + "os" + "sync" + "time" + + "github.com/yaoapp/yao/tai" + taiworkspace "github.com/yaoapp/yao/tai/workspace" +) + +// Manager owns workspace CRUD, file I/O, and node management. +// Pools are shared with sandbox.Manager โ€” both reference the same tai.Client instances. +type Manager struct { + pools map[string]*tai.Client + mu sync.RWMutex +} + +// NewManager creates a workspace manager with the given pools. +func NewManager(pools map[string]*tai.Client) *Manager { + if pools == nil { + pools = make(map[string]*tai.Client) + } + return &Manager{pools: pools} +} + +// Create allocates storage on the target node and persists metadata. +func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Workspace, error) { + if opts.Node == "" { + return nil, ErrNodeMissing + } + + client, err := m.getClient(opts.Node) + if err != nil { + return nil, err + } + + id := opts.ID + if id == "" { + id = generateID() + } + + now := time.Now().UTC() + ws := &Workspace{ + ID: id, + Name: opts.Name, + Owner: opts.Owner, + Node: opts.Node, + Labels: opts.Labels, + CreatedAt: now, + UpdatedAt: now, + } + + vol := client.Volume() + + if err := vol.MkdirAll(ctx, id, "."); err != nil { + return nil, fmt.Errorf("workspace: create directory: %w", err) + } + + data, err := marshalMeta(ws) + if err != nil { + return nil, err + } + if err := vol.WriteFile(ctx, id, metadataFile, data, 0644); err != nil { + return nil, fmt.Errorf("workspace: write metadata: %w", err) + } + + return ws, nil +} + +// Get returns a workspace by ID. +// If the node is unknown, scans all pools. +func (m *Manager) Get(ctx context.Context, id string) (*Workspace, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + for nodeName, client := range m.pools { + ws, err := m.readMeta(ctx, client, id) + if err != nil { + continue + } + if ws.Node == "" { + ws.Node = nodeName + } + return ws, nil + } + return nil, ErrNotFound +} + +// List returns workspaces, optionally filtered by owner and/or node. +func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Workspace, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var result []*Workspace + for nodeName, client := range m.pools { + if opts.Node != "" && nodeName != opts.Node { + continue + } + entries, err := client.Volume().ListDir(ctx, "", ".") + if err != nil { + continue + } + for _, e := range entries { + if !e.IsDir { + continue + } + ws, err := m.readMeta(ctx, client, e.Path) + if err != nil { + continue + } + if ws.Node == "" { + ws.Node = nodeName + } + if opts.Owner != "" && ws.Owner != opts.Owner { + continue + } + result = append(result, ws) + } + } + return result, nil +} + +// Update modifies workspace metadata (Name, Labels). +// Node and Owner are immutable after creation. +func (m *Manager) Update(ctx context.Context, id string, opts UpdateOptions) (*Workspace, error) { + ws, client, err := m.resolve(ctx, id) + if err != nil { + return nil, err + } + + if opts.Name != nil { + ws.Name = *opts.Name + } + if opts.Labels != nil { + ws.Labels = opts.Labels + } + ws.UpdatedAt = time.Now().UTC() + + data, err := marshalMeta(ws) + if err != nil { + return nil, err + } + if err := client.Volume().WriteFile(ctx, id, metadataFile, data, 0644); err != nil { + return nil, fmt.Errorf("workspace: write metadata: %w", err) + } + return ws, nil +} + +// Delete removes workspace storage from the node. +func (m *Manager) Delete(ctx context.Context, id string, force bool) error { + _, client, err := m.resolve(ctx, id) + if err != nil { + return err + } + + vol := client.Volume() + if err := vol.Remove(ctx, id, ".", true); err != nil { + return fmt.Errorf("workspace: remove: %w", err) + } + return nil +} + +// Nodes returns all configured Tai nodes with their online status. +func (m *Manager) Nodes() []NodeInfo { + m.mu.RLock() + defer m.mu.RUnlock() + + nodes := make([]NodeInfo, 0, len(m.pools)) + for name := range m.pools { + nodes = append(nodes, NodeInfo{ + Name: name, + Online: true, + }) + } + return nodes +} + +// FS returns an fs.FS-compatible filesystem for the given workspace. +func (m *Manager) FS(ctx context.Context, id string) (taiworkspace.FS, error) { + ws, client, err := m.resolve(ctx, id) + if err != nil { + return nil, err + } + _ = ws + return client.Workspace(id), nil +} + +// ReadFile reads a file from the workspace. +func (m *Manager) ReadFile(ctx context.Context, id string, path string) ([]byte, error) { + _, client, err := m.resolve(ctx, id) + if err != nil { + return nil, err + } + data, _, err := client.Volume().ReadFile(ctx, id, path) + return data, err +} + +// WriteFile writes a file to the workspace. +func (m *Manager) WriteFile(ctx context.Context, id string, path string, data []byte, perm os.FileMode) error { + _, client, err := m.resolve(ctx, id) + if err != nil { + return err + } + return client.Volume().WriteFile(ctx, id, path, data, perm) +} + +// ListDir lists entries in a workspace directory. +func (m *Manager) ListDir(ctx context.Context, id string, path string) ([]DirEntry, error) { + _, client, err := m.resolve(ctx, id) + if err != nil { + return nil, err + } + entries, err := client.Volume().ListDir(ctx, id, path) + if err != nil { + return nil, err + } + result := make([]DirEntry, len(entries)) + for i, e := range entries { + result[i] = DirEntry{ + Name: e.Path, + IsDir: e.IsDir, + Size: e.Size, + } + } + return result, nil +} + +// Remove deletes a file or directory from the workspace. +func (m *Manager) Remove(ctx context.Context, id string, path string) error { + _, client, err := m.resolve(ctx, id) + if err != nil { + return err + } + return client.Volume().Remove(ctx, id, path, true) +} + +// AddPool registers a new Tai node. +func (m *Manager) AddPool(name string, client *tai.Client) { + m.mu.Lock() + defer m.mu.Unlock() + m.pools[name] = client +} + +// RemovePool unregisters a Tai node. +func (m *Manager) RemovePool(name string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.pools, name) +} + +// NodeForWorkspace returns the node name for a given workspace ID. +// Used by sandbox.Manager to route container creation to the correct pool. +func (m *Manager) NodeForWorkspace(ctx context.Context, id string) (string, error) { + ws, _, err := m.resolve(ctx, id) + if err != nil { + return "", err + } + return ws.Node, nil +} + +// MountPath returns the host-side directory path for a workspace, +// suitable for use as a Docker bind mount source. +// For local volumes this is dataDir/{id}; for remote (Tai) the server handles mounts. +func (m *Manager) MountPath(ctx context.Context, id string) (string, error) { + _, client, err := m.resolve(ctx, id) + if err != nil { + return "", err + } + dataDir := client.DataDir() + if dataDir == "" { + return "", nil + } + return dataDir + "/" + id, nil +} + +// --- internal --- + +func (m *Manager) getClient(node string) (*tai.Client, error) { + m.mu.RLock() + defer m.mu.RUnlock() + client, ok := m.pools[node] + if !ok { + return nil, ErrNodeOffline + } + return client, nil +} + +// resolve finds the workspace and its tai.Client by scanning pools. +func (m *Manager) resolve(ctx context.Context, id string) (*Workspace, *tai.Client, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, client := range m.pools { + ws, err := m.readMeta(ctx, client, id) + if err != nil { + continue + } + return ws, client, nil + } + return nil, nil, ErrNotFound +} + +func (m *Manager) readMeta(ctx context.Context, client *tai.Client, id string) (*Workspace, error) { + data, _, err := client.Volume().ReadFile(ctx, id, metadataFile) + if err != nil { + return nil, err + } + return unmarshalMeta(data) +} + +// DirEntry represents a file or directory entry in a workspace listing. +type DirEntry struct { + Name string `json:"name"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` +} diff --git a/workspace/testutils_test.go b/workspace/testutils_test.go new file mode 100644 index 00000000..99b37a4f --- /dev/null +++ b/workspace/testutils_test.go @@ -0,0 +1,89 @@ +package workspace_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/yaoapp/yao/tai" + "github.com/yaoapp/yao/tai/volume" + "github.com/yaoapp/yao/workspace" +) + +type poolConfig struct { + Name string + Addr string +} + +func testPools() []poolConfig { + pools := []poolConfig{ + {Name: "local", Addr: "local"}, + } + if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" { + pools = append(pools, poolConfig{Name: "remote", Addr: addr}) + } + return pools +} + +func setupManagerForPool(tb testing.TB, pc poolConfig) *workspace.Manager { + tb.Helper() + client := clientForPool(tb, pc) + pools := map[string]*tai.Client{pc.Name: client} + return workspace.NewManager(pools) +} + +func clientForPool(tb testing.TB, pc poolConfig) *tai.Client { + tb.Helper() + if pc.Addr == "local" { + return localClient(tb, tb.TempDir()) + } + client, err := tai.New(pc.Addr) + if err != nil { + tb.Fatalf("tai.New(%s): %v", pc.Addr, err) + } + tb.Cleanup(func() { client.Close() }) + return client +} + +func localClient(tb testing.TB, dataDir string) *tai.Client { + tb.Helper() + vol := volume.NewLocal(dataDir) + client, err := tai.New("local", tai.WithVolume(vol), tai.WithDataDir(dataDir)) + if err != nil { + tb.Fatalf("tai.New local: %v", err) + } + tb.Cleanup(func() { client.Close() }) + return client +} + +func setupManagerMultiNode(t *testing.T) *workspace.Manager { + t.Helper() + pools := map[string]*tai.Client{ + "node-a": localClient(t, t.TempDir()), + "node-b": localClient(t, t.TempDir()), + } + return workspace.NewManager(pools) +} + +func createWorkspace(tb testing.TB, m *workspace.Manager, node string, opts ...func(*workspace.CreateOptions)) *workspace.Workspace { + tb.Helper() + co := workspace.CreateOptions{ + Name: "test-workspace", + Owner: "test-user", + Node: node, + } + for _, fn := range opts { + fn(&co) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + ws, err := m.Create(ctx, co) + if err != nil { + tb.Fatalf("Create workspace: %v", err) + } + tb.Cleanup(func() { + m.Delete(context.Background(), ws.ID, true) + }) + return ws +} diff --git a/workspace/workspace.go b/workspace/workspace.go new file mode 100644 index 00000000..8f273198 --- /dev/null +++ b/workspace/workspace.go @@ -0,0 +1,78 @@ +package workspace + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" +) + +// MountMode controls read-write or read-only access when a workspace is +// bind-mounted into a container. +type MountMode string + +const ( + MountRW MountMode = "rw" + MountRO MountMode = "ro" +) + +const metadataFile = ".workspace.json" + +// Workspace is a persistent, user-managed storage entity. +// It is pinned to a specific Tai node (host machine) at creation time; +// containers referencing this workspace are automatically routed to that node. +type Workspace struct { + ID string `json:"id"` + Name string `json:"name"` + Owner string `json:"owner"` + Node string `json:"node"` + Labels map[string]string `json:"labels,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateOptions configures a new workspace. +type CreateOptions struct { + ID string // explicit ID; empty = auto-generate (uuid) + Name string // human-readable name + Owner string // user ID + Node string // target Tai node (required) + Labels map[string]string // arbitrary metadata +} + +// ListOptions filters workspace listing. +type ListOptions struct { + Owner string // filter by owner; empty = all + Node string // filter by node; empty = all +} + +// UpdateOptions specifies which metadata fields to change. +// nil fields are left unchanged. Node and Owner are immutable. +type UpdateOptions struct { + Name *string // nil = no change + Labels map[string]string // nil = no change; non-nil replaces all labels +} + +// NodeInfo describes a Tai node available for workspace storage. +type NodeInfo struct { + Name string // pool name = node name + Addr string // tai:// address + Online bool // tai client is connected +} + +func generateID() string { + return fmt.Sprintf("ws-%s", uuid.New().String()[:12]) +} + +func marshalMeta(ws *Workspace) ([]byte, error) { + return json.MarshalIndent(ws, "", " ") +} + +func unmarshalMeta(data []byte) (*Workspace, error) { + var ws Workspace + if err := json.Unmarshal(data, &ws); err != nil { + return nil, fmt.Errorf("workspace: invalid metadata: %w", err) + } + return &ws, nil +} diff --git a/workspace/workspace_test.go b/workspace/workspace_test.go new file mode 100644 index 00000000..f5949cfd --- /dev/null +++ b/workspace/workspace_test.go @@ -0,0 +1,323 @@ +package workspace_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yaoapp/yao/tai" + "github.com/yaoapp/yao/tai/volume" + "github.com/yaoapp/yao/workspace" +) + +func TestCreate(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name) + + assert.NotEmpty(t, ws.ID) + assert.Equal(t, "test-workspace", ws.Name) + assert.Equal(t, "test-user", ws.Owner) + assert.Equal(t, pc.Name, ws.Node) + assert.False(t, ws.CreatedAt.IsZero()) + assert.False(t, ws.UpdatedAt.IsZero()) + }) + } +} + +func TestCreate_AutoID(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name) + + assert.True(t, len(ws.ID) > 0) + assert.Contains(t, ws.ID, "ws-") + }) + } +} + +func TestCreate_ExplicitID(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) { + co.ID = "my-custom-id" + }) + + assert.Equal(t, "my-custom-id", ws.ID) + }) + } +} + +func TestCreate_WithLabels(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) { + co.Labels = map[string]string{"project": "frontend", "env": "dev"} + }) + + assert.Equal(t, "frontend", ws.Labels["project"]) + assert.Equal(t, "dev", ws.Labels["env"]) + }) + } +} + +func TestCreate_InvalidNode(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + _, err := m.Create(context.Background(), workspace.CreateOptions{ + Name: "bad", + Owner: "user", + Node: "", + }) + assert.ErrorIs(t, err, workspace.ErrNodeMissing) + }) + } +} + +func TestCreate_NodeNotFound(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + _, err := m.Create(context.Background(), workspace.CreateOptions{ + Name: "bad", + Owner: "user", + Node: "nonexistent-node", + }) + assert.ErrorIs(t, err, workspace.ErrNodeOffline) + }) + } +} + +func TestGet(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name) + + got, err := m.Get(context.Background(), ws.ID) + require.NoError(t, err) + assert.Equal(t, ws.ID, got.ID) + assert.Equal(t, ws.Name, got.Name) + assert.Equal(t, ws.Owner, got.Owner) + assert.Equal(t, ws.Node, got.Node) + }) + } +} + +func TestGet_NotFound(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + _, err := m.Get(context.Background(), "nonexistent") + assert.ErrorIs(t, err, workspace.ErrNotFound) + }) + } +} + +func TestList(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) { co.Name = "ws-1" }) + createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) { co.Name = "ws-2" }) + + list, err := m.List(context.Background(), workspace.ListOptions{}) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(list), 2) + }) + } +} + +func TestList_FilterOwner(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) { + co.Owner = "alice" + co.Name = "alice-ws" + }) + createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) { + co.Owner = "bob" + co.Name = "bob-ws" + }) + + list, err := m.List(context.Background(), workspace.ListOptions{Owner: "alice"}) + require.NoError(t, err) + assert.Len(t, list, 1) + assert.Equal(t, "alice", list[0].Owner) + }) + } +} + +func TestList_FilterNode(t *testing.T) { + m := setupManagerMultiNode(t) + + ctx := context.Background() + _, err := m.Create(ctx, workspace.CreateOptions{Name: "a", Owner: "u", Node: "node-a"}) + require.NoError(t, err) + _, err = m.Create(ctx, workspace.CreateOptions{Name: "b", Owner: "u", Node: "node-b"}) + require.NoError(t, err) + + list, err := m.List(ctx, workspace.ListOptions{Node: "node-a"}) + require.NoError(t, err) + assert.Len(t, list, 1) + assert.Equal(t, "node-a", list[0].Node) +} + +func TestUpdate_Name(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name) + + newName := "renamed-workspace" + updated, err := m.Update(context.Background(), ws.ID, workspace.UpdateOptions{ + Name: &newName, + }) + require.NoError(t, err) + assert.Equal(t, newName, updated.Name) + assert.Equal(t, ws.Owner, updated.Owner) + assert.True(t, updated.UpdatedAt.After(ws.UpdatedAt) || updated.UpdatedAt.Equal(ws.UpdatedAt)) + }) + } +} + +func TestUpdate_Labels(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name, func(co *workspace.CreateOptions) { + co.Labels = map[string]string{"old": "value"} + }) + + updated, err := m.Update(context.Background(), ws.ID, workspace.UpdateOptions{ + Labels: map[string]string{"new": "label"}, + }) + require.NoError(t, err) + assert.Equal(t, "label", updated.Labels["new"]) + assert.Empty(t, updated.Labels["old"]) + }) + } +} + +func TestUpdate_NotFound(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + _, err := m.Update(context.Background(), "nonexistent", workspace.UpdateOptions{}) + assert.ErrorIs(t, err, workspace.ErrNotFound) + }) + } +} + +func TestDelete(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws, err := m.Create(context.Background(), workspace.CreateOptions{ + Name: "to-delete", Owner: "user", Node: pc.Name, + }) + require.NoError(t, err) + + err = m.Delete(context.Background(), ws.ID, false) + require.NoError(t, err) + + _, err = m.Get(context.Background(), ws.ID) + assert.ErrorIs(t, err, workspace.ErrNotFound) + }) + } +} + +func TestDelete_NotFound(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + err := m.Delete(context.Background(), "nonexistent", false) + assert.ErrorIs(t, err, workspace.ErrNotFound) + }) + } +} + +func TestNodes(t *testing.T) { + m := setupManagerMultiNode(t) + nodes := m.Nodes() + assert.Len(t, nodes, 2) + + names := make(map[string]bool) + for _, n := range nodes { + names[n.Name] = true + assert.True(t, n.Online) + } + assert.True(t, names["node-a"]) + assert.True(t, names["node-b"]) +} + +func TestNodeForWorkspace(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ws := createWorkspace(t, m, pc.Name) + + node, err := m.NodeForWorkspace(context.Background(), ws.ID) + require.NoError(t, err) + assert.Equal(t, pc.Name, node) + }) + } +} + +func TestNodeForWorkspace_NotFound(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + _, err := m.NodeForWorkspace(context.Background(), "nonexistent") + assert.ErrorIs(t, err, workspace.ErrNotFound) + }) + } +} + +func TestAddPool(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + assert.Len(t, m.Nodes(), 1) + + vol := volume.NewLocal(t.TempDir()) + client, err := tai.New("local", tai.WithVolume(vol)) + require.NoError(t, err) + defer client.Close() + + m.AddPool("new-node", client) + assert.Len(t, m.Nodes(), 2) + }) + } +} + +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) { + m := setupManagerForPool(t, poolConfig{Name: "local", Addr: "local"}) + ws := createWorkspace(t, m, "local") + + mountPath, err := m.MountPath(context.Background(), ws.ID) + require.NoError(t, err) + assert.Contains(t, mountPath, ws.ID) +} + +func TestMountPath_NotFound(t *testing.T) { + m := setupManagerForPool(t, poolConfig{Name: "local", Addr: "local"}) + _, err := m.MountPath(context.Background(), "nonexistent") + assert.ErrorIs(t, err, workspace.ErrNotFound) +}